9.6 KiB
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
- Stop every Vaultwarden instance that uses the database. Do not perform this migration during a rolling deployment.
- Take and verify a full database backup.
- Keep the complete startup error. It identifies the state that needs review.
- Do not add or delete rows in
__diesel_schema_migrationsmerely 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:
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_allkeeps 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_allto false; - to intentionally promote the member to Custom with Create/Edit/Delete-any authority, set that
membership's
atypeto the legacy Manager value3and keepaccess_alltrue. The repair copies the bit to all three collection permissions before convertingatypeto4.
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_collectionanddelete_any_collectionto 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_collectionsto true for that exact membership. The unambiguous1/1/1state 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 setcreate_new_collectionsback to false before normal service resumes. This restores the explicitly reviewed direct0/1/1state after the repair marker exists.
The organization boundary used to identify a current group source is:
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:
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-120000is absent from the ledger;- all three expected columns exist, are non-null booleans, and default to false;
access_allstill exists and2026-07-24-120000has not run;- the stored values are either the untouched false defaults, the values produced by the canonical
membership-
access_allcopy, or exact0/1/1values accompanied by both the durable same-run marker and a current same-organizationgroups.access_allsource; 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:
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.