Files
Oxicloud/migrations/20260730000000_role_grants.sql
T

245 lines
11 KiB
SQL
Raw Normal View History

-- ════════════════════════════════════════════════════════════════════════════
-- 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$;