refactor(role): use grant only
- remove permission centric mode
- finalize migration drop all tables with permissions
- ensure roles are ENUM (owner is always displayed first)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #1: storage.role_grants.role — TEXT → storage.grant_role ENUM
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- D-Prep shipped `role_grants.role` as TEXT + CHECK constraint. Promoting it
|
||||
-- to a native PostgreSQL ENUM gives us three things at once:
|
||||
--
|
||||
-- 1. Index-driven sort by role strength. The ENUM values are declared in
|
||||
-- strength order — owner first, viewer last. `ORDER BY role ASC` then
|
||||
-- yields the UX-mandated "strongest first" ordering (Owner → Editor →
|
||||
-- Contributor → Commenter → Viewer) without a CASE expression. The
|
||||
-- `idx_role_grants_subject` / `idx_role_grants_resource` indexes can be
|
||||
-- extended (or composite-augmented) with the role column for index-only
|
||||
-- ordered scans.
|
||||
--
|
||||
-- 2. Type-level safety. The CHECK constraint goes away; invalid roles fail
|
||||
-- at the column type, not at row insertion. One contract instead of two
|
||||
-- (column type AND check constraint).
|
||||
--
|
||||
-- 3. Cleaner query shape. Every listing query that used the strength CASE
|
||||
-- becomes a plain `ORDER BY role` after this migration.
|
||||
--
|
||||
-- Trade-off accepted: PostgreSQL ENUMs allow ADD VALUE (with BEFORE / AFTER
|
||||
-- positional anchors) and RENAME VALUE, but not DROP VALUE or arbitrary
|
||||
-- reorder. The OxiCloud role roster is intentionally stable — new roles get
|
||||
-- appended, none get reordered or removed. Confirmed with Ed.
|
||||
--
|
||||
-- This migration must run BEFORE the access_grants drop, since it's purely
|
||||
-- about role_grants.role.
|
||||
|
||||
-- ── 1. Create the ENUM type ────────────────────────────────────────────────
|
||||
-- Declaration order = sort order. Strongest first so `ORDER BY role ASC`
|
||||
-- matches the UX requirement (max permission → least permission).
|
||||
|
||||
CREATE TYPE storage.grant_role AS ENUM (
|
||||
'owner', -- ordinal 0, sorts first
|
||||
'editor', -- ordinal 1
|
||||
'contributor', -- ordinal 2
|
||||
'commenter', -- ordinal 3
|
||||
'viewer' -- ordinal 4, sorts last
|
||||
);
|
||||
|
||||
COMMENT ON TYPE storage.grant_role IS
|
||||
'Role-keyed grant strength. Declaration order is sort order: ORDER BY '
|
||||
'role ASC yields owner → viewer (strongest → weakest), matching the '
|
||||
'share-dialog and shared-with-me UX. Adding a new role is ALTER TYPE '
|
||||
'ADD VALUE; renaming is ALTER TYPE RENAME VALUE. Dropping or reordering '
|
||||
'is not supported — adjust the roster only by append.';
|
||||
|
||||
|
||||
-- ── 2. Drop the redundant CHECK constraint ─────────────────────────────────
|
||||
-- The inline CHECK on role_grants.role was auto-named
|
||||
-- `role_grants_role_check` by PostgreSQL. Drop it before the type swap —
|
||||
-- the ENUM now enforces the same invariant at the column level.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
DROP CONSTRAINT IF EXISTS role_grants_role_check;
|
||||
|
||||
|
||||
-- ── 3. Convert role TEXT → storage.grant_role ──────────────────────────────
|
||||
-- USING cast: text values are guaranteed to be one of the five valid labels
|
||||
-- (the dropped CHECK enforced this; the D-Prep backfill only produced these
|
||||
-- five values). If a stray value slipped through, the cast errors out and
|
||||
-- the migration aborts — preferable to silently coercing.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
ALTER COLUMN role TYPE storage.grant_role
|
||||
USING role::storage.grant_role;
|
||||
|
||||
COMMENT ON COLUMN storage.role_grants.role IS
|
||||
'One of owner / editor / contributor / commenter / viewer. Expanded to '
|
||||
'a Permission bundle by the in-code role_bundle() function at engine '
|
||||
'read time. Sort order matches declaration order in storage.grant_role.';
|
||||
@@ -0,0 +1,122 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #2: cascade triggers for storage.role_grants
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The D-Prep migration created `storage.role_grants` but no cascade triggers.
|
||||
-- Until now, role_grants stayed consistent because the application-layer
|
||||
-- lifecycle hooks (`engine.revoke_all_for_resource` / `_subject`) wiped rows
|
||||
-- on the canonical delete paths, AND the existing `trg_cleanup_grants_*`
|
||||
-- triggers kept `storage.access_grants` clean as a defence-in-depth net.
|
||||
--
|
||||
-- The follow-up cleanup PR drops `access_grants` (and its triggers) entirely.
|
||||
-- Without this migration that drop would leave `role_grants` without any
|
||||
-- DB-level safety net — direct SQL, future codepaths that forget to call the
|
||||
-- engine hooks, and any other bypass route could orphan rows whose subject
|
||||
-- or resource has already been deleted.
|
||||
--
|
||||
-- This migration mirrors the four forward + one reverse triggers from
|
||||
-- `20260520000000_rebac_access_grants.sql` and `20260612000001_share_grant_
|
||||
-- reverse_cascade.sql`, retargeted at `storage.role_grants`. Same shape, same
|
||||
-- AFTER-DELETE semantics, same idempotent CREATE OR REPLACE patterns.
|
||||
--
|
||||
-- During the transition window (this migration applied; `access_grants` not
|
||||
-- yet dropped) both sets of triggers coexist — they target different tables
|
||||
-- and don't conflict. Once `access_grants` is dropped, the old triggers and
|
||||
-- their helper functions vanish in the same migration.
|
||||
|
||||
-- ── 1. Forward cascade: resource delete → cleanup role_grants ──────────────
|
||||
-- Fires AFTER DELETE on storage.folders / storage.files; deletes every
|
||||
-- role_grants row referencing that resource. TG_ARGV[0] discriminates which
|
||||
-- resource_type the trigger is wired for.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_resource_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.role_grants
|
||||
WHERE resource_type = TG_ARGV[0]
|
||||
AND resource_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_folder ON storage.folders;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_folder
|
||||
AFTER DELETE ON storage.folders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('folder');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_file ON storage.files;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_file
|
||||
AFTER DELETE ON storage.files
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('file');
|
||||
|
||||
|
||||
-- ── 2. Forward cascade: subject delete → cleanup role_grants ───────────────
|
||||
-- Fires AFTER DELETE on auth.users / storage.shares; deletes every
|
||||
-- role_grants row referencing that subject. Groups are NOT wired here —
|
||||
-- `subject_group_service::delete()` performs that cascade transactionally
|
||||
-- in application code, mirroring the historical access_grants behaviour.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_subject_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.role_grants
|
||||
WHERE subject_type = TG_ARGV[0]
|
||||
AND subject_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_user ON auth.users;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_user
|
||||
AFTER DELETE ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('user');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_token ON storage.shares;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_token
|
||||
AFTER DELETE ON storage.shares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('token');
|
||||
|
||||
|
||||
-- ── 3. Reverse cascade: last-token-grant delete → cleanup storage.shares ───
|
||||
-- A caller hitting DELETE /api/grants/{id} on a token's role grant would
|
||||
-- otherwise leave the storage.shares row stranded — the token still
|
||||
-- resolves to "no access" (cascade query finds no rows), but the metadata
|
||||
-- row accumulates forever.
|
||||
--
|
||||
-- With role_grants the UNIQUE (subject, resource) constraint guarantees a
|
||||
-- token has at most ONE role grant per resource, so "the last grant for a
|
||||
-- token" collapses to "the only grant for that token". The NOT EXISTS
|
||||
-- guard still works correctly — it just always evaluates the same way for
|
||||
-- token subjects.
|
||||
--
|
||||
-- The DELETE on storage.shares is a no-op when the share row is already
|
||||
-- gone (the forward cascade `trg_cleanup_role_grants_token` is in flight
|
||||
-- and already removed it). Idempotent in both directions.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_share_on_last_role_grant_delete()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.subject_type = 'token' THEN
|
||||
DELETE FROM storage.shares s
|
||||
WHERE s.id = OLD.subject_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM storage.role_grants rg
|
||||
WHERE rg.subject_type = 'token'
|
||||
AND rg.subject_id = OLD.subject_id
|
||||
);
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_share_on_role_grant_delete ON storage.role_grants;
|
||||
CREATE TRIGGER trg_cleanup_share_on_role_grant_delete
|
||||
AFTER DELETE ON storage.role_grants
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_share_on_last_role_grant_delete();
|
||||
|
||||
COMMENT ON FUNCTION storage.cleanup_share_on_last_role_grant_delete() IS
|
||||
'Reverse cascade: deletes storage.shares row when its last token role grant is removed. Pairs with trg_cleanup_role_grants_token (forward direction).';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #3: drop storage.access_grants (and everything attached to it)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The final step of the role-keyed ReBAC cleanup. By the time this migration
|
||||
-- runs:
|
||||
--
|
||||
-- * Every read path goes through `storage.role_grants` (cleanup #1 / #2).
|
||||
-- * The engine no longer has a `grant()` method; `set_role()` /
|
||||
-- `clear_role()` are the only writes.
|
||||
-- * The HTTP surface (`POST /api/grants`, `PUT /api/grants/role`) only
|
||||
-- accepts role-keyed shapes.
|
||||
-- * `share_service`, `subject_group_service`, `auth_application_service`,
|
||||
-- `share_pg_repository`, and `integration_test_support` all read
|
||||
-- `role_grants` exclusively.
|
||||
-- * `storage.role_grants` has its own cascade triggers
|
||||
-- (`trg_cleanup_role_grants_*`) and reverse-cascade
|
||||
-- (`trg_cleanup_share_on_role_grant_delete`), added in cleanup #2.
|
||||
--
|
||||
-- So `access_grants` is fully unreferenced — we can drop it together with
|
||||
-- the helper triggers + functions defined in
|
||||
-- `20260520000000_rebac_access_grants.sql` and
|
||||
-- `20260612000001_share_grant_reverse_cascade.sql`.
|
||||
--
|
||||
-- Roll-back posture: this is destructive. There is no down migration. The
|
||||
-- D-Prep backfill is one-way (role-keyed rows are derived from
|
||||
-- permission-keyed clusters; the reverse reconstruction would need a fixed
|
||||
-- bundle mapping that may have shifted between releases). Recovering
|
||||
-- requires restoring from a backup taken before this migration runs.
|
||||
|
||||
-- ── 1. Drop the access_grants triggers FROM their source tables ────────────
|
||||
-- These triggers live on storage.folders / storage.files / auth.users /
|
||||
-- storage.shares. Dropping access_grants doesn't implicitly remove them
|
||||
-- (the trigger row points at the source table; the body references the
|
||||
-- target table, and that body is what breaks once access_grants is gone).
|
||||
-- Drop them explicitly so subsequent DELETEs on those source tables don't
|
||||
-- error out.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
|
||||
|
||||
-- The reverse-cascade trigger is ON access_grants and goes away with the
|
||||
-- table — but the IF EXISTS makes this safe regardless of drop order.
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_share_on_grant_delete ON storage.access_grants;
|
||||
|
||||
|
||||
-- ── 2. Drop the trigger helper functions ────────────────────────────────────
|
||||
-- No other code references these — the `cleanup_role_grants_*` equivalents
|
||||
-- defined in cleanup #2 carry the same behaviour against role_grants.
|
||||
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_resource_delete();
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_subject_delete();
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_share_on_last_token_grant_delete();
|
||||
|
||||
|
||||
-- ── 3. Drop the table ──────────────────────────────────────────────────────
|
||||
-- CASCADE removes any remaining dependent objects (indexes, comments, and
|
||||
-- the reverse-cascade trigger if it survived step 1). With every Rust code
|
||||
-- path already routed through role_grants, nothing in the application
|
||||
-- layer will notice.
|
||||
|
||||
DROP TABLE IF EXISTS storage.access_grants CASCADE;
|
||||
Reference in New Issue
Block a user