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
315 lines
10 KiB
SQL
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;
|