Browse Source

Remove custom-role recovery document

pull/7397/head
tom27052006 3 weeks ago
parent
commit
af2e180e02
  1. 200
      docs/custom-role-migration-recovery.md
  2. 8
      src/db/mod.rs

200
docs/custom-role-migration-recovery.md

@ -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.

8
src/db/mod.rs

@ -472,7 +472,6 @@ const CUSTOM_ROLE_REPAIR_MIGRATION: &str = "20260723120000";
const CUSTOM_COLLECTION_PERMISSIONS_MIGRATION: &str = "20260716120000"; const CUSTOM_COLLECTION_PERMISSIONS_MIGRATION: &str = "20260716120000";
const DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION: &str = "20260724120000"; const DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION: &str = "20260724120000";
const CUSTOM_ROLE_SAME_RUN_MARKER_TABLE: &str = "__vw_custom_role_same_run_0716"; const CUSTOM_ROLE_SAME_RUN_MARKER_TABLE: &str = "__vw_custom_role_same_run_0716";
const CUSTOM_ROLE_MIGRATION_RECOVERY_DOC: &str = "docs/custom-role-migration-recovery.md";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[expect( #[expect(
@ -588,8 +587,8 @@ fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: Cus
}; };
std::io::Error::other(format!( std::io::Error::other(format!(
"Custom-role migration preflight stopped startup: {detail} Back up the database and follow \ "Custom-role migration preflight stopped startup: {detail} Back up the database and resolve \
{CUSTOM_ROLE_MIGRATION_RECOVERY_DOC}." the legacy membership state manually before restarting."
)) ))
.into() .into()
} }
@ -855,9 +854,8 @@ mod mysql_migrations {
{version} schema, but its column definitions or data were modified \ {version} schema, but its column definitions or data were modified \
(matching columns: {matching_column_definitions}/3, unexpected rows: \ (matching columns: {matching_column_definitions}/3, unexpected rows: \
{unexpected_values}). Refusing automatic recovery. Back up the database and \ {unexpected_values}). Refusing automatic recovery. Back up the database and \
follow {doc}.", resolve the partial migration manually before restarting.",
version = super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION, version = super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION,
doc = super::CUSTOM_ROLE_MIGRATION_RECOVERY_DOC,
)) ))
.into()); .into());
} }

Loading…
Cancel
Save