245 lines
11 KiB
SQL
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$;
|