Files
Oxicloud/migrations/20260730000000_role_grants.sql
T
Edouard Vanbelle f168c4578f feat(roles): prepare migration ReBAC to roles
prepare migration of permission to roles
    this simplify drastically database (permission are now simply defined in code)
    and will permit reuse of the same ReBAC engine to define owners of drives

    mapping:

    ```
        Role::Viewer => &[Permission::Read],
        Role::Commenter => &[Permission::Read, Permission::Comment],
        Role::Contributor => &[Permission::Read, Permission::Create],
        Role::Editor => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
        ],
        Role::Owner => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
            Permission::Share,
            Permission::Delete,
            Permission::Manage,
        ],
    ```
2026-06-18 02:47:06 +02:00

245 lines
11 KiB
SQL

-- ════════════════════════════════════════════════════════════════════════════
-- D-Prep: storage.role_grants — role-bundle replacement for access_grants
-- ════════════════════════════════════════════════════════════════════════════
-- Refactor #1 of the Drive sequence (see `docs/plan/drive.md` § Prerequisite).
--
-- Today every role assignment is stored as N rows in `storage.access_grants`
-- (one row per Permission in the role's bundle — editor = 4 rows, owner = 6).
-- This migration introduces `storage.role_grants` where each role assignment
-- is ONE row carrying the role name; permission expansion happens at engine
-- read time via the in-code `role_bundle()` function.
--
-- The five roles shipped on day one:
-- viewer = {read}
-- commenter = {comment, read} ← new
-- contributor = {create, read} ← new
-- editor = {comment, create, read, update}
-- owner = {comment, create, delete, read, share, update}
-- (post-Drive: + manage, when Group-as-Resource lands)
--
-- This migration is **additive**: `storage.access_grants` stays populated as
-- a dual-write safety net until a follow-up cleanup PR drops it after the
-- new model has baked in production. The down migration just drops
-- role_grants — access_grants is untouched, so rollback is trivial.
--
-- Pre-flight: the migration REFUSES to run if `access_grants` contains any
-- non-bundle clusters (permission sets that don't match one of the five
-- roles above). Run `tools/audit-grants-bundle-shape.sql` first to confirm
-- the data is clean — Ed's audit on 2026-06-17 returned 100% bundle-shaped.
-- ── 1. Pre-flight assertion ─────────────────────────────────────────────────
-- Refuse to migrate if there are any non-bundle clusters. The five known
-- bundles are listed here verbatim; keep them in sync with the in-code
-- `role_bundle()` function.
DO $BODY$
DECLARE
bad_count BIGINT;
BEGIN
WITH cluster AS (
SELECT subject_type, subject_id, resource_type, resource_id,
array_agg(permission ORDER BY permission) AS perms
FROM storage.access_grants
GROUP BY 1, 2, 3, 4
)
SELECT count(*) INTO bad_count
FROM cluster
WHERE perms NOT IN (
ARRAY['read']::text[],
ARRAY['comment','read']::text[],
ARRAY['create','read']::text[],
ARRAY['comment','create','read','update']::text[],
ARRAY['comment','create','delete','read','share','update']::text[]
);
IF bad_count > 0 THEN
RAISE EXCEPTION
'D-Prep migration refused: % (subject,resource) clusters in '
'storage.access_grants have non-bundle permission sets. Run '
'tools/audit-grants-bundle-shape.sql section 3 to inspect them, '
'then either resolve manually or extend the bundle list above '
'with a new named role before retrying.', bad_count;
END IF;
END $BODY$;
-- ── 2. The role_grants table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS storage.role_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the role)
-- 'user' → auth.users.id
-- 'group' → storage.subject_groups.id
-- 'token' → storage.shares.id (anonymous link — always 'viewer')
subject_type TEXT NOT NULL
CHECK (subject_type IN ('user', 'group', 'token')),
subject_id UUID NOT NULL,
-- Resource (what the role is on)
-- 'drive' and 'group' join later as Drive + Group-as-Resource land.
resource_type TEXT NOT NULL
CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Role — expands to a permission bundle via the in-code `role_bundle()`
-- function. The CHECK lists the day-one role roster; adding a new
-- role is a single ALTER TABLE DROP CONSTRAINT / ADD CONSTRAINT pair
-- (or replace with a foreign key into a lookup table if instance-
-- defined roles ever land).
--
-- Universal roster: ANY role can be granted on ANY resource_type.
-- Permission bundles include capabilities the resource type may not
-- check for (e.g. `Manage` on a folder, `Create` on a file); those
-- produce harmless no-ops at engine read time — no per-resource-type
-- validation needed at the DB layer.
--
-- The UI exposes only Viewer/Editor/Owner in the share dialog today
-- (matches the existing 3-button UX). Commenter and Contributor stay
-- in the enum for server-side use + future UI exposure when a real
-- use case asks for them.
role TEXT NOT NULL
CHECK (role IN ('viewer', 'commenter', 'contributor', 'editor', 'owner')),
-- Audit + lifecycle
granted_by UUID NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
-- Exactly one role per (subject, resource). Atomic role changes become
-- a single UPDATE; no DELETE+INSERT race.
UNIQUE (subject_type, subject_id, resource_type, resource_id)
);
COMMENT ON TABLE storage.role_grants IS
'Role-based ReBAC grants. One row = one role assignment. Permission '
'bundle expansion is in-code; see role_bundle() in '
'src/application/dtos/grant_dto.rs. Replaces storage.access_grants; '
'both tables coexist during the D-Prep dual-write window.';
COMMENT ON COLUMN storage.role_grants.role IS
'One of viewer / commenter / contributor / editor / owner. Expanded to '
'a Permission bundle by the in-code role_bundle() function at engine '
'read time.';
-- ── 3. Indexes — match the hot-path queries ─────────────────────────────────
-- "What does this caller have access to?" — every WebDAV / NC request,
-- every UI default-drive resolution (post-Drive) hits this.
CREATE INDEX IF NOT EXISTS idx_role_grants_subject
ON storage.role_grants (subject_type, subject_id);
-- "Who has access to this resource?" — share dialogs, audit views.
CREATE INDEX IF NOT EXISTS idx_role_grants_resource
ON storage.role_grants (resource_type, resource_id);
-- Partial index on expiry — only rows that actually expire (mirrors the
-- access_grants index pattern, same rationale).
CREATE INDEX IF NOT EXISTS idx_role_grants_expires_at
ON storage.role_grants (expires_at) WHERE expires_at IS NOT NULL;
-- For GET /api/grants/outgoing/resources (who granted what).
CREATE INDEX IF NOT EXISTS idx_role_grants_granted_by
ON storage.role_grants (granted_by);
-- ── 4. Backfill from access_grants ─────────────────────────────────────────
-- For each (subject, resource) cluster in access_grants, write one
-- role_grants row with the matching role. The CASE expression mirrors
-- `Role::expand()` exactly — when that function changes (new role added),
-- update both this CASE and the CHECK constraint above.
--
-- expires_at: take MIN across the cluster (most conservative — the role
-- assignment expires at the earliest expiry of any of its constituent
-- grants). granted_at: MIN (when the role assignment started). granted_by:
-- the granter of the earliest row (preserves attribution to the admin who
-- initially set the role up).
WITH cluster AS (
SELECT subject_type,
subject_id,
resource_type,
resource_id,
array_agg(permission ORDER BY permission) AS perms,
MIN(granted_at) AS earliest_granted_at,
MIN(expires_at) AS earliest_expires_at
FROM storage.access_grants
GROUP BY 1, 2, 3, 4
),
earliest_grantor AS (
SELECT DISTINCT ON (subject_type, subject_id, resource_type, resource_id)
subject_type,
subject_id,
resource_type,
resource_id,
granted_by
FROM storage.access_grants
ORDER BY subject_type, subject_id, resource_type, resource_id, granted_at ASC
)
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by, granted_at, expires_at)
SELECT
c.subject_type,
c.subject_id,
c.resource_type,
c.resource_id,
CASE c.perms
WHEN ARRAY['read']::text[]
THEN 'viewer'
WHEN ARRAY['comment','read']::text[]
THEN 'commenter'
WHEN ARRAY['create','read']::text[]
THEN 'contributor'
WHEN ARRAY['comment','create','read','update']::text[]
THEN 'editor'
WHEN ARRAY['comment','create','delete','read','share','update']::text[]
THEN 'owner'
END AS role,
eg.granted_by,
c.earliest_granted_at,
c.earliest_expires_at
FROM cluster c
JOIN earliest_grantor eg USING (subject_type, subject_id, resource_type, resource_id)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING;
-- ── 5. Post-flight consistency check ───────────────────────────────────────
-- Assert that the backfill landed one role_grants row per (subject,
-- resource) cluster in access_grants. Any mismatch means a bundle pattern
-- silently failed to match — refuses to commit, surfacing the bug.
DO $BODY$
DECLARE
expected_clusters BIGINT;
actual_role_grants BIGINT;
null_roles BIGINT;
BEGIN
SELECT count(*) INTO expected_clusters
FROM (
SELECT 1 FROM storage.access_grants
GROUP BY subject_type, subject_id, resource_type, resource_id
) c;
SELECT count(*) INTO actual_role_grants FROM storage.role_grants;
IF expected_clusters != actual_role_grants THEN
RAISE EXCEPTION
'D-Prep backfill consistency check failed: expected % role_grants '
'rows (one per distinct (subject, resource) cluster in access_grants), '
'got %. Investigate before declaring the migration successful.',
expected_clusters, actual_role_grants;
END IF;
-- Defensive: NULL role would mean the CASE expression failed to match.
-- Pre-flight already refuses this, but double-check.
SELECT count(*) INTO null_roles FROM storage.role_grants WHERE role IS NULL;
IF null_roles > 0 THEN
RAISE EXCEPTION
'D-Prep backfill produced % role_grants rows with NULL role — '
'a bundle pattern slipped past the pre-flight check. Investigate.',
null_roles;
END IF;
END $BODY$;