Files
planflow/prisma/migrations/20260807223246_tenancy_identity_authorization/migration.sql
T
Claude 11763142d5 WP-01: tenancy, identity and capability authorization
Adds the data model for accounts, locations, teams, users, memberships
and scopes, plus roles, the 70-capability catalogue, database-backed
sessions, and the audit log.

Isolation is enforced twice, independently. A Prisma extension injects
accountId into every query, and PostgreSQL row-level security filters
underneath it, keyed on a transaction-local setting. The first alone
leaves raw queries unguarded; the second alone returns empty results
without saying why.

Integration tests prove both against a real database rather than
through the application layer, which would only prove the application
layer. They create a restricted role to do it — and that exposed a trap
worth naming: **a PostgreSQL superuser bypasses row-level security even
with FORCE**. Connecting the app as one silently disables the second
layer while every application test still passes. checkTenantIsolation
now refuses to start in production on such a database, warns in
development, and reports through /api/sante. The README explains the
role to create.

The audit log is append-only by trigger, so it resists even a
superuser: a trail that can be rewritten proves nothing. Entries
carrying an adjustment or an unlock are rejected without a
justification, and known secret-bearing fields are redacted before
writing — the log is read, exported and kept for years, so it must not
become a second unencrypted copy of what is encrypted elsewhere.

Sensitive columns use AES-256-GCM with the key held outside the
database. Sign-in verifies a dummy hash for unknown accounts so timing
does not enumerate addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-07 22:39:33 +00:00

315 lines
10 KiB
SQL

-- CreateEnum
CREATE TYPE "MembershipStatus" AS ENUM ('INVITED', 'ACTIVE', 'ARCHIVED');
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"siren" TEXT,
"apeCode" TEXT,
"collectiveAgreementId" TEXT,
"agreementOverrides" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"siret" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/Paris',
"employerContributionRate" DECIMAL(5,2) NOT NULL DEFAULT 0,
"silaeDossier" TEXT,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'fr',
"mfaSecretEnc" BYTEA,
"mfaEnrolledAt" TIMESTAMP(3),
"lastSignInAt" TIMESTAMP(3),
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Membership" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"userId" TEXT,
"roleId" TEXT NOT NULL,
"lineManagerId" TEXT,
"employeeNumber" TEXT NOT NULL,
"silaeMatricule" TEXT,
"status" "MembershipStatus" NOT NULL DEFAULT 'INVITED',
"invitedAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MembershipScope" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"allLocations" BOOLEAN NOT NULL DEFAULT false,
"locationId" TEXT,
"teamId" TEXT,
CONSTRAINT "MembershipScope_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip" TEXT,
"userAgent" TEXT,
"revokedAt" TIMESTAMP(3),
"revokedBy" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invitation" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"email" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"category" TEXT NOT NULL,
"label" TEXT NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"actorMembershipId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"reason" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RetentionPolicy" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"objectType" TEXT NOT NULL,
"durationMonths" INTEGER NOT NULL,
"startPoint" TEXT NOT NULL,
"justification" TEXT NOT NULL,
"legalHold" BOOLEAN NOT NULL DEFAULT false,
"effectiveFrom" DATE NOT NULL,
CONSTRAINT "RetentionPolicy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeatureFlag" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"noticeDocumentRef" TEXT,
"noticeDeliveredAt" TIMESTAMP(3),
"cseOpinionAt" TIMESTAMP(3),
"activatedAt" TIMESTAMP(3),
CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Location_accountId_idx" ON "Location"("accountId");
-- CreateIndex
CREATE INDEX "Team_accountId_idx" ON "Team"("accountId");
-- CreateIndex
CREATE INDEX "Team_locationId_idx" ON "Team"("locationId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "Membership_accountId_idx" ON "Membership"("accountId");
-- CreateIndex
CREATE INDEX "Membership_userId_idx" ON "Membership"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Membership_accountId_employeeNumber_key" ON "Membership"("accountId", "employeeNumber");
-- CreateIndex
CREATE INDEX "MembershipScope_accountId_idx" ON "MembershipScope"("accountId");
-- CreateIndex
CREATE INDEX "MembershipScope_membershipId_idx" ON "MembershipScope"("membershipId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_tokenHash_key" ON "Invitation"("tokenHash");
-- CreateIndex
CREATE INDEX "Invitation_accountId_idx" ON "Invitation"("accountId");
-- CreateIndex
CREATE INDEX "Role_accountId_idx" ON "Role"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_accountId_key_key" ON "Role"("accountId", "key");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- CreateIndex
CREATE INDEX "AuditLog_accountId_entityType_entityId_idx" ON "AuditLog"("accountId", "entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_accountId_occurredAt_idx" ON "AuditLog"("accountId", "occurredAt");
-- CreateIndex
CREATE INDEX "RetentionPolicy_accountId_idx" ON "RetentionPolicy"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "RetentionPolicy_accountId_objectType_effectiveFrom_key" ON "RetentionPolicy"("accountId", "objectType", "effectiveFrom");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureFlag_accountId_key_key" ON "FeatureFlag"("accountId", "key");
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_lineManagerId_fkey" FOREIGN KEY ("lineManagerId") REFERENCES "Membership"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorMembershipId_fkey" FOREIGN KEY ("actorMembershipId") REFERENCES "Membership"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RetentionPolicy" ADD CONSTRAINT "RetentionPolicy_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeatureFlag" ADD CONSTRAINT "FeatureFlag_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;