committed by
GitHub
30 changed files with 8282 additions and 645 deletions
@ -0,0 +1,95 @@ |
|||
-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or |
|||
-- the Custom role. Two explicit operator decisions are required before anything is touched, and both |
|||
-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older |
|||
-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. |
|||
-- |
|||
-- Both guards use temporary tables on purpose: on MySQL/MariaDB temporary-table DDL is the only DDL |
|||
-- that does not commit implicitly, so a refusal here cannot leave a half-reverted schema behind. |
|||
|
|||
-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_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_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
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 __vw_custom_role_downgrade_guard; |
|||
|
|||
-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a |
|||
-- Custom member holds, so handing it out automatically would *grant* authority during a |
|||
-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every |
|||
-- Custom member to plain User. See README.md in tools/custom_role_rollback/. |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>'); |
|||
-- |
|||
-- The duplicate key aborts the revert. It is only inserted while the list is absent. |
|||
CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_rollback_manager_allowlist' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_rollback_allowlist_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Roles and `access_all` are recomputed together, because in the old schema they are not |
|||
-- independent: |
|||
-- |
|||
-- * Owners and Admins always carried the bit and it grants them nothing extra; |
|||
-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three |
|||
-- collection permissions -- in the old schema `access_all` also carried collection deletion, so |
|||
-- an Edit-only member must not silently gain it; |
|||
-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy |
|||
-- state the upgrade refuses, so leaving it set would make the database unable to move forward |
|||
-- again. |
|||
-- |
|||
-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` |
|||
-- was never modified, so the older binary derives it again by itself for whoever comes back as |
|||
-- Manager. |
|||
UPDATE users_organizations |
|||
SET access_all = CASE |
|||
WHEN atype IN (0, 1) THEN TRUE |
|||
WHEN atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND create_new_collections = TRUE |
|||
AND edit_any_collection = TRUE |
|||
AND delete_any_collection = TRUE THEN TRUE |
|||
ELSE FALSE |
|||
END, |
|||
atype = CASE |
|||
WHEN atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 |
|||
WHEN atype = 4 THEN 2 |
|||
ELSE atype |
|||
END; |
|||
|
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN manage_users, |
|||
DROP COLUMN manage_groups, |
|||
DROP COLUMN manage_policies, |
|||
DROP COLUMN create_new_collections, |
|||
DROP COLUMN edit_any_collection, |
|||
DROP COLUMN delete_any_collection, |
|||
DROP COLUMN access_event_logs, |
|||
DROP COLUMN access_import_export, |
|||
DROP COLUMN access_reports; |
|||
|
|||
-- Both decisions authorized *this* downgrade, not the next one. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
@ -0,0 +1,127 @@ |
|||
-- 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. |
|||
|
|||
-- 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; |
|||
|
|||
-- Never inherit a downgrade acknowledgement left behind by an earlier revert. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -0,0 +1,84 @@ |
|||
-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or |
|||
-- the Custom role. Two explicit operator decisions are required before anything is touched, and both |
|||
-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older |
|||
-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. |
|||
|
|||
-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_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_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
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; |
|||
|
|||
-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a |
|||
-- Custom member holds, so handing it out automatically would *grant* authority during a |
|||
-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every |
|||
-- Custom member to plain User. See README.md in tools/custom_role_rollback/. |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>'); |
|||
-- |
|||
-- The duplicate key aborts the revert. It is only inserted while the list is absent. |
|||
CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_rollback_manager_allowlist') IS NULL; |
|||
DROP TABLE __vw_rollback_allowlist_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Roles and `access_all` are recomputed together, because in the old schema they are not |
|||
-- independent: |
|||
-- |
|||
-- * Owners and Admins always carried the bit and it grants them nothing extra; |
|||
-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three |
|||
-- collection permissions -- in the old schema `access_all` also carried collection deletion, so |
|||
-- an Edit-only member must not silently gain it; |
|||
-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy |
|||
-- state the upgrade refuses, so leaving it set would make the database unable to move forward |
|||
-- again. |
|||
-- |
|||
-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` |
|||
-- was never modified, so the older binary derives it again by itself for whoever comes back as |
|||
-- Manager. |
|||
UPDATE users_organizations |
|||
SET access_all = CASE |
|||
WHEN atype IN (0, 1) THEN TRUE |
|||
WHEN atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND create_new_collections = TRUE |
|||
AND edit_any_collection = TRUE |
|||
AND delete_any_collection = TRUE THEN TRUE |
|||
ELSE FALSE |
|||
END, |
|||
atype = CASE |
|||
WHEN atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 |
|||
WHEN atype = 4 THEN 2 |
|||
ELSE atype |
|||
END; |
|||
|
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN manage_users, |
|||
DROP COLUMN manage_groups, |
|||
DROP COLUMN manage_policies, |
|||
DROP COLUMN create_new_collections, |
|||
DROP COLUMN edit_any_collection, |
|||
DROP COLUMN delete_any_collection, |
|||
DROP COLUMN access_event_logs, |
|||
DROP COLUMN access_import_export, |
|||
DROP COLUMN access_reports; |
|||
|
|||
-- Both decisions authorized *this* downgrade, not the next one. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
@ -0,0 +1,122 @@ |
|||
-- 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 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; |
|||
|
|||
-- Never inherit a downgrade acknowledgement left behind by an earlier revert. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -0,0 +1,102 @@ |
|||
-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or |
|||
-- the Custom role. Two explicit operator decisions are required before anything is touched, and both |
|||
-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older |
|||
-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. |
|||
|
|||
-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_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_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); |
|||
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; |
|||
|
|||
-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a |
|||
-- Custom member holds, so handing it out automatically would *grant* authority during a |
|||
-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every |
|||
-- Custom member to plain User. See README.md in tools/custom_role_rollback/. |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>'); |
|||
-- |
|||
-- The duplicate key aborts the revert. It is only inserted while the list is absent. |
|||
CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_rollback_allowlist_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist' |
|||
); |
|||
DROP TABLE __vw_rollback_allowlist_guard; |
|||
|
|||
-- Roles and `access_all` are recomputed together, because in the old schema they are not independent: |
|||
-- |
|||
-- * Owners and Admins always carried the bit and it grants them nothing extra; |
|||
-- * an allowlisted Custom member becomes a Manager, keeping the bit only with all three collection |
|||
-- permissions -- `access_all` also carried collection deletion there, so an Edit-only member must |
|||
-- not silently gain it; |
|||
-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy |
|||
-- state the upgrade refuses, so leaving it set would strand the database. |
|||
-- |
|||
-- Group-derived Manager authority needs no restoring: `groups.access_all` was never modified, so the |
|||
-- older binary derives it again for whoever comes back as Manager. |
|||
CREATE TABLE users_organizations_old ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users (uuid), |
|||
org_uuid TEXT NOT NULL REFERENCES organizations (uuid), |
|||
|
|||
access_all BOOLEAN NOT NULL, |
|||
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, |
|||
|
|||
UNIQUE (user_uuid, org_uuid) |
|||
); |
|||
|
|||
INSERT INTO users_organizations_old ( |
|||
uuid, user_uuid, org_uuid, access_all, akey, status, atype, |
|||
reset_password_key, external_id, invited_by_email |
|||
) |
|||
SELECT |
|||
uo.uuid, uo.user_uuid, uo.org_uuid, |
|||
CASE |
|||
WHEN uo.atype IN (0, 1) THEN TRUE |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND uo.create_new_collections = TRUE |
|||
AND uo.edit_any_collection = TRUE |
|||
AND uo.delete_any_collection = TRUE THEN TRUE |
|||
ELSE FALSE |
|||
END, |
|||
uo.akey, uo.status, |
|||
CASE |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 |
|||
WHEN uo.atype = 4 THEN 2 |
|||
ELSE uo.atype |
|||
END, |
|||
uo.reset_password_key, uo.external_id, uo.invited_by_email |
|||
FROM users_organizations AS uo; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_old RENAME TO users_organizations; |
|||
|
|||
-- Both decisions authorized *this* downgrade, not the next one. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
@ -0,0 +1,156 @@ |
|||
-- 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 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) |
|||
); |
|||
|
|||
-- 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; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,228 @@ |
|||
# Rolling back the Custom-role change |
|||
|
|||
The Custom-role change removes the membership `access_all` column and adds nine permission columns. |
|||
A Vaultwarden version from before that change cannot start against the new schema, because its |
|||
`schema.rs` still expects `access_all` to exist. |
|||
|
|||
Vaultwarden only ever applies *pending* migrations — it never reverts one on its own — so putting the |
|||
old image back is not enough. Run the script for your backend once and the old version starts |
|||
again. |
|||
|
|||
## Choosing which members come back as Manager |
|||
|
|||
The old and new role models are not ordered, so this is a decision, not a conversion. The legacy |
|||
Manager role is **not** a subset of what a Custom member holds: it manages — and deletes — every |
|||
collection reachable through `users_collections.manage`, `collections_groups.manage` or |
|||
`groups.access_all`, and it reads member and collection ACL details through `ManagerHeadersLoose`. |
|||
None of that needs a permission flag in the old schema. Mapping every Custom member to Manager would |
|||
therefore *grant* authority during a downgrade: a member with `deleteAnyCollection = false` but a |
|||
direct or group-based manage grant would come back able to delete those collections, and a member |
|||
with no permissions at all would come back able to read the organization's member list. |
|||
|
|||
So the scripts map to Manager only what you list, and everything else to plain User. Create the list |
|||
with every Vaultwarden instance stopped, right before running the rollback: |
|||
|
|||
```sql |
|||
CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); |
|||
``` |
|||
|
|||
Use `CHAR(36)` instead of `TEXT` on MySQL/MariaDB and PostgreSQL. An empty list is a valid answer and |
|||
maps every Custom member to plain User. To add members, list the candidates and pick from them: |
|||
|
|||
```sql |
|||
SELECT uuid, user_uuid, org_uuid, status, |
|||
manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection, |
|||
access_event_logs, access_import_export, access_reports |
|||
FROM users_organizations WHERE atype = 4; |
|||
|
|||
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>'); |
|||
``` |
|||
|
|||
The decision is deliberately taken **now**, from what each membership holds today, rather than from |
|||
any record of who was a Manager before the upgrade. Such a record would describe the state at the |
|||
time of the *first* upgrade and would never be updated afterwards, so a member whose Manager powers |
|||
an owner has since reduced — or who was demoted to User and later re-created as a limited Custom |
|||
member — would be handed the whole legacy role back. Historical provenance is evidence, not |
|||
authorization, and the upgrade therefore keeps none. |
|||
|
|||
## What is lost |
|||
|
|||
The old schema has nowhere to store the nine permissions, so they are dropped: |
|||
|
|||
| Before the rollback | After | |
|||
|---|---| |
|||
| Owner / Admin | Owner / Admin with `access_all = TRUE` | |
|||
| Custom **on the allowlist**, with all three collection permissions | Manager with `access_all = TRUE` | |
|||
| Custom **on the allowlist**, with only some collection permissions | Manager with `access_all = FALSE` | |
|||
| Custom not on the allowlist | plain User with `access_all = FALSE` | |
|||
| plain User | plain User with `access_all = FALSE` | |
|||
|
|||
Per-collection assignments (`users_collections`, `collections_groups`) and `groups.access_all` are |
|||
untouched. Only `users_organizations` changes, so a member mapped to plain User keeps every grant |
|||
those tables carry and loses only the organization-wide powers the old schema cannot express. A |
|||
member who comes back as Manager and is still in an organization-local `accessAll` group gets the |
|||
group-derived collection authority back automatically — the old binary derives it live from |
|||
`groups.access_all`, which the upgrade never touched. |
|||
|
|||
One row does not come back byte-identical to what the database held before the *upgrade*, because |
|||
the information no longer exists to reconstruct it: |
|||
|
|||
- **Owner/Admin always come back with `access_all = TRUE`**, even if the flag was `FALSE` for them |
|||
before. The upgrade dropped the column precisely because Owners and Admins reach every collection |
|||
through their role, so the original value is unknown afterwards. It grants them nothing they did |
|||
not already have as Owner/Admin; the visible difference is that unassigned collections show up in |
|||
their personal vault view again. |
|||
|
|||
A plain User carrying `access_all` cannot reach this point at all: the upgrade refuses to start on |
|||
such a database and asks an owner to resolve it first, precisely so that no rollback has to guess |
|||
what the bit meant. For the same reason a Custom member mapped to plain User never keeps `access_all` |
|||
— that combination is the one legacy state the upgrade refuses, and leaving it behind would make the |
|||
database unable to move forward again. |
|||
|
|||
Edit-any-collection deliberately does **not** become `access_all` on its own: in the old schema that |
|||
flag also carried the legacy "manage all collections" authority including deletion, so a member who |
|||
only held Edit must not come back with delete rights. |
|||
|
|||
## How group-derived Manager authority is migrated |
|||
|
|||
Before the Custom role, a Manager could reach every collection of an organization in two ways: the |
|||
membership's own `access_all` bit, or membership of an organization-local group with `accessAll`. |
|||
The upgrade preserves both, deterministically and without asking: |
|||
|
|||
| Legacy Manager has | After the upgrade | |
|||
|---|---| |
|||
| membership `access_all = TRUE` | `createNewCollections` + `editAnyCollection` + `deleteAnyCollection` | |
|||
| only an organization-local `accessAll` group | `editAnyCollection` + `deleteAnyCollection` | |
|||
| neither | no collection permission | |
|||
|
|||
Collection *creation* is deliberately not granted in the second row: it always required the |
|||
membership bit, never the group. |
|||
|
|||
The second row is a policy choice worth knowing about. The group-derived capability used to be |
|||
dynamic — it ended when the group was deleted, when its `accessAll` was cleared, when the member left |
|||
it, and it was inert whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model is bound to a |
|||
group like that, so it becomes a membership permission and therefore: |
|||
|
|||
- it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
- it applies even with the groups feature switched off; |
|||
- `editAnyCollection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
collection directly rather than through the group. |
|||
|
|||
That is accepted on purpose. The alternatives are worse: silently dropping the permission would |
|||
revoke access these members have today, and refusing to migrate would block an ordinary upgrade from |
|||
an official Vaultwarden database. After the upgrade the permission is visible in the member's |
|||
permission list and an owner can clear it with a checkbox — which is more than the old model offered, |
|||
where the same authority was invisible on the membership. |
|||
|
|||
`groups.access_all` and every `groups_users` row are left exactly as they are, so the group keeps |
|||
granting collection *access* to its members as before. A `groups_users` row pointing at another |
|||
organization's `accessAll` group conveys nothing, exactly as it conveys nothing today. |
|||
|
|||
Status is not part of the rule: an invited, accepted or revoked membership is converted like a |
|||
confirmed one. None of them holds authority in that state, and the permission is what the membership |
|||
would come back with if it is ever restored — which is exactly what `access_all` would have done. |
|||
|
|||
The only state the upgrade refuses outright is a plain **User** carrying membership `access_all`; see |
|||
above. That refusal can also be resolved without any SQL, once for the whole instance, by setting |
|||
`LEGACY_USER_ACCESS_ALL_MIGRATION` to `drop` (clear the flag; each member keeps the collections they |
|||
are explicitly assigned to) or `materialize` (write the reach out as explicit assignments first, |
|||
confirmed memberships only, then clear it). The setting is read only while that migration is pending. |
|||
|
|||
## How to run it |
|||
|
|||
Stop every Vaultwarden instance and take a backup first. Create the allowlist as described above. |
|||
Then: |
|||
|
|||
```bash |
|||
# SQLite |
|||
sqlite3 -bail /path/to/data/db.sqlite3 < tools/custom_role_rollback/sqlite.sql |
|||
|
|||
# MySQL / MariaDB |
|||
mysql -u <user> -p <database> < tools/custom_role_rollback/mysql.sql |
|||
|
|||
# PostgreSQL |
|||
psql -U <user> -d <database> -v ON_ERROR_STOP=1 -f tools/custom_role_rollback/postgresql.sql |
|||
``` |
|||
|
|||
Every script begins with a **read-only precondition** that inspects the schema and the migration |
|||
ledger before it touches anything, and refuses unless all of these hold: |
|||
|
|||
- membership `access_all` is gone (so the upgrade did run, and this script has not), |
|||
- all nine permission columns exist, |
|||
- the Custom-role migration `20260630120000` is recorded in `__diesel_schema_migrations`, |
|||
- **no migration newer than `20260630120000` is recorded** — this script does not know what a later |
|||
migration changed, and removing only the Custom-role version would leave the ledger claiming a |
|||
migration whose schema objects may have been undone, |
|||
- **`__vw_rollback_manager_allowlist` exists**, and on MySQL/MariaDB has exactly one non-nullable, |
|||
uniquely indexed `users_organizations_uuid` column — a table of the right name but the wrong shape |
|||
would otherwise pass every check and then fail on the first read, *after* the first `ALTER TABLE` |
|||
has already committed implicitly, |
|||
- SQLite only: **`users_organizations` has exactly the eighteen expected columns, two indexes and no |
|||
triggers.** The SQLite script rebuilds the table from a fixed column list, so anything it does not |
|||
know about would be dropped along with its data. The column check uses `pragma_table_xinfo`, which |
|||
unlike `table_info` also reports generated columns, and the index check counts `pragma_index_list` |
|||
rather than `sqlite_master`, because the index behind a `UNIQUE` constraint has no SQL text and |
|||
would otherwise be invisible. |
|||
|
|||
A second run, or a half-finished upgrade, is therefore refused with a message that names the reason |
|||
and leaves the database exactly as it was. This matters most on MySQL/MariaDB, where nothing can be |
|||
rolled back: without the check, a database whose `access_all` was already dropped but whose |
|||
permission columns were never added would get through the first `ADD COLUMN` and the value rewrites |
|||
before failing on the `DROP COLUMN` — ending up less consistent than before. |
|||
|
|||
The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations` and |
|||
`__vw_rollback_manager_allowlist` once each, requires all of them to live in the **same** schema, and |
|||
addresses that schema explicitly from then on. An unqualified name is otherwise resolved per |
|||
statement through `search_path`, so a session with `search_path = decoy, real` could have the table |
|||
rewrite land in one schema and the ledger delete in another. |
|||
|
|||
The MySQL/MariaDB script ends with an explicit `COMMIT`. Everything before it is DDL and commits |
|||
implicitly, but the final ledger `DELETE` is plain DML: under `autocommit = 0` it would be rolled |
|||
back on disconnect, leaving the schema old while the migration still counts as applied — and a later |
|||
upgrade would then skip it and start new code against the old schema. |
|||
|
|||
**Do not drop the `-bail` / `ON_ERROR_STOP=1` flags, do not pass `--force` to `mysql`, and do not run |
|||
these through a client that keeps going after a failed statement.** The sqlite3 shell continues after |
|||
errors by default; the script sets `.bail on` itself, but that is a shell command a different runner |
|||
will ignore. A runner that carries on past a failing statement would reach the `DROP TABLE` and commit |
|||
an empty `users_organizations`. |
|||
|
|||
SQLite and PostgreSQL apply the script in a single transaction, so an aborted run leaves the |
|||
database untouched. On MySQL/MariaDB the statements cannot be wrapped in a transaction (DDL commits |
|||
implicitly there); the precondition is what keeps a mismatch from being mutated at all, but if the |
|||
script is interrupted *after* it passed, restore the backup and start over. |
|||
|
|||
The SQLite script rebuilds `users_organizations` instead of using `ALTER TABLE ... DROP COLUMN`, which |
|||
only exists since SQLite 3.35 — the same reason the forward migration rebuilds the table. It therefore |
|||
also works against the older system SQLite that `sqlite_system` builds link. |
|||
|
|||
Afterwards start the older Vaultwarden version. Upgrading again later re-applies the migration from a |
|||
clean state: it reads the restored `atype = 3` rows directly and converts them deterministically, |
|||
so the round trip converges. |
|||
|
|||
## Reverting with the Diesel CLI instead |
|||
|
|||
For a development checkout, `2026-06-30-120000/down.sql` does the same thing. It refuses by default |
|||
and needs the same two decisions the scripts above take, in the same order: |
|||
|
|||
```sql |
|||
CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); |
|||
``` |
|||
|
|||
Then `diesel migration revert` works as usual. Both tables are dropped by the revert they authorize, |
|||
so one decision covers one downgrade, and `2026-06-30-120000/up.sql` clears a leftover |
|||
acknowledgement as well, so consent never carries over into a later, unrelated revert. |
|||
|
|||
Unlike the standalone scripts, the down migration has no precondition beyond those two guards: it is |
|||
a development path, and Diesel already knows the migration is recorded. |
|||
|
|||
On MySQL/MariaDB the revert **cannot be resumed**: every `ALTER TABLE` 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; re-running it then fails with |
|||
`Unknown column` (1091) and the only way out is the backup. The down migration adds and drops its |
|||
columns in one `ALTER TABLE` each rather than one per column, which is the closest this backend gets |
|||
to all-or-nothing, and temporary guard tables are removed with `DROP TEMPORARY TABLE`, which is one |
|||
implicit commit fewer and cannot hit a permanent table of the same name by accident. Use `mysql.sql` |
|||
above for anything you care about. |
|||
@ -0,0 +1,251 @@ |
|||
-- Roll a MySQL/MariaDB database back to the schema the Vaultwarden version *before* the Custom-role |
|||
-- change expects, so that older binary starts again. Read README.md in this directory first -- |
|||
-- it lists exactly what is lost and how to run this safely. |
|||
-- |
|||
-- NOTE: MySQL/MariaDB commit every DDL statement implicitly, so this script cannot be wrapped in a |
|||
-- transaction. That is exactly why everything below the precondition has to be reached in a known |
|||
-- state: an ALTER that fails halfway leaves every earlier statement committed. Take a backup before |
|||
-- running it; if it is interrupted, restore and start over. |
|||
|
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- Precondition. Read-only and session-local: reads `information_schema` and the ledger, prints the |
|||
-- reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY table. No |
|||
-- permanent object is touched, so a database this script does not fit keeps its exact state -- which |
|||
-- matters because DDL here cannot be rolled back. Without it a partially upgraded database would get |
|||
-- through several committed statements before failing on error 1091, ending up *less* consistent. |
|||
-- |
|||
-- Deliberately not a stored procedure with SIGNAL: MySQL caps `MESSAGE_TEXT` at 128 characters and |
|||
-- answers a longer one with error 1648 instead of the diagnosis (MariaDB accepts it, so the |
|||
-- difference is easy to miss), and CREATE PROCEDURE is a permanent object that would have to be |
|||
-- written before the checks run. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition ( |
|||
ok INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_rollback_precondition (ok) VALUES (1); |
|||
|
|||
-- 1) Membership `access_all` has to be gone already, i.e. the upgrade ran and this script did not. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: users_organizations.access_all still exists. This database was ', |
|||
'either never upgraded past the Custom-role migrations, or this script already ran.' |
|||
) AS rollback_precondition_failure |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name = 'access_all'; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name = 'access_all'; |
|||
|
|||
-- 2) All nine permission columns have to be present. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: expected all nine Custom-role permission columns on ', |
|||
'users_organizations, found ', c.n, '. The upgrade is incomplete, so restore the backup taken ', |
|||
'before it and start over.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
|
|||
-- 3) The Custom-role migration has to be recorded. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: expected the Custom-role migration in ', |
|||
'__diesel_schema_migrations, found ', c.n, '. Schema and ledger disagree, so restore the backup ', |
|||
'taken before the upgrade and start over.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
|
|||
-- 4) No migration newer than the Custom-role change may be recorded: this script does not know what |
|||
-- such a migration changed, and removing only the one version below would leave the ledger |
|||
-- claiming a migration whose schema objects this script may have undone. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: ', c.n, ' migration(s) newer than the Custom-role change are ', |
|||
'recorded. Use the rollback script shipped with that newer version.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version > '20260630120000' |
|||
) AS c |
|||
WHERE c.n <> 0; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version > '20260630120000' |
|||
) AS c |
|||
WHERE c.n <> 0; |
|||
|
|||
-- 5) Which memberships come back as legacy Manager has to be decided for *this* rollback. An empty |
|||
-- list is a valid answer and maps every Custom member to plain User. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist does not exist. See README.md, ', |
|||
'section "Choosing which members come back as Manager".' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_rollback_manager_allowlist' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_rollback_manager_allowlist' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
|
|||
-- 7) ...and it has to have the shape the role mapping reads: exactly one non-nullable, uniquely |
|||
-- indexed CHAR(36) `users_organizations_uuid`. A colliding table would otherwise pass every check |
|||
-- above and fail on the first SELECT, *after* the `ADD COLUMN` below has committed implicitly. The |
|||
-- type is part of the authorization boundary: MySQL/MariaDB compare a character UUID against a |
|||
-- numeric allowlist as numbers, so an INT value such as 0 could match unrelated UUIDs. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist must have exactly one column ', |
|||
'named users_organizations_uuid, typed CHAR(36), NOT NULL and uniquely indexed. Create it as ', |
|||
'documented in README.md.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols, |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' |
|||
AND data_type = 'char' AND character_maximum_length = 36 |
|||
AND is_nullable = 'NO') AS usable, |
|||
(SELECT COUNT(*) FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq |
|||
) AS c |
|||
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols, |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' |
|||
AND data_type = 'char' AND character_maximum_length = 36 |
|||
AND is_nullable = 'NO') AS usable, |
|||
(SELECT COUNT(*) FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq |
|||
) AS c |
|||
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1; |
|||
|
|||
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits implicitly, |
|||
-- and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_rollback_precondition; |
|||
|
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- From here on the database is known to be in the state this script converts *from*. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and |
|||
-- keeps its per-collection assignments. The legacy role is not a subset of what a Custom member |
|||
-- holds, so handing it out on less than a current, deliberate decision would *grant* authority during |
|||
-- a downgrade -- and historical provenance is not that decision. See README.md. |
|||
-- |
|||
-- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three |
|||
-- collection permissions, because in the old schema the bit also carried collection deletion. A |
|||
-- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade |
|||
-- refuses. |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations |
|||
SET access_all = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND create_new_collections = TRUE |
|||
AND edit_any_collection = TRUE |
|||
AND delete_any_collection = TRUE; |
|||
|
|||
-- The old server cannot load type 4. |
|||
UPDATE users_organizations SET atype = 3 |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); |
|||
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4; |
|||
|
|||
-- One ALTER, not nine. Every `ALTER TABLE` commits implicitly here, so nine statements mean eight |
|||
-- intermediate states an interruption could leave behind; one statement is the closest this backend |
|||
-- gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN manage_users, |
|||
DROP COLUMN manage_groups, |
|||
DROP COLUMN manage_policies, |
|||
DROP COLUMN create_new_collections, |
|||
DROP COLUMN edit_any_collection, |
|||
DROP COLUMN delete_any_collection, |
|||
DROP COLUMN access_event_logs, |
|||
DROP COLUMN access_import_export, |
|||
DROP COLUMN access_reports; |
|||
|
|||
-- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored |
|||
-- `atype = 3` rows directly and converts them deterministically. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
|
|||
-- Finally forget the migration, so the older binary does not see a ledger from the future |
|||
-- and a later upgrade applies it again from a clean state. |
|||
DELETE FROM __diesel_schema_migrations |
|||
WHERE version = '20260630120000'; |
|||
|
|||
-- Every statement above except this DELETE is DDL and was therefore committed implicitly the moment |
|||
-- it ran. The DELETE is plain DML: under `autocommit = 0` -- which `mysql --init-command`, a my.cnf |
|||
-- default, or a connection pool can all set -- it would be rolled back on disconnect, leaving the |
|||
-- schema rolled back but the migration still marked as applied. A later upgrade would then skip |
|||
-- it and start new code against the old schema. Commit it explicitly; harmless when |
|||
-- autocommit is already on. |
|||
COMMIT; |
|||
@ -0,0 +1,220 @@ |
|||
-- Roll a PostgreSQL database back to the schema the Vaultwarden version *before* the Custom-role |
|||
-- change expects, so that older binary starts again. Read README.md in this directory first -- |
|||
-- it lists exactly what is lost and how to run this safely. |
|||
-- |
|||
-- PostgreSQL DDL is transactional, so this whole script either applies or it does not. |
|||
-- |
|||
-- Everything runs inside one DO block against schema-qualified names. An unqualified relation is |
|||
-- resolved per statement through `search_path`, i.e. to the first schema that happens to contain a |
|||
-- matching name -- so a session with `search_path = decoy, real` could have the checks and the table |
|||
-- rewrite land in `decoy` while the ledger delete hits `real`, leaving the real database with a new |
|||
-- schema and a ledger claiming the old one. Resolving each relation once, requiring all of them to |
|||
-- live in the *same* namespace, and then addressing that namespace explicitly removes the ambiguity. |
|||
|
|||
BEGIN; |
|||
|
|||
DO $$ |
|||
DECLARE |
|||
memberships regclass := to_regclass('users_organizations'); |
|||
ledger regclass := to_regclass('__diesel_schema_migrations'); |
|||
allowlist regclass := to_regclass('__vw_rollback_manager_allowlist'); |
|||
ns oid; |
|||
ns_name text; |
|||
access_all_present int; |
|||
permission_columns int; |
|||
allowlist_columns int; |
|||
ledger_rows int; |
|||
future_rows int; |
|||
BEGIN |
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- Bind the target. Read-only: this inspects the catalog and the migration ledger and changes |
|||
-- nothing, so a database this script does not fit keeps its exact state. The transaction would |
|||
-- roll back a mismatch anyway; this turns a raw "column does not exist" into a message that says |
|||
-- what to do, and it keeps all three backends' scripts symmetrical. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
IF memberships IS NULL THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: no users_organizations table is ' |
|||
'reachable through the current search_path. Connect to the database and ' |
|||
'schema Vaultwarden uses.'; |
|||
END IF; |
|||
|
|||
IF ledger IS NULL THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: no __diesel_schema_migrations table ' |
|||
'is reachable through the current search_path.'; |
|||
END IF; |
|||
|
|||
IF allowlist IS NULL THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist does ' |
|||
'not exist. Which memberships come back as legacy Manager has to be decided ' |
|||
'for this rollback -- an empty list is a valid answer and maps every Custom ' |
|||
'member to plain User. See README.md, section "Choosing which members come ' |
|||
'back as Manager".'; |
|||
END IF; |
|||
|
|||
SELECT relnamespace INTO ns FROM pg_class WHERE oid = memberships; |
|||
|
|||
IF (SELECT relnamespace FROM pg_class WHERE oid = ledger) <> ns |
|||
OR (SELECT relnamespace FROM pg_class WHERE oid = allowlist) <> ns THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: the tables this script needs resolve ' |
|||
'to different schemas through the current search_path -- ' |
|||
'users_organizations in "%", __diesel_schema_migrations in "%", ' |
|||
'__vw_rollback_manager_allowlist in "%". Set search_path to exactly the ' |
|||
'schema Vaultwarden uses and run this again.', |
|||
(SELECT nspname FROM pg_namespace WHERE oid = ns), |
|||
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace |
|||
WHERE c.oid = ledger), |
|||
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace |
|||
WHERE c.oid = allowlist); |
|||
END IF; |
|||
|
|||
SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns; |
|||
|
|||
SELECT count(*) INTO access_all_present |
|||
FROM pg_attribute |
|||
WHERE attrelid = memberships |
|||
AND attnum > 0 |
|||
AND NOT attisdropped |
|||
AND attname = 'access_all'; |
|||
|
|||
SELECT count(*) INTO permission_columns |
|||
FROM pg_attribute |
|||
WHERE attrelid = memberships |
|||
AND attnum > 0 |
|||
AND NOT attisdropped |
|||
AND attname IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
); |
|||
|
|||
-- The allowlist is read by the role mapping below, so a hand-written table of the right name but |
|||
-- the wrong shape has to be caught here rather than mid-rewrite. |
|||
SELECT count(*) INTO allowlist_columns |
|||
FROM pg_attribute |
|||
WHERE attrelid = allowlist |
|||
AND attnum > 0 |
|||
AND NOT attisdropped |
|||
AND attname = 'users_organizations_uuid'; |
|||
|
|||
EXECUTE format( |
|||
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''', |
|||
ns_name |
|||
) INTO ledger_rows; |
|||
|
|||
EXECUTE format( |
|||
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260630120000''', |
|||
ns_name |
|||
) INTO future_rows; |
|||
|
|||
IF access_all_present <> 0 THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: users_organizations.access_all still ' |
|||
'exists. This database was either never upgraded past the Custom-role ' |
|||
'migrations, or this script already ran.'; |
|||
END IF; |
|||
|
|||
IF permission_columns <> 9 THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role ' |
|||
'permission columns on users_organizations, found %. The upgrade is ' |
|||
'incomplete, so restore the backup taken before it and start over.', |
|||
permission_columns; |
|||
END IF; |
|||
|
|||
IF allowlist_columns <> 1 THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist has ' |
|||
'no users_organizations_uuid column. Create it as documented in README.md.'; |
|||
END IF; |
|||
|
|||
IF ledger_rows <> 1 THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected the Custom-role migration ' |
|||
'in __diesel_schema_migrations, found %. Schema and ledger disagree, so ' |
|||
'restore the backup taken before the upgrade and start over.', |
|||
ledger_rows; |
|||
END IF; |
|||
|
|||
IF future_rows <> 0 THEN |
|||
RAISE EXCEPTION 'Rollback refused, nothing was changed: % migration(s) newer than the ' |
|||
'Custom-role change are recorded. This script does not know what they ' |
|||
'changed, and removing only the Custom-role version would leave the ' |
|||
'ledger inconsistent. Use the rollback script shipped with that newer ' |
|||
'version.', |
|||
future_rows; |
|||
END IF; |
|||
|
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- From here on the database is known to be in the state this script converts *from*, and every |
|||
-- statement addresses the one namespace bound above. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
EXECUTE format( |
|||
'ALTER TABLE %I.users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE', |
|||
ns_name |
|||
); |
|||
|
|||
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a |
|||
-- subset of what a Custom member holds -- it manages, and deletes, every collection reachable |
|||
-- through `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and |
|||
-- reads member and collection ACL details through `ManagerHeadersLoose`, none of which needs a |
|||
-- permission flag in the old schema -- so handing it out on anything less than a current, |
|||
-- deliberate decision would *grant* authority during a downgrade. |
|||
-- Historical provenance would not be that decision either: a record of who was a Manager |
|||
-- before the first upgrade is never updated afterwards, so a member whose powers an owner has |
|||
-- since reduced would get all of them back. |
|||
-- |
|||
-- Everything else becomes a plain User and keeps its per-collection assignments. |
|||
-- |
|||
-- `access_all` follows the same mapping the down migrations use: everyone who reached every |
|||
-- collection keeps that reach, and a Custom member has to hold all three collection permissions |
|||
-- -- Edit-only must not silently turn into the legacy "manage all collections" authority, which |
|||
-- in that older schema also carried collection deletion. A member mapped to plain User never |
|||
-- keeps it: `User + access_all` is the one legacy state the upgrade refuses. |
|||
EXECUTE format( |
|||
'UPDATE %I.users_organizations SET access_all = TRUE WHERE atype IN (0, 1)', ns_name |
|||
); |
|||
EXECUTE format( |
|||
'UPDATE %I.users_organizations SET access_all = TRUE ' |
|||
'WHERE atype = 4 ' |
|||
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist) ' |
|||
' AND create_new_collections = TRUE ' |
|||
' AND edit_any_collection = TRUE ' |
|||
' AND delete_any_collection = TRUE', |
|||
ns_name, ns_name |
|||
); |
|||
|
|||
-- The old server cannot load type 4. |
|||
EXECUTE format( |
|||
'UPDATE %I.users_organizations SET atype = 3 ' |
|||
'WHERE atype = 4 ' |
|||
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist)', |
|||
ns_name, ns_name |
|||
); |
|||
EXECUTE format( |
|||
'UPDATE %I.users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4', ns_name |
|||
); |
|||
|
|||
EXECUTE format( |
|||
'ALTER TABLE %I.users_organizations ' |
|||
' DROP COLUMN manage_users, ' |
|||
' DROP COLUMN manage_groups, ' |
|||
' DROP COLUMN manage_policies, ' |
|||
' DROP COLUMN create_new_collections, ' |
|||
' DROP COLUMN edit_any_collection, ' |
|||
' DROP COLUMN delete_any_collection, ' |
|||
' DROP COLUMN access_event_logs, ' |
|||
' DROP COLUMN access_import_export, ' |
|||
' DROP COLUMN access_reports', |
|||
ns_name |
|||
); |
|||
|
|||
-- The two decisions this rollback required. A later re-upgrade needs neither: it reads the |
|||
-- restored `atype = 3` rows directly and converts them deterministically. |
|||
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_allow_custom_role_downgrade', ns_name); |
|||
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_rollback_manager_allowlist', ns_name); |
|||
|
|||
-- Finally forget the migration, so the older binary does not see a ledger from the future and a |
|||
-- later upgrade applies it again from a clean state. |
|||
EXECUTE format( |
|||
'DELETE FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''', |
|||
ns_name |
|||
); |
|||
END $$; |
|||
|
|||
COMMIT; |
|||
@ -0,0 +1,209 @@ |
|||
-- Roll a SQLite database back to the schema the Vaultwarden version *before* the Custom-role |
|||
-- change expects, so that older binary starts again. Read README.md in this directory first -- |
|||
-- it lists exactly what is lost and how to run this safely. |
|||
-- |
|||
-- `ALTER TABLE ... DROP COLUMN` is avoided on purpose -- it needs SQLite 3.35, and this script has to |
|||
-- work on the same older system SQLite the forward migrations support. The rebuild recreates |
|||
-- `access_all` and drops all nine permission columns in one step. |
|||
|
|||
-- Stop at the first error. Without this the sqlite3 shell keeps going after a failed statement, |
|||
-- and a second run -- where the SELECT below can no longer see the permission columns -- would |
|||
-- still reach DROP TABLE and commit an empty users_organizations. `.bail on` is a shell command; |
|||
-- a runner that is not the sqlite3 CLI has to abort on the first error and roll back by itself. |
|||
.bail on |
|||
|
|||
PRAGMA foreign_keys = OFF; |
|||
|
|||
BEGIN; |
|||
|
|||
-- Refuse to start at all unless the database is in the exact state this script converts *from*. A |
|||
-- repeat run, or a half-finished upgrade, would otherwise only fail somewhere in the middle. Each |
|||
-- check is read-only, and the name of the failing CHECK constraint *is* the error message. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_membership_access_all_still_exists_so_this_database_was_not_upgraded_or_was_already_rolled_back |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT CASE |
|||
WHEN NOT EXISTS (SELECT 1 FROM pragma_table_xinfo('users_organizations') WHERE name = 'access_all') |
|||
THEN 1 |
|||
ELSE 0 |
|||
END; |
|||
DROP TABLE __vw_rollback_precondition; |
|||
|
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_columns ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_all_nine_custom_role_permission_columns_must_exist_restore_the_pre_upgrade_backup |
|||
CHECK (ok = 9) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_columns (ok) |
|||
SELECT COUNT(*) |
|||
FROM pragma_table_xinfo('users_organizations') |
|||
WHERE name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_columns; |
|||
|
|||
-- The rebuild below copies a fixed column list, so anything this script does not know about would be |
|||
-- silently dropped together with its data. Require the table to hold *exactly* the eighteen columns |
|||
-- the Custom-role upgrade leaves behind -- not merely to contain them. A newer migration that added a |
|||
-- column, or a local modification, therefore refuses here instead of being destroyed at COMMIT. |
|||
-- |
|||
-- `table_xinfo`, not `table_info`: the latter omits generated columns entirely, so a STORED or |
|||
-- VIRTUAL column would pass the count unseen and then be lost in the rebuild. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_exact_columns ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_users_organizations_has_unexpected_columns_this_script_is_older_than_the_database |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_exact_columns (ok) |
|||
SELECT CASE WHEN total = 18 AND known = 18 THEN 1 ELSE 0 END |
|||
FROM ( |
|||
SELECT |
|||
COUNT(*) AS total, |
|||
SUM(CASE WHEN name IN ( |
|||
'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' |
|||
) THEN 1 ELSE 0 END) AS known |
|||
FROM pragma_table_xinfo('users_organizations') |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_exact_columns; |
|||
|
|||
-- Same reasoning for everything else attached to the table: `DROP TABLE` takes its indexes and |
|||
-- triggers with it, and the rebuild recreates only the PRIMARY KEY and the UNIQUE pair. |
|||
-- |
|||
-- Counting `index_list` rather than `sqlite_master` on purpose. An index that SQLite created for a |
|||
-- UNIQUE constraint has no SQL text, so `sqlite_master.sql IS NOT NULL` cannot see it -- an extra |
|||
-- `UNIQUE(external_id)` would pass unnoticed and be gone afterwards. `index_list` reports every |
|||
-- index, so the upgraded table's own two are the exact expected count. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_objects ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_users_organizations_has_extra_indexes_constraints_or_triggers_the_rebuild_would_destroy |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_objects (ok) |
|||
SELECT CASE WHEN indexes = 2 AND triggers = 0 THEN 1 ELSE 0 END |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM pragma_index_list('users_organizations')) AS indexes, |
|||
(SELECT COUNT(*) FROM sqlite_master |
|||
WHERE tbl_name = 'users_organizations' AND type = 'trigger') AS triggers |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_objects; |
|||
|
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_ledger ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_the_custom_role_migration_must_be_recorded_schema_and_ledger_disagree |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_ledger (ok) |
|||
SELECT COUNT(*) |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000' |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_ledger; |
|||
|
|||
-- A migration newer than the Custom-role one has run, so this script cannot know what it changed |
|||
-- or whether the rebuild below would undo it. Removing only that one version would also leave the |
|||
-- ledger claiming a migration whose schema objects are gone. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_migrations_newer_than_the_custom_role_change_are_recorded_use_a_newer_rollback_script |
|||
CHECK (ok = 0) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_future_ledger (ok) |
|||
SELECT COUNT(*) FROM __diesel_schema_migrations WHERE version > '20260630120000'; |
|||
DROP TABLE __vw_rollback_precondition_future_ledger; |
|||
|
|||
-- Which memberships come back as Manager has to be decided *for this rollback*. See README.md; an |
|||
-- empty list is a valid answer and maps every Custom member to plain User. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_allowlist ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_create_vw_rollback_manager_allowlist_first_see_readme_role_mapping |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_allowlist (ok) |
|||
SELECT COUNT(*) |
|||
FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist'; |
|||
DROP TABLE __vw_rollback_precondition_allowlist; |
|||
|
|||
CREATE TABLE users_organizations_rollback ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users (uuid), |
|||
org_uuid TEXT NOT NULL REFERENCES organizations (uuid), |
|||
access_all BOOLEAN NOT NULL DEFAULT 0, |
|||
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, |
|||
|
|||
UNIQUE (user_uuid, org_uuid) |
|||
); |
|||
|
|||
-- Roles and the legacy flag are recomputed together, because in the old schema they are not |
|||
-- independent. |
|||
-- |
|||
-- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and |
|||
-- keeps every grant `users_collections` and `collections_groups` carry. The legacy role is not a |
|||
-- subset of what a Custom member holds, so handing it out on less than a current, deliberate decision |
|||
-- would *grant* authority during a downgrade -- and historical provenance is not that decision. See |
|||
-- README.md. |
|||
-- |
|||
-- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three |
|||
-- collection permissions, because in the old schema the bit also carried collection deletion. A |
|||
-- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade |
|||
-- refuses, so leaving it set would strand this database. |
|||
INSERT INTO users_organizations_rollback ( |
|||
uuid, user_uuid, org_uuid, access_all, akey, status, atype, |
|||
reset_password_key, external_id, invited_by_email |
|||
) |
|||
SELECT |
|||
uo.uuid, uo.user_uuid, uo.org_uuid, |
|||
CASE |
|||
WHEN uo.atype IN (0, 1) THEN 1 |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND uo.create_new_collections = 1 |
|||
AND uo.edit_any_collection = 1 |
|||
AND uo.delete_any_collection = 1 THEN 1 |
|||
ELSE 0 |
|||
END, |
|||
uo.akey, uo.status, |
|||
-- The old server cannot load type 4. |
|||
CASE |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
THEN 3 |
|||
WHEN uo.atype = 4 THEN 2 |
|||
ELSE uo.atype |
|||
END, |
|||
uo.reset_password_key, uo.external_id, uo.invited_by_email |
|||
FROM users_organizations AS uo; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_rollback RENAME TO users_organizations; |
|||
|
|||
-- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored |
|||
-- `atype = 3` rows directly and converts them deterministically. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
|
|||
-- Finally forget the migration, so the older binary does not see a ledger from the future and a |
|||
-- later upgrade applies it again from a clean state. |
|||
DELETE FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000' |
|||
); |
|||
|
|||
COMMIT; |
|||
Loading…
Reference in new issue