dorfteich/apps/api/prisma/migrations/20260709115727_role_grants/migration.sql
Claude Opus 4.8 4d48d72c40
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add grant model and shared permission-resolution algorithm (#51)
The heart of the security model: one algorithm, implemented once, for API,
collab, and UI (permissions.md — authoritative).

- shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per
  permissions.md — specificity page > label (incl. ancestor labels) > pond,
  deny wins within a level, default-closed, Site Admin bypass — plus the trash
  rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError`
  enforces the structural constraints. Documented, I/O-free signatures for
  API/collab reuse.
- prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on
  (pond, subject, role, scope); migration adds a CHECK backstop that a
  POND_ADMIN grant is pond-scope + user-subject.
- api `grants/`: `GrantsService.createGrant` validates before insert (structural
  + no extra admin on a personal pond), rejects duplicates; `grantsForPond`
  returns the shared resolver model (what #52/#53 consume); enum mappers between
  the DB and the shared model. Interim "who may manage grants" stays until #52.
- tests: exhaustive table-driven resolver suite — every worked example from
  permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor
  inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny,
  trash) and a property test (a less-specific grant never overrides a
  more-specific decision); validation unit tests; grants db test proving
  write-time rejection of invalid grants.
- i18n: grant error codes (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00

46 lines
1.6 KiB
SQL

-- CreateEnum
CREATE TYPE "GrantSubjectType" AS ENUM ('USER', 'AUTHENTICATED', 'PUBLIC');
-- CreateEnum
CREATE TYPE "GrantRole" AS ENUM ('POND_ADMIN', 'EDITOR', 'READER');
-- CreateEnum
CREATE TYPE "GrantScopeType" AS ENUM ('POND', 'LABEL', 'PAGE');
-- CreateEnum
CREATE TYPE "GrantEffect" AS ENUM ('ALLOW', 'DENY');
-- DropIndex
DROP INDEX "page_content_cache_search_vector_idx";
-- CreateTable
CREATE TABLE "role_grants" (
"id" TEXT NOT NULL,
"pond_id" TEXT NOT NULL,
"subject_type" "GrantSubjectType" NOT NULL,
"subject_id" TEXT,
"role" "GrantRole" NOT NULL,
"scope_type" "GrantScopeType" NOT NULL,
"scope_id" TEXT,
"effect" "GrantEffect" NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_grants_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "role_grants_pond_id_idx" ON "role_grants"("pond_id");
-- CreateIndex
CREATE UNIQUE INDEX "role_grants_pond_id_subject_type_subject_id_role_scope_type_key" ON "role_grants"("pond_id", "subject_type", "subject_id", "role", "scope_type", "scope_id");
-- AddForeignKey
ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Structural backstop (permissions.md, issue #51): a POND_ADMIN grant is only
-- valid at pond scope for a specific user. The personal-pond single-admin rule
-- needs the pond type and is enforced in the service, not here.
ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_admin_scope_check"
CHECK ("role" <> 'POND_ADMIN' OR ("scope_type" = 'POND' AND "subject_type" = 'USER'));