f168c4578f
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,
],
```
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$;
|