2 changed files with 3 additions and 205 deletions
@ -1,200 +0,0 @@ |
|||||
# Custom-role migration recovery |
|
||||
|
|
||||
Vaultwarden deliberately stops the Custom-role migration when the old database state cannot be |
|
||||
translated without either removing access or adding new management authority. A failed preflight |
|
||||
does not authorize Vaultwarden to choose between those outcomes. |
|
||||
|
|
||||
## Before doing anything |
|
||||
|
|
||||
1. Stop every Vaultwarden instance that uses the database. Do not perform this migration during a |
|
||||
rolling deployment. |
|
||||
2. Take and verify a full database backup. |
|
||||
3. Keep the complete startup error. It identifies the state that needs review. |
|
||||
4. Do not add or delete rows in `__diesel_schema_migrations` merely to bypass the preflight. |
|
||||
|
|
||||
The relevant versions are: |
|
||||
|
|
||||
| Version | Purpose | |
|
||||
|---|---| |
|
||||
| `2026-07-15-120000` | Mark that `2026-07-16` is pending in the same migration sequence | |
|
||||
| `2026-07-16-120000` | Add the three collection-permission columns | |
|
||||
| `2026-07-23-120000` | Reconcile legacy Manager/Custom membership permissions | |
|
||||
| `2026-07-24-120000` | Drop membership-level `access_all` | |
|
||||
| `2026-07-24-130000` | Add the three Custom Access permissions | |
|
||||
| `2026-07-24-140000` | Refuse a lossy Custom-role downgrade | |
|
||||
|
|
||||
Diesel stores these directory versions without punctuation in `__diesel_schema_migrations` (for |
|
||||
example, `2026-07-16-120000` is stored as `20260716120000`). |
|
||||
The immutable `2026-06-30-120000` migration is an earlier prerequisite; this table focuses on the |
|
||||
new marker/repair/drop/downgrade safety sequence. |
|
||||
|
|
||||
## Legacy User with membership `access_all` |
|
||||
|
|
||||
Find the affected memberships before the source column is dropped: |
|
||||
|
|
||||
```sql |
|
||||
SELECT uuid, user_uuid, org_uuid, status |
|
||||
FROM users_organizations |
|
||||
WHERE atype = 2 AND access_all = TRUE; |
|
||||
``` |
|
||||
|
|
||||
This state was accepted by older Vaultwarden versions. It has no exact representation in the new |
|
||||
nine-bit Custom-role model: |
|
||||
|
|
||||
- clearing `access_all` keeps the User role but removes organization-wide vault access; |
|
||||
- changing the member to Custom with all three collection permissions preserves broad vault access, |
|
||||
but also grants collection-management capabilities the old User role did not have. |
|
||||
|
|
||||
An organization owner must decide the intended role and permissions for each result. Make that |
|
||||
change on the backed-up pre-drop database and record the decision: |
|
||||
|
|
||||
- to keep the member a normal User, set that membership's `access_all` to false; |
|
||||
- to intentionally promote the member to Custom with Create/Edit/Delete-any authority, set that |
|
||||
membership's `atype` to the legacy Manager value `3` and keep `access_all` true. The repair copies |
|
||||
the bit to all three collection permissions before converting `atype` to `4`. |
|
||||
|
|
||||
Apply either change by exact membership UUID while all Vaultwarden instances are stopped. Do not |
|
||||
bulk-promote these records automatically. |
|
||||
|
|
||||
## Group-derived legacy collection management |
|
||||
|
|
||||
An organization-local `groups.access_all` relationship is safe when the membership has no direct |
|
||||
collection permissions. During a normal upgrade, the older `2026-07-16` migration temporarily |
|
||||
copies that relationship to the exact direct `create/edit/delete = 0/1/1` pattern. The new repair |
|
||||
recognizes the still-present, organization-bound source and deterministically resets the direct |
|
||||
Edit/Delete bits to false **only** when the durable `2026-07-15` marker proves that `2026-07-16` was |
|
||||
pending in the same migration sequence. Vault access remains group-derived, so removing the member |
|
||||
from the group also removes that access. |
|
||||
|
|
||||
The marker survives a process failure between `2026-07-16` and `2026-07-23`, allowing the next |
|
||||
startup to finish the deterministic repair. `2026-07-23` transactionally clears the marker row only |
|
||||
after all guards and data updates succeed. The empty internal bookkeeping table is intentionally |
|
||||
retained so MySQL does not introduce a DDL commit boundary. Do not create, remove, or populate |
|
||||
`__vw_custom_role_same_run_0716` manually. |
|
||||
|
|
||||
The preflight stops only when the three columns already exist and it finds a `0/1/1` pattern. At |
|
||||
that point the values may be either an intentional direct Edit+Delete grant or an older group |
|
||||
backfill whose source group was removed; the database has no provenance bit that can distinguish |
|
||||
them. |
|
||||
|
|
||||
For each stopped `0/1/1` membership, the owner must choose one of these executable outcomes: |
|
||||
|
|
||||
- **Group-derived or obsolete:** set `edit_any_collection` and `delete_any_collection` to false for |
|
||||
that exact membership. Leave the intended group relationship in place if access should remain |
|
||||
group-derived. The next preflight can then proceed. |
|
||||
- **Intentionally direct Edit+Delete:** while every server is stopped, temporarily set |
|
||||
`create_new_collections` to true for that exact membership. The unambiguous `1/1/1` state passes |
|
||||
the repair and is not treated as a group backfill. Run the migration in a maintenance instance |
|
||||
that is not reachable by clients, stop it as soon as all six recovery-sequence versions listed |
|
||||
above are recorded, then set `create_new_collections` back to false before normal service resumes. |
|
||||
This restores the explicitly reviewed direct `0/1/1` state after the repair marker exists. |
|
||||
|
|
||||
The organization boundary used to identify a current group source is: |
|
||||
|
|
||||
```sql |
|
||||
SELECT DISTINCT uo.uuid, uo.org_uuid, g.uuid AS group_uuid |
|
||||
FROM users_organizations AS uo |
|
||||
INNER JOIN groups_users AS gu ON gu.users_organizations_uuid = uo.uuid |
|
||||
INNER JOIN groups AS g ON g.uuid = gu.groups_uuid |
|
||||
WHERE uo.atype IN (3, 4) |
|
||||
AND uo.access_all = FALSE |
|
||||
AND g.organizations_uuid = uo.org_uuid |
|
||||
AND g.access_all = TRUE; |
|
||||
``` |
|
||||
|
|
||||
On MySQL, quote the table as `` `groups` ``. Review direct `0/1/1` records separately: |
|
||||
|
|
||||
```sql |
|
||||
SELECT uuid, user_uuid, org_uuid |
|
||||
FROM users_organizations |
|
||||
WHERE atype IN (3, 4) |
|
||||
AND access_all = FALSE |
|
||||
AND create_new_collections = FALSE |
|
||||
AND edit_any_collection = TRUE |
|
||||
AND delete_any_collection = TRUE; |
|
||||
``` |
|
||||
|
|
||||
Because an explicit Edit+Delete assignment has the same stored values as the historical derived |
|
||||
state, Vaultwarden cannot classify those records automatically. Never use the temporary Create bit |
|
||||
while a server is accepting client traffic. |
|
||||
|
|
||||
## The `access_all` column was already dropped |
|
||||
|
|
||||
If `2026-07-24-120000` is recorded but `2026-07-23-120000` is not, restore a backup from before the |
|
||||
drop and migrate again after resolving the cases above. The old membership bit is no longer present, |
|
||||
so a later migration cannot prove which members had it. |
|
||||
|
|
||||
If no such backup exists, perform a membership-by-membership authorization review using |
|
||||
administrative records before changing roles or flags. Only after the final state has been reviewed |
|
||||
may an operator mark the repair version as resolved. Vaultwarden intentionally provides no automatic |
|
||||
command for this irreversible case. |
|
||||
|
|
||||
## Historical MySQL partial `2026-07-16` migration |
|
||||
|
|
||||
An older branch revision could fail on the unquoted `groups` identifier after MySQL had already |
|
||||
committed all three `ADD COLUMN` statements. The migration version was not recorded, so a normal |
|
||||
retry then failed on duplicate columns. |
|
||||
|
|
||||
Vaultwarden automatically completes this state only when all of the following are true: |
|
||||
|
|
||||
- `2026-07-16-120000` is absent from the ledger; |
|
||||
- all three expected columns exist, are non-null booleans, and default to false; |
|
||||
- `access_all` still exists and `2026-07-24-120000` has not run; |
|
||||
- the stored values are either the untouched false defaults, the values produced by the canonical |
|
||||
membership-`access_all` copy, or exact `0/1/1` values accompanied by both the durable same-run |
|
||||
marker and a current same-organization `groups.access_all` source; and |
|
||||
- neither a legacy User/access-all case nor ambiguous group provenance exists. |
|
||||
|
|
||||
It then reapplies the canonical membership data copy and inserts the ledger row in one transaction. |
|
||||
For the narrowly accepted same-run `0/1/1` crash state, that copy first reconstructs `0/0/0`; the |
|
||||
pending canonical group backfill and `2026-07-23` repair then run normally. A missing group source, |
|
||||
any other partial column set, changed definition, or unexpected value stops startup. Preserve that |
|
||||
database and repair it manually from the verified backup; do not drop columns that may contain |
|
||||
independently changed permissions. |
|
||||
|
|
||||
## Verification after recovery |
|
||||
|
|
||||
After a successful start, verify: |
|
||||
|
|
||||
```sql |
|
||||
SELECT version |
|
||||
FROM __diesel_schema_migrations |
|
||||
WHERE version IN ( |
|
||||
'20260715120000', |
|
||||
'20260716120000', |
|
||||
'20260723120000', |
|
||||
'20260724120000', |
|
||||
'20260724130000', |
|
||||
'20260724140000' |
|
||||
) |
|
||||
ORDER BY version; |
|
||||
|
|
||||
SELECT COUNT(*) AS invalid_manager_types |
|
||||
FROM users_organizations |
|
||||
WHERE atype = 3; |
|
||||
``` |
|
||||
|
|
||||
All six versions must be present and `invalid_manager_types` must be zero. Then test a fresh login, |
|
||||
sync, collection read/edit/delete, and group removal for every membership that was reviewed. |
|
||||
|
|
||||
## Downgrade guard |
|
||||
|
|
||||
The old schema cannot encode nine independent permissions in its single membership `access_all` |
|
||||
bit. Even a state that currently happens to use only `0/0/0` or `1/1/1` could be changed after a |
|
||||
one-step guard was reverted and before a later incremental downgrade. A conditional guard would |
|
||||
therefore create false confidence. |
|
||||
|
|
||||
The newest migration always stops an automatic downgrade with a duplicate-key error in the |
|
||||
`__vw_custom_role_downgrade_guard` temporary table, before any production permission column or |
|
||||
migration-ledger row is removed. This mechanism is enforced by primary keys on SQLite, PostgreSQL, |
|
||||
MySQL 5.7+, and MariaDB; it does not rely on historically ignored MySQL `CHECK` constraints. This is |
|
||||
intentional. |
|
||||
|
|
||||
Rollback requires either: |
|
||||
|
|
||||
- restoring a verified database backup taken before the Custom-role upgrade; or |
|
||||
- an explicit offline transformation plan that exports all permissions, defines the accepted |
|
||||
semantic loss or role changes membership by membership, and is tested against a disposable copy |
|
||||
on the same database backend. |
|
||||
|
|
||||
Do not delete the `20260724140000` ledger row merely to bypass this protection. |
|
||||
Loading…
Reference in new issue