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