69 changed files with 3360 additions and 5349 deletions
@ -1,37 +1,127 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine |
|||
-- granular permissions. |
|||
-- |
|||
-- Two different columns are called `access_all`, and everything below depends on keeping them apart: |
|||
-- |
|||
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped |
|||
-- at the end of this file. |
|||
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read |
|||
-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group |
|||
-- members access to every collection afterwards exactly as before. |
|||
-- |
|||
-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection |
|||
-- through either. Only the membership bit is going away, but the capability an owner configured |
|||
-- through either route is preserved, so both are read below. While this file runs the membership |
|||
-- column still exists and `atype = 3` still unambiguously means "legacy Manager". |
|||
-- |
|||
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates |
|||
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort |
|||
-- below as nothing but a driver-level duplicate-key error. |
|||
-- |
|||
-- A temporary table on purpose: on MySQL/MariaDB it is the only DDL that does not commit implicitly, |
|||
-- so a refusal cannot leave a half-applied migration behind. |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
-- A plain User carrying membership `access_all`, reachable only on databases written before the web |
|||
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and |
|||
-- future, with no management authority, and the new model has no permission for that: |
|||
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. |
|||
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when |
|||
-- such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- One ALTER TABLE for all nine columns: MySQL/MariaDB commit every DDL statement implicitly, so nine |
|||
-- separate statements would leave nine points at which a crash produces a partially migrated schema. |
|||
-- A single ALTER is one such point, and on MySQL 8 it is atomic. |
|||
ALTER TABLE users_organizations |
|||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives |
|||
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard |
|||
-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management |
|||
-- capability it is configured with right now: |
|||
-- |
|||
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three |
|||
-- collection permissions, including creating collections; |
|||
-- * an organization-local `access_all` group covered editing and deleting every collection, but |
|||
-- never creation -- that always required the membership bit; |
|||
-- * a Manager with neither keeps all three at FALSE. |
|||
-- |
|||
-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the |
|||
-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on |
|||
-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL |
|||
-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the |
|||
-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its |
|||
-- own. The alternative is silently revoking access these members have today, or refusing an ordinary |
|||
-- upgrade; the permission is visible in the member's permission list and an owner can clear it. |
|||
-- |
|||
-- The management (manage_users / manage_groups / manage_policies) and access (event logs / |
|||
-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager |
|||
-- capability -- every member mutation, every policy write, the organization export and both |
|||
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. |
|||
-- |
|||
-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` |
|||
-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list |
|||
-- (`GET /organizations/<org>/users`) for them and for nobody whose reach came from a group. |
|||
-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, |
|||
-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and |
|||
-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` |
|||
-- satisfies `has_full_access()`, which opens the organization collection list and |
|||
-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach |
|||
-- through the group, so only the route is new. |
|||
-- |
|||
-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means |
|||
-- Manager everywhere it is read. |
|||
-- |
|||
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is |
|||
-- converted like a confirmed one, since none holds authority in that state and the permissions are |
|||
-- what it would come back with -- the same thing `access_all` would have done. |
|||
-- |
|||
-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at |
|||
-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
), |
|||
delete_any_collection = access_all |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
), |
|||
atype = 4 |
|||
WHERE atype = 3; |
|||
|
|||
-- The flag is now fully represented by the role model, so drop the redundant column. This concerns |
|||
-- `users_organizations` only; `groups.access_all` stays. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
-- Never inherit a downgrade acknowledgement left behind by an earlier revert. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
|
|||
@ -1 +0,0 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -1,13 +0,0 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT IGNORE INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
); |
|||
@ -1,34 +0,0 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade' |
|||
); |
|||
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements |
|||
-- mean two intermediate states that survive a failure while Diesel still considers the migration |
|||
-- unapplied; one statement is the closest this backend gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN create_new_collections, |
|||
DROP COLUMN edit_any_collection, |
|||
DROP COLUMN delete_any_collection; |
|||
@ -1,68 +0,0 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE so a |
|||
-- refusal leaves no half-added column group behind -- every ALTER commits implicitly here, and a |
|||
-- partial group is what the startup preflight then has to recover from. |
|||
-- |
|||
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this whole check is |
|||
-- free of durable side effects. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
-- `groups` is a reserved word in MySQL 8 and must be quoted, matching the existing |
|||
-- `2022-07-27-110000_add_group_support` migration. (PostgreSQL/SQLite do not reserve it.) |
|||
INNER JOIN `groups` ON `groups`.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND `groups`.organizations_uuid = users_organizations.org_uuid |
|||
AND `groups`.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -1,101 +0,0 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. Refusing also keeps this file free of DDL, which on MySQL/MariaDB would |
|||
-- commit implicitly and break this migration out of its transaction. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path. |
|||
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear only the marker row as transactional DML. Keeping the empty bookkeeping table avoids |
|||
-- MySQL DDL implicit commits, so the permission repair, marker clear, and Diesel ledger insert |
|||
-- either commit together or are all retried. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -1,13 +0,0 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -1,5 +0,0 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
@ -1,28 +0,0 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade' |
|||
); |
|||
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements |
|||
-- mean two intermediate states that survive a failure while Diesel still considers the migration |
|||
-- unapplied; one statement is the closest this backend gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN access_event_logs, |
|||
DROP COLUMN access_import_export, |
|||
DROP COLUMN access_reports; |
|||
@ -1,5 +0,0 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -1,60 +0,0 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- Operators who only need the old server version to start again do not need Diesel at all -- |
|||
-- tools/custom_role_rollback/ has a self-contained script per backend. |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade'); |
|||
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- Second, MySQL/MariaDB-only guard: this revert chain cannot be resumed here. |
|||
-- |
|||
-- Every `ALTER TABLE` in it commits on its own, while Diesel deletes the ledger row in a separate |
|||
-- statement afterwards. A crash in between leaves the columns gone and the migration still recorded |
|||
-- as applied, and re-running it fails forever with `Unknown column` (1091) -- the startup preflight |
|||
-- then refuses the database, correctly, and the only way out is the backup. Making it resumable |
|||
-- needs conditional DDL, i.e. a stored procedure built before the checks have run; the standalone |
|||
-- script in tools/custom_role_rollback/mysql.sql does the whole downgrade in one audited pass |
|||
-- instead, and is what operators should use. |
|||
-- |
|||
-- So this is supported for development checkouts only, and it says so. Acknowledge separately from |
|||
-- the data-loss marker above -- that one is about what a downgrade discards, this one is about what |
|||
-- an interrupted downgrade cannot repair: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
CREATE TEMPORARY TABLE __vw_mysql_resume_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_mysql_resume_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_mysql_resume_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_unresumable_mysql_downgrade' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_mysql_resume_guard; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -1,13 +0,0 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. Both of them: this backend's revert chain needs a second one, |
|||
-- acknowledging that it cannot be resumed after a crash between a committed ALTER and the ledger. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -1,111 +0,0 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
-- |
|||
-- Every statement here is DML or TEMPORARY-table bookkeeping, so nothing commits implicitly and the |
|||
-- repair either lands with the ledger insert or not at all. |
|||
|
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather |
|||
-- than creating it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from an |
|||
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not |
|||
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships below |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare |
|||
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection |
|||
-- FROM users_organizations uo |
|||
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid |
|||
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid |
|||
-- WHERE uo.atype = 4 AND g.access_all = 1 |
|||
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record table, |
|||
-- so an unrecorded membership keeps exactly the permissions it has. |
|||
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_custom_role_history_verified' |
|||
) |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_legacy_group_authority_guard; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -1,121 +0,0 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as `Duplicate entry '1' for key 'PRIMARY'` |
|||
-- and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all = 1); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = 1`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = 0, delete_any_collection = 0 |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this check is free |
|||
-- of durable side effects. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_ack_permanent_collection_authority' |
|||
) |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
@ -1,38 +1,122 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3 |
|||
ON CONFLICT DO NOTHING; |
|||
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine |
|||
-- granular permissions. |
|||
-- |
|||
-- Two different columns are called `access_all`, and everything below depends on keeping them apart: |
|||
-- |
|||
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped |
|||
-- at the end of this file. |
|||
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read |
|||
-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group |
|||
-- members access to every collection afterwards exactly as before. |
|||
-- |
|||
-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection |
|||
-- through either. Only the membership bit is going away, but the capability an owner configured |
|||
-- through either route is preserved, so both are read below. While this file runs the membership |
|||
-- column still exists and `atype = 3` still unambiguously means "legacy Manager". |
|||
-- |
|||
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates |
|||
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort |
|||
-- below as nothing but a driver-level duplicate-key error. |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
-- A plain User carrying membership `access_all`, reachable only on databases written before the web |
|||
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and |
|||
-- future, with no management authority, and the new model has no permission for that: |
|||
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. |
|||
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when |
|||
-- such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
ALTER TABLE users_organizations |
|||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, |
|||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives |
|||
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard |
|||
-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management |
|||
-- capability it is configured with right now: |
|||
-- |
|||
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three |
|||
-- collection permissions, including creating collections; |
|||
-- * an organization-local `access_all` group covered editing and deleting every collection, but |
|||
-- never creation -- that always required the membership bit; |
|||
-- * a Manager with neither keeps all three at FALSE. |
|||
-- |
|||
-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the |
|||
-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on |
|||
-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL |
|||
-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the |
|||
-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its |
|||
-- own. The alternative is silently revoking access these members have today, or refusing an ordinary |
|||
-- upgrade; the permission is visible in the member's permission list and an owner can clear it. |
|||
-- |
|||
-- The management (manage_users / manage_groups / manage_policies) and access (event logs / |
|||
-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager |
|||
-- capability -- every member mutation, every policy write, the organization export and both |
|||
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. |
|||
-- |
|||
-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` |
|||
-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list |
|||
-- (`GET /organizations/<org>/users`) for them and for nobody whose reach came from a group. |
|||
-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, |
|||
-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and |
|||
-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` |
|||
-- satisfies `has_full_access()`, which opens the organization collection list and |
|||
-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach |
|||
-- through the group, so only the route is new. |
|||
-- |
|||
-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means |
|||
-- Manager everywhere it is read. |
|||
-- |
|||
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is |
|||
-- converted like a confirmed one, since none holds authority in that state and the permissions are |
|||
-- what it would come back with -- the same thing `access_all` would have done. |
|||
-- |
|||
-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at |
|||
-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
), |
|||
delete_any_collection = access_all |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
), |
|||
atype = 4 |
|||
WHERE atype = 3; |
|||
|
|||
-- The flag is now fully represented by the role model: Owners/Admins hold it implicitly, a Custom |
|||
-- member holds it through `edit_any_collection`. Drop the redundant column. This only concerns |
|||
-- users_organizations; `groups.access_all` stays. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
-- Never inherit a downgrade acknowledgement left behind by an earlier revert. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
|
|||
@ -1 +0,0 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -1,13 +0,0 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
) |
|||
ON CONFLICT (marker) DO NOTHING; |
|||
@ -1,25 +0,0 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
ALTER TABLE users_organizations DROP COLUMN create_new_collections; |
|||
ALTER TABLE users_organizations DROP COLUMN edit_any_collection; |
|||
ALTER TABLE users_organizations DROP COLUMN delete_any_collection; |
|||
@ -1,60 +0,0 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so |
|||
-- the refusal is symmetrical with the other backends -- PostgreSQL DDL is transactional, so nothing |
|||
-- would be left behind either way. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND groups.organizations_uuid = users_organizations.org_uuid |
|||
AND groups.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -1,96 +0,0 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path. |
|||
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3 |
|||
ON CONFLICT DO NOTHING; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear the same-run marker only after every permission update succeeds. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -1,13 +0,0 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -1,5 +0,0 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
@ -1,19 +0,0 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
ALTER TABLE users_organizations DROP COLUMN access_event_logs; |
|||
ALTER TABLE users_organizations DROP COLUMN access_import_export; |
|||
ALTER TABLE users_organizations DROP COLUMN access_reports; |
|||
@ -1,5 +0,0 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -1,27 +0,0 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- Operators who only need the old server version to start again do not need Diesel at all -- |
|||
-- tools/custom_role_rollback/ has a self-contained script per backend. |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -1,11 +0,0 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -1,103 +0,0 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
DO $$ |
|||
DECLARE |
|||
undecidable int := 0; |
|||
BEGIN |
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses |
|||
-- rather than creating it. |
|||
IF to_regclass('__vw_custom_role_legacy_manager') IS NULL THEN |
|||
RAISE EXCEPTION |
|||
'Upgrade refused, nothing was changed: __vw_custom_role_legacy_manager does not exist, ' |
|||
'so which memberships were legacy Managers before the upgrade is unknown. Start ' |
|||
'Vaultwarden once to get the full recovery instructions, or see ' |
|||
'tools/custom_role_rollback/README.md.'; |
|||
END IF; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from |
|||
-- an earlier revision of this feature branch), or it is an ordinary modern Custom member who must |
|||
-- not gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this is the backstop for a bare |
|||
-- migration runner. |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record |
|||
-- table, so an unrecorded membership keeps exactly the permissions it has. |
|||
IF to_regclass('__vw_custom_role_history_verified') IS NULL THEN |
|||
SELECT count(*) INTO undecidable |
|||
FROM users_organizations uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users gu |
|||
INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
END IF; |
|||
|
|||
IF undecidable <> 0 THEN |
|||
RAISE EXCEPTION |
|||
'Upgrade refused, nothing was changed: % Custom membership(s) belong to an access_all ' |
|||
'group but are not on record as legacy Managers, and this database''s Custom-role ' |
|||
'history has never been audited, so a converted legacy Manager cannot be told from an ' |
|||
'ordinary Custom member. Review them with: SELECT uo.uuid, uo.org_uuid, uo.status, ' |
|||
'uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection FROM ' |
|||
'users_organizations uo JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid ' |
|||
'JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid ' |
|||
'WHERE uo.atype = 4 AND g.access_all AND uo.uuid NOT IN (SELECT ' |
|||
'users_organizations_uuid FROM __vw_custom_role_legacy_manager); Start Vaultwarden once ' |
|||
'for the full recovery instructions.', |
|||
undecidable; |
|||
END IF; |
|||
END $$; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -1,111 +0,0 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as a bare duplicate-key violation on |
|||
-- `__vw_permanent_authority_guard` and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection OR uo.delete_any_collection) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = t` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = t`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = f` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = FALSE, delete_any_collection = FALSE |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND to_regclass('__vw_ack_permanent_collection_authority') IS NULL |
|||
LIMIT 1; |
|||
DROP TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
@ -1,37 +1,156 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid TEXT NOT NULL PRIMARY KEY |
|||
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine |
|||
-- granular permissions. |
|||
-- |
|||
-- Two different columns are called `access_all`, and everything below depends on keeping them apart: |
|||
-- |
|||
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped |
|||
-- at the end of this file. |
|||
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read |
|||
-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group |
|||
-- members access to every collection afterwards exactly as before. |
|||
-- |
|||
-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection |
|||
-- through either. Only the membership bit is going away, but the capability an owner configured |
|||
-- through either route is preserved, so both are read below. While this file runs the membership |
|||
-- column still exists and `atype = 3` still unambiguously means "legacy Manager". |
|||
-- |
|||
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates |
|||
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort |
|||
-- below as nothing but a driver-level duplicate-key error. |
|||
|
|||
-- A plain User carrying membership `access_all`, reachable only on databases written before the web |
|||
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and |
|||
-- future, with no management authority, and the new model has no permission for that: |
|||
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. |
|||
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when |
|||
-- such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- Schema and data change in one table rebuild, which also keeps the conversion unambiguous: |
|||
-- `atype = 3` still means Manager while the permission values are computed from it. |
|||
-- |
|||
-- `ALTER TABLE ... DROP COLUMN` is deliberately not used -- it needs SQLite 3.35.0, while a |
|||
-- `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1. The |
|||
-- rebuild follows the existing 2022-03-02-210038_update_devices_primary_key pattern; Vaultwarden runs |
|||
-- SQLite migrations with `PRAGMA foreign_keys = OFF`, so the drop does not cascade into groups_users. |
|||
CREATE TABLE users_organizations_new ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users (uuid), |
|||
org_uuid TEXT NOT NULL REFERENCES organizations (uuid), |
|||
|
|||
akey TEXT NOT NULL, |
|||
status INTEGER NOT NULL, |
|||
atype INTEGER NOT NULL, |
|||
reset_password_key TEXT, |
|||
external_id TEXT, |
|||
invited_by_email TEXT DEFAULT NULL, |
|||
manage_users BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_groups BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_policies BOOLEAN NOT NULL DEFAULT FALSE, |
|||
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, |
|||
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, |
|||
access_import_export BOOLEAN NOT NULL DEFAULT FALSE, |
|||
access_reports BOOLEAN NOT NULL DEFAULT FALSE, |
|||
|
|||
UNIQUE (user_uuid, org_uuid) |
|||
); |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives |
|||
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard |
|||
-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management |
|||
-- capability it is configured with right now: |
|||
-- |
|||
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three |
|||
-- collection permissions, including creating collections; |
|||
-- * an organization-local `access_all` group covered editing and deleting every collection, but |
|||
-- never creation -- that always required the membership bit; |
|||
-- * a Manager with neither keeps all three at FALSE. |
|||
-- |
|||
-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the |
|||
-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on |
|||
-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL |
|||
-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the |
|||
-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its |
|||
-- own. The alternative is silently revoking access these members have today, or refusing an ordinary |
|||
-- upgrade; the permission is visible in the member's permission list and an owner can clear it. |
|||
-- |
|||
-- The management (manage_users / manage_groups / manage_policies) and access (event logs / |
|||
-- import-export / reports) permissions start out FALSE for everyone. Nothing they unlock was a Manager |
|||
-- capability -- every member mutation, every policy write, the organization export and both |
|||
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. |
|||
-- |
|||
-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` |
|||
-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list |
|||
-- (`GET /organizations/<org>/users`) for them and for nobody whose reach came from a group. |
|||
-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, |
|||
-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and |
|||
-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` |
|||
-- satisfies `has_full_access()`, which opens the organization collection list and |
|||
-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach |
|||
-- through the group, so only the route is new. |
|||
-- |
|||
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is |
|||
-- converted like a confirmed one, since none holds authority in that state and the permissions are |
|||
-- what it would come back with -- the same thing `access_all` would have done. |
|||
-- |
|||
-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at |
|||
-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. |
|||
INSERT INTO users_organizations_new ( |
|||
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, |
|||
invited_by_email, manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection, |
|||
access_event_logs, access_import_export, access_reports |
|||
) |
|||
SELECT |
|||
uo.uuid, uo.user_uuid, uo.org_uuid, uo.akey, uo.status, |
|||
CASE WHEN uo.atype = 3 THEN 4 ELSE uo.atype END, |
|||
uo.reset_password_key, uo.external_id, uo.invited_by_email, |
|||
FALSE, FALSE, FALSE, |
|||
CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END, |
|||
CASE |
|||
WHEN uo.atype = 3 |
|||
AND (uo.access_all = TRUE |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
)) |
|||
THEN TRUE ELSE FALSE |
|||
END, |
|||
CASE |
|||
WHEN uo.atype = 3 |
|||
AND (uo.access_all = TRUE |
|||
OR EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
)) |
|||
THEN TRUE ELSE FALSE |
|||
END, |
|||
FALSE, FALSE, FALSE |
|||
FROM users_organizations AS uo; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_new RENAME TO users_organizations; |
|||
|
|||
-- Never inherit a downgrade acknowledgement left behind by an earlier revert. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
|
|||
@ -1 +0,0 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -1,12 +0,0 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT OR IGNORE INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
); |
|||
@ -1,28 +0,0 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade' |
|||
); |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
ALTER TABLE users_organizations DROP COLUMN create_new_collections; |
|||
ALTER TABLE users_organizations DROP COLUMN edit_any_collection; |
|||
ALTER TABLE users_organizations DROP COLUMN delete_any_collection; |
|||
@ -1,63 +0,0 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so |
|||
-- a refusal leaves no half-added column group behind -- on MySQL/MariaDB every ALTER commits on its |
|||
-- own, and a partial group is what the startup preflight then has to recover from. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND groups.organizations_uuid = users_organizations.org_uuid |
|||
AND groups.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -1,98 +0,0 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path where 2026-06-30-120000 already recorded them. |
|||
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear the same-run marker only after every permission update succeeds. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -1,13 +0,0 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -1,46 +0,0 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
-- |
|||
-- `ALTER TABLE ... DROP COLUMN` is deliberately NOT used here: it only exists since SQLite 3.35.0, |
|||
-- while a `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1 |
|||
-- (which is what Debian 11 ships). Forward migrations have to run on every supported build, so use |
|||
-- the portable table rebuild instead -- the same pattern as |
|||
-- 2022-03-02-210038_update_devices_primary_key. Vaultwarden runs SQLite migrations with |
|||
-- `PRAGMA foreign_keys = OFF`, so dropping the old table does not cascade into groups_users. |
|||
CREATE TABLE users_organizations_new ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users (uuid), |
|||
org_uuid TEXT NOT NULL REFERENCES organizations (uuid), |
|||
|
|||
akey TEXT NOT NULL, |
|||
status INTEGER NOT NULL, |
|||
atype INTEGER NOT NULL, |
|||
reset_password_key TEXT, |
|||
external_id TEXT, |
|||
invited_by_email TEXT DEFAULT NULL, |
|||
manage_users BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_groups BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_policies BOOLEAN NOT NULL DEFAULT FALSE, |
|||
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, |
|||
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
|
|||
UNIQUE (user_uuid, org_uuid) |
|||
); |
|||
|
|||
INSERT INTO users_organizations_new ( |
|||
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, |
|||
invited_by_email, manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection |
|||
) |
|||
SELECT |
|||
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, |
|||
invited_by_email, manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection |
|||
FROM users_organizations; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_new RENAME TO users_organizations; |
|||
@ -1,22 +0,0 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade' |
|||
); |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
ALTER TABLE users_organizations DROP COLUMN access_event_logs; |
|||
ALTER TABLE users_organizations DROP COLUMN access_import_export; |
|||
ALTER TABLE users_organizations DROP COLUMN access_reports; |
|||
@ -1,5 +0,0 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -1,29 +0,0 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- Operators who only need the old server version to start again do not need Diesel at all -- |
|||
-- tools/custom_role_rollback/ has a self-contained script per backend. |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'); |
|||
DROP TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -1,11 +0,0 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -1,107 +0,0 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
|
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather |
|||
-- than creating it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from an |
|||
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not |
|||
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships below |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare |
|||
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection |
|||
-- FROM users_organizations uo |
|||
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid |
|||
-- WHERE uo.atype = 4 AND g.access_all = 1 |
|||
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record table, |
|||
-- so an unrecorded membership keeps exactly the permissions it has. |
|||
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_history_verified' |
|||
) |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_group_authority_guard; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -1,4 +0,0 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -1,117 +0,0 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as `UNIQUE constraint failed: |
|||
-- __vw_permanent_authority_guard.blocked` and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all = 1); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = 1`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = 0, delete_any_collection = 0 |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_ack_permanent_collection_authority' |
|||
) |
|||
LIMIT 1; |
|||
DROP TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
Loading…
Reference in new issue