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
This commit is contained in:
16 files changed
+2037
-9
No files matched your search
@@ -12,7 +12,9 @@ La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative :
|
||||
|
||||
## État
|
||||
|
||||
**WP-00 — socle.** Next.js, Prisma, base de données, en-têtes de sécurité, tests, CI, image Docker. Aucun écran métier : ils arrivent à partir de WP-01 (tenancy, identité, autorisation).
|
||||
**WP-00 — socle** et **implémentation du design** : six écrans sur données de démonstration.
|
||||
|
||||
**WP-01 — tenancy, identité, autorisation** : en cours. Modèle de données, RLS, journal d'audit append-only, catalogue de 70 capacités, cinq rôles, sessions en base. Les écrans lisent encore `src/lib/demo`.
|
||||
|
||||
## Démarrer
|
||||
|
||||
@@ -57,6 +59,22 @@ pnpm test:e2e # build, serveur standalone, tests de bout en bout
|
||||
|
||||
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
|
||||
|
||||
## Configuration de la base — à ne pas rater
|
||||
|
||||
**L'application ne doit pas se connecter en superutilisateur PostgreSQL.**
|
||||
|
||||
Un superutilisateur contourne la *row-level security*, y compris déclarée en `FORCE`. Connecter PlanFlow avec un tel compte désactive silencieusement la seconde couche d'isolation multi-tenant : les requêtes fonctionnent, les tests applicatifs passent, et rien n'indique que la protection a disparu — jusqu'au jour où quelqu'un lit les données d'un autre établissement.
|
||||
|
||||
```sql
|
||||
CREATE ROLE planflow_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;
|
||||
GRANT USAGE ON SCHEMA public TO planflow_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO planflow_app;
|
||||
```
|
||||
|
||||
Les migrations, elles, s'appliquent avec un compte propriétaire distinct.
|
||||
|
||||
L'application vérifie ce point au démarrage : elle refuse de démarrer en production sur une base mal configurée, et se contente d'un avertissement en développement. `GET /api/sante` expose l'état sous `tenantIsolation`.
|
||||
|
||||
## Choix structurants
|
||||
|
||||
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Défense en profondeur — PLAN.md §3.1 et §3.5.
|
||||
--
|
||||
-- L'extension Prisma filtre déjà chaque requête par compte. Ces règles-ci
|
||||
-- valent pour le cas où elle serait contournée : requête brute, bug de
|
||||
-- câblage, ou console de maintenance. Une isolation qui repose sur une seule
|
||||
-- couche applicative n'est pas une isolation.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Journal d'audit append-only
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION planflow_deny_write() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'La table % est en append-only : ni UPDATE ni DELETE. Une correction s''écrit, elle ne se réécrit pas.',
|
||||
TG_TABLE_NAME
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER audit_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON "AuditLog"
|
||||
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. Row-level security, portée par le compte courant
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Le compte est transmis par `SET LOCAL app.account_id` à l'ouverture d'une
|
||||
-- transaction (voir src/server/tenant.ts). Hors transaction scopée, le réglage
|
||||
-- est absent et les politiques ne laissent rien passer.
|
||||
--
|
||||
-- current_setting(..., true) renvoie NULL plutôt que d'échouer quand le
|
||||
-- réglage n'existe pas : c'est ce qui permet à la politique de refuser au lieu
|
||||
-- de faire planter la requête avec une erreur peu parlante.
|
||||
|
||||
CREATE OR REPLACE FUNCTION planflow_current_account() RETURNS text AS $$
|
||||
SELECT NULLIF(current_setting('app.account_id', true), '');
|
||||
$$ LANGUAGE sql STABLE;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'Location', 'Team', 'Membership', 'MembershipScope', 'Invitation',
|
||||
'Role', 'AuditLog', 'RetentionPolicy', 'FeatureFlag'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
|
||||
t
|
||||
);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
|
||||
t
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Account lui-même : lecture limitée au compte courant.
|
||||
ALTER TABLE "Account" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "Account" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON "Account"
|
||||
USING (id = planflow_current_account());
|
||||
CREATE POLICY tenant_insert ON "Account"
|
||||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Rôle applicatif
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Le propriétaire d'une table contourne la RLS, sauf FORCE ci-dessus. FORCE
|
||||
-- l'applique aussi au propriétaire, donc les migrations et les tâches
|
||||
-- d'administration doivent passer explicitement par un compte à privilèges.
|
||||
-- On ne crée pas de rôle séparé ici : le déploiement auto-hébergé n'en a qu'un,
|
||||
-- et FORCE suffit à rendre les politiques effectives.
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g. Git)
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
+279
-5
@@ -1,14 +1,288 @@
|
||||
// PlanFlow — schéma de données.
|
||||
// PlanFlow — schéma de données. Voir PLAN.md §4.
|
||||
//
|
||||
// WP-00 n'installe que la plomberie : datasource, generator et les extensions
|
||||
// PostgreSQL dont les lots suivants dépendent. Les modèles métier arrivent au
|
||||
// WP-01 (tenancy, identité, autorisation) — voir PLAN.md §4.
|
||||
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tenancy — PLAN.md §4.1
|
||||
// ============================================================================
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
siren String?
|
||||
apeCode String?
|
||||
collectiveAgreementId String?
|
||||
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
|
||||
/// l'organisation auditée n'a pas d'accord d'entreprise.
|
||||
agreementOverrides Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
locations Location[]
|
||||
memberships Membership[]
|
||||
roles Role[]
|
||||
auditLogs AuditLog[]
|
||||
retention RetentionPolicy[]
|
||||
featureFlags FeatureFlag[]
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
name String
|
||||
siret String?
|
||||
timezone String @default("Europe/Paris")
|
||||
|
||||
/// Taux moyen de cotisations patronales, en pourcentage.
|
||||
employerContributionRate Decimal @default(0) @db.Decimal(5, 2)
|
||||
silaeDossier String?
|
||||
archivedAt DateTime?
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
teams Team[]
|
||||
scopes MembershipScope[]
|
||||
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
locationId String
|
||||
name String
|
||||
position Int @default(0)
|
||||
archivedAt DateTime?
|
||||
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
scopes MembershipScope[]
|
||||
|
||||
@@index([accountId])
|
||||
@@index([locationId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Identité
|
||||
// ============================================================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
firstName String
|
||||
lastName String
|
||||
locale String @default("fr")
|
||||
|
||||
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
|
||||
mfaSecretEnc Bytes?
|
||||
mfaEnrolledAt DateTime?
|
||||
lastSignInAt DateTime?
|
||||
failedAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
memberships Membership[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
/// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
|
||||
/// salariés ne se connectent pas — ils doivent rester plannifiables et
|
||||
/// exportables sans compte (PLAN.md §4.1).
|
||||
model Membership {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
userId String?
|
||||
roleId String
|
||||
lineManagerId String?
|
||||
employeeNumber String
|
||||
silaeMatricule String?
|
||||
status MembershipStatus @default(INVITED)
|
||||
invitedAt DateTime?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
lineManager Membership? @relation("LineManager", fields: [lineManagerId], references: [id], onDelete: SetNull)
|
||||
reports Membership[] @relation("LineManager")
|
||||
scopes MembershipScope[]
|
||||
invitations Invitation[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@unique([accountId, employeeNumber])
|
||||
@@index([accountId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum MembershipStatus {
|
||||
INVITED
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
/// Périmètre d'un membership. `allLocations` évite d'énumérer 34 établissements
|
||||
/// pour un directeur — et de rater le 35ᵉ le jour de son ouverture.
|
||||
model MembershipScope {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
membershipId String
|
||||
allLocations Boolean @default(false)
|
||||
locationId String?
|
||||
teamId String?
|
||||
|
||||
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([membershipId])
|
||||
}
|
||||
|
||||
/// Session serveur. En base plutôt qu'en JWT : la matrice n° 23 impose de
|
||||
/// pouvoir révoquer une session, ce qu'un jeton signé ne permet pas.
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
ip String?
|
||||
userAgent String?
|
||||
revokedAt DateTime?
|
||||
revokedBy String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Invitation {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
membershipId String
|
||||
tokenHash String @unique
|
||||
email String
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Autorisation — PLAN.md §4.2 et §5
|
||||
// ============================================================================
|
||||
|
||||
/// Rôle configurable par le client. Le code référence `key`, jamais `name` :
|
||||
/// renommer « Manager » en « Responsable » ne doit rien casser.
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
key String
|
||||
name String
|
||||
isSystem Boolean @default(false)
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
permissions RolePermission[]
|
||||
memberships Membership[]
|
||||
|
||||
@@unique([accountId, key])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
/// Capacité stable, nommée `ressource.action.qualificatif`. Référentiel global :
|
||||
/// les capacités sont définies par le produit, seule leur attribution varie.
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
category String
|
||||
label String
|
||||
|
||||
roles RolePermission[]
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String
|
||||
permissionId String
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Traçabilité — PLAN.md §3.5
|
||||
// ============================================================================
|
||||
|
||||
/// Journal d'audit. Append-only, imposé par un trigger PostgreSQL : une piste
|
||||
/// qu'on peut réécrire ne prouve rien.
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
actorMembershipId String?
|
||||
action String
|
||||
entityType String
|
||||
entityId String
|
||||
before Json?
|
||||
after Json?
|
||||
reason String?
|
||||
ip String?
|
||||
userAgent String?
|
||||
occurredAt DateTime @default(now())
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
actor Membership? @relation(fields: [actorMembershipId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([accountId, entityType, entityId])
|
||||
@@index([accountId, occurredAt])
|
||||
}
|
||||
|
||||
/// Durées de conservation par objet — PLAN.md §12.5.
|
||||
/// « 5 ans partout » est explicitement proscrit : chaque durée porte son point
|
||||
/// de départ et sa justification.
|
||||
model RetentionPolicy {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
objectType String
|
||||
durationMonths Int
|
||||
startPoint String
|
||||
justification String
|
||||
legalHold Boolean @default(false)
|
||||
effectiveFrom DateTime @db.Date
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([accountId, objectType, effectiveFrom])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
/// Verrou de conformité — PLAN.md §12.4.
|
||||
/// Une fonctionnalité de contrôle reste inactive tant que la notice au salarié
|
||||
/// et l'avis du CSE ne sont pas enregistrés.
|
||||
model FeatureFlag {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
key String
|
||||
enabled Boolean @default(false)
|
||||
noticeDocumentRef String?
|
||||
noticeDeliveredAt DateTime?
|
||||
cseOpinionAt DateTime?
|
||||
activatedAt DateTime?
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([accountId, key])
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
|
||||
import {
|
||||
DEFAULT_ROLE_PERMISSIONS,
|
||||
PERMISSION_DEFINITIONS,
|
||||
SYSTEM_ROLES,
|
||||
} from '../src/domain/access/permissions';
|
||||
|
||||
/**
|
||||
* Jeu de données de départ — PLAN.md §11.
|
||||
*
|
||||
* **Entièrement fictif.** Deux établissements, des équipes, et les rôles
|
||||
* fournis. Le mot de passe de démonstration n'a de sens qu'en développement ;
|
||||
* il est refusé si NODE_ENV vaut production.
|
||||
*/
|
||||
|
||||
const DEMO_PASSWORD = 'planflow-demo-2026';
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL ?? '',
|
||||
});
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'Le seed installe un compte de démonstration : refusé en production.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('→ Capacités');
|
||||
for (const permission of PERMISSION_DEFINITIONS) {
|
||||
await prisma.permission.upsert({
|
||||
where: { code: permission.code },
|
||||
update: { category: permission.category, label: permission.label },
|
||||
create: permission,
|
||||
});
|
||||
}
|
||||
console.log(` ${PERMISSION_DEFINITIONS.length} capacités`);
|
||||
|
||||
console.log('→ Compte');
|
||||
const account = await prisma.account.upsert({
|
||||
where: { id: 'demo-account' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'demo-account',
|
||||
name: 'Maison Rivage',
|
||||
siren: '000000000',
|
||||
apeCode: '4759B',
|
||||
collectiveAgreementId: '1517',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('→ Rôles');
|
||||
const roleIds = new Map<string, string>();
|
||||
for (const role of SYSTEM_ROLES) {
|
||||
const created = await prisma.role.upsert({
|
||||
where: { accountId_key: { accountId: account.id, key: role.key } },
|
||||
update: { name: role.name },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
isSystem: true,
|
||||
},
|
||||
});
|
||||
roleIds.set(role.key, created.id);
|
||||
|
||||
const codes = DEFAULT_ROLE_PERMISSIONS[role.key];
|
||||
const permissions = await prisma.permission.findMany({
|
||||
where: { code: { in: codes } },
|
||||
});
|
||||
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
roleId: created.id,
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(` ${role.name} — ${permissions.length} capacités`);
|
||||
}
|
||||
|
||||
console.log('→ Établissements');
|
||||
const locations = [
|
||||
{ id: 'loc-nantes', name: 'Nantes Atlantis' },
|
||||
{ id: 'loc-rennes', name: 'Rennes Alma' },
|
||||
];
|
||||
for (const location of locations) {
|
||||
await prisma.location.upsert({
|
||||
where: { id: location.id },
|
||||
update: { name: location.name },
|
||||
create: {
|
||||
id: location.id,
|
||||
accountId: account.id,
|
||||
name: location.name,
|
||||
employerContributionRate: 42,
|
||||
},
|
||||
});
|
||||
for (const [index, team] of ['Vente', 'Caisse', 'Réserve'].entries()) {
|
||||
await prisma.team.upsert({
|
||||
where: { id: `${location.id}-${index}` },
|
||||
update: {},
|
||||
create: {
|
||||
id: `${location.id}-${index}`,
|
||||
accountId: account.id,
|
||||
locationId: location.id,
|
||||
name: team,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('→ Comptes utilisateurs');
|
||||
const passwordHash = await hash(DEMO_PASSWORD, {
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
});
|
||||
|
||||
const people = [
|
||||
{ email: 'direction@example.test', firstName: 'Camille', lastName: 'Ferrand', role: 'owner', all: true },
|
||||
{ email: 'manager.nantes@example.test', firstName: 'Jonas', lastName: 'Meyer', role: 'manager', all: false },
|
||||
{ email: 'manager.rennes@example.test', firstName: 'Inès', lastName: 'Bakhti', role: 'manager', all: false },
|
||||
{ email: 'salarie@example.test', firstName: 'Rémi', lastName: 'Chartier', role: 'employee', all: false },
|
||||
];
|
||||
|
||||
for (const [index, person] of people.entries()) {
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: person.email },
|
||||
update: {},
|
||||
create: {
|
||||
email: person.email,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.membership.upsert({
|
||||
where: {
|
||||
accountId_employeeNumber: {
|
||||
accountId: account.id,
|
||||
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
|
||||
},
|
||||
},
|
||||
update: { userId: user.id, status: 'ACTIVE' },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
userId: user.id,
|
||||
roleId: roleIds.get(person.role) as string,
|
||||
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.membershipScope.deleteMany({
|
||||
where: { membershipId: membership.id },
|
||||
});
|
||||
await prisma.membershipScope.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
allLocations: person.all,
|
||||
locationId: person.all
|
||||
? null
|
||||
: person.email.includes('rennes')
|
||||
? 'loc-rennes'
|
||||
: 'loc-nantes',
|
||||
},
|
||||
});
|
||||
console.log(` ${person.email} — ${person.role}`);
|
||||
}
|
||||
|
||||
console.log('→ Durées de conservation');
|
||||
const retention = [
|
||||
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
|
||||
['ForfaitDayEntry', 36, 'creation', 'Décompte des jours de forfait : 3 ans minimum.'],
|
||||
['UserContract', 60, 'contract_end', 'Pièces contractuelles : 5 ans.'],
|
||||
['PersonnelRegister', 60, 'employee_departure', 'Registre du personnel : 5 ans après le départ.'],
|
||||
['PayrollVariable', 72, 'period_end', "Éléments d'assiette transmis à Silae : 6 ans."],
|
||||
] as const;
|
||||
|
||||
for (const [objectType, durationMonths, startPoint, justification] of retention) {
|
||||
await prisma.retentionPolicy.upsert({
|
||||
where: {
|
||||
accountId_objectType_effectiveFrom: {
|
||||
accountId: account.id,
|
||||
objectType,
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
},
|
||||
},
|
||||
update: { durationMonths, startPoint, justification },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
objectType,
|
||||
durationMonths,
|
||||
startPoint,
|
||||
justification,
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ${retention.length} politiques`);
|
||||
|
||||
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { checkTenantIsolation } from '@/server/db-guard';
|
||||
import { checkDatabase } from '@/server/health';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -14,8 +15,18 @@ export const dynamic = 'force-dynamic';
|
||||
export async function GET() {
|
||||
const database = await checkDatabase();
|
||||
|
||||
// L'isolation est signalée mais ne dégrade pas la sonde : une base joignable
|
||||
// avec un compte trop privilégié reste une application qui répond. Le refus
|
||||
// de démarrer, lui, est traité par assertTenantIsolation en production.
|
||||
const isolation = database.ok
|
||||
? await checkTenantIsolation().catch(() => null)
|
||||
: null;
|
||||
|
||||
return NextResponse.json(
|
||||
{ status: database.ok ? 'ok' : 'degraded' },
|
||||
{
|
||||
status: database.ok ? 'ok' : 'degraded',
|
||||
tenantIsolation: isolation ? (isolation.ok ? 'enforced' : 'weakened') : 'unknown',
|
||||
},
|
||||
{
|
||||
status: database.ok ? 200 : 503,
|
||||
headers: { 'cache-control': 'no-store' },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { PermissionCode } from '@/domain/access/permissions';
|
||||
|
||||
/**
|
||||
* Autorisation par capacités — PLAN.md §3.2.
|
||||
*
|
||||
* Point d'entrée unique. Deux règles qui ne se négocient pas :
|
||||
*
|
||||
* 1. La vérification a lieu **avant tout effet**, côté serveur. Masquer un
|
||||
* bouton est un confort, pas une sécurité : la mutation reste appelable.
|
||||
* 2. On teste une **capacité**, jamais un nom de rôle. Les rôles sont
|
||||
* configurables par le client ; un écran qui teste `role === 'manager'`
|
||||
* casse le jour où quelqu'un renomme le rôle ou en crée un cinquième.
|
||||
*/
|
||||
|
||||
export interface Scope {
|
||||
/** Accès à tous les établissements du compte. */
|
||||
allLocations: boolean;
|
||||
locationIds: string[];
|
||||
teamIds: string[];
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
membershipId: string;
|
||||
accountId: string;
|
||||
userId: string | null;
|
||||
roleKey: string;
|
||||
permissions: ReadonlySet<string>;
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
/** Ressource visée, quand la capacité seule ne suffit pas à décider. */
|
||||
export interface ResourceRef {
|
||||
accountId?: string;
|
||||
locationId?: string;
|
||||
teamId?: string;
|
||||
/** Membership concerné, pour distinguer « ses » données de celles d'autrui. */
|
||||
membershipId?: string;
|
||||
}
|
||||
|
||||
export class AuthorizationError extends Error {
|
||||
readonly permission: string;
|
||||
|
||||
constructor(permission: string, detail?: string) {
|
||||
super(
|
||||
detail
|
||||
? `Capacité « ${permission} » requise : ${detail}`
|
||||
: `Capacité « ${permission} » requise`,
|
||||
);
|
||||
this.name = 'AuthorizationError';
|
||||
this.permission = permission;
|
||||
}
|
||||
}
|
||||
|
||||
/** Le périmètre couvre-t-il la ressource visée ? */
|
||||
export function inScope(actor: Actor, resource?: ResourceRef): boolean {
|
||||
if (!resource) return true;
|
||||
|
||||
if (resource.accountId && resource.accountId !== actor.accountId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actor.scope.allLocations) return true;
|
||||
|
||||
if (resource.locationId) {
|
||||
return actor.scope.locationIds.includes(resource.locationId);
|
||||
}
|
||||
|
||||
if (resource.teamId) {
|
||||
return actor.scope.teamIds.includes(resource.teamId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function can(
|
||||
actor: Actor,
|
||||
permission: PermissionCode,
|
||||
resource?: ResourceRef,
|
||||
): boolean {
|
||||
if (!actor.permissions.has(permission)) return false;
|
||||
return inScope(actor, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante qui lève. À préférer dans les Server Actions : oublier de traiter le
|
||||
* `false` d'un `can()` laisse passer la mutation, alors qu'une exception
|
||||
* interrompt tout.
|
||||
*/
|
||||
export function authorize(
|
||||
actor: Actor,
|
||||
permission: PermissionCode,
|
||||
resource?: ResourceRef,
|
||||
): void {
|
||||
if (!actor.permissions.has(permission)) {
|
||||
throw new AuthorizationError(permission);
|
||||
}
|
||||
if (!inScope(actor, resource)) {
|
||||
throw new AuthorizationError(
|
||||
permission,
|
||||
'la ressource est hors du périmètre du compte ou de l’établissement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisit entre la capacité « sur soi » et « sur autrui ».
|
||||
*
|
||||
* Un salarié voit ses propres compteurs sans détenir le droit de voir ceux des
|
||||
* autres — l'audit relève explicitement ces deux droits séparés.
|
||||
*/
|
||||
export function canForMember(
|
||||
actor: Actor,
|
||||
ownPermission: PermissionCode,
|
||||
othersPermission: PermissionCode,
|
||||
targetMembershipId: string,
|
||||
): boolean {
|
||||
if (targetMembershipId === actor.membershipId) {
|
||||
return actor.permissions.has(ownPermission);
|
||||
}
|
||||
return actor.permissions.has(othersPermission);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Catalogue des capacités — PLAN.md §5.
|
||||
*
|
||||
* Les codes sont **stables** : le produit les référence, les écrans les
|
||||
* testent, et un client peut renommer ses rôles sans rien casser. Ajouter une
|
||||
* capacité est une migration de données ; en renommer une est une rupture.
|
||||
*
|
||||
* Forme : `ressource.action.qualificatif`.
|
||||
*/
|
||||
|
||||
export interface PermissionDefinition {
|
||||
code: string;
|
||||
category: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PERMISSIONS = [
|
||||
// --- Planning ------------------------------------------------------------
|
||||
['planning.view', 'Planning', 'Voir les plannings publiés'],
|
||||
['planning.view_unpublished', 'Planning', 'Voir les semaines non publiées'],
|
||||
['planning.create', 'Planning', 'Créer un créneau'],
|
||||
['planning.create_on_published', 'Planning', 'Créer sur une semaine publiée'],
|
||||
['planning.edit', 'Planning', 'Modifier un créneau'],
|
||||
['planning.edit_published', 'Planning', 'Modifier une semaine publiée'],
|
||||
['planning.delete', 'Planning', 'Supprimer un créneau'],
|
||||
['planning.duplicate', 'Planning', 'Dupliquer une semaine'],
|
||||
['planning.publish', 'Planning', 'Publier une semaine'],
|
||||
['planning.unpublish', 'Planning', 'Dépublier une semaine'],
|
||||
['planning.validate', 'Planning', 'Valider une semaine'],
|
||||
['planning.invalidate', 'Planning', 'Invalider une semaine'],
|
||||
['planning.bulk_actions', 'Planning', 'Actions de masse'],
|
||||
['planning.unassigned.view', 'Planning', 'Voir les besoins non couverts'],
|
||||
['planning.alerts.view', 'Planning', 'Voir les alertes de convention'],
|
||||
['planning.alerts.acknowledge', 'Planning', 'Acquitter une alerte'],
|
||||
['planning.counters.view', 'Planning', 'Voir les compteurs et le coût'],
|
||||
['planning.labels.manage', 'Planning', 'Gérer les étiquettes'],
|
||||
['planning.notes.manage', 'Planning', 'Gérer les notes de journée'],
|
||||
['planning.print', 'Planning', 'Imprimer un planning'],
|
||||
|
||||
// --- Personnel -----------------------------------------------------------
|
||||
['members.view', 'Personnel', 'Voir l’annuaire'],
|
||||
['members.create', 'Personnel', 'Créer un salarié'],
|
||||
['members.edit', 'Personnel', 'Modifier un dossier'],
|
||||
['members.archive', 'Personnel', 'Archiver un salarié'],
|
||||
['members.delete', 'Personnel', 'Supprimer un salarié'],
|
||||
['members.salary.view', 'Personnel', 'Voir les rémunérations'],
|
||||
['members.contract.create', 'Personnel', 'Créer un contrat'],
|
||||
['members.contract.edit', 'Personnel', 'Modifier un contrat'],
|
||||
['members.contract.delete_past', 'Personnel', 'Supprimer un contrat passé'],
|
||||
['members.documents.view', 'Personnel', 'Consulter les documents'],
|
||||
['members.documents.manage', 'Personnel', 'Gérer les documents'],
|
||||
['members.register.export', 'Personnel', 'Exporter le registre du personnel'],
|
||||
['members.dpae.check', 'Personnel', 'Contrôler l’éligibilité DPAE'],
|
||||
|
||||
// --- Heures --------------------------------------------------------------
|
||||
['hours.view', 'Heures', 'Voir les heures'],
|
||||
['hours.edit_actual', 'Heures', 'Saisir les heures réelles'],
|
||||
['hours.validate', 'Heures', 'Valider les heures'],
|
||||
|
||||
// --- Absences et compteurs ----------------------------------------------
|
||||
['timeoff.view_own', 'Absences', 'Voir ses absences'],
|
||||
['timeoff.view_others', 'Absences', 'Voir les absences des autres'],
|
||||
['timeoff.request', 'Absences', 'Demander une absence'],
|
||||
['timeoff.decide', 'Absences', 'Accepter ou refuser une demande'],
|
||||
['timeoff.delete', 'Absences', 'Supprimer une absence'],
|
||||
['timeoff.bypass_notice', 'Absences', 'Contourner le délai de prévenance'],
|
||||
['timeoff.forecast.view', 'Absences', 'Voir la prévision de solde'],
|
||||
['counters.view_own', 'Absences', 'Voir ses compteurs'],
|
||||
['counters.view_others', 'Absences', 'Voir les compteurs des autres'],
|
||||
['counters.adjust', 'Absences', 'Ajuster un compteur'],
|
||||
|
||||
// --- Paie ----------------------------------------------------------------
|
||||
['payroll.access', 'Paie', 'Accéder à la paie'],
|
||||
['payroll.period.create', 'Paie', 'Créer une période'],
|
||||
['payroll.period.update', 'Paie', 'Modifier une période'],
|
||||
['payroll.period.delete', 'Paie', 'Supprimer une période'],
|
||||
['payroll.period.alternative', 'Paie', 'Créer une période alternative'],
|
||||
['payroll.period.lock', 'Paie', 'Verrouiller une période'],
|
||||
['payroll.period.unlock', 'Paie', 'Déverrouiller une période'],
|
||||
['payroll.export', 'Paie', 'Exporter les variables'],
|
||||
['payroll.export.silae', 'Paie', 'Exporter au format Silae'],
|
||||
['payroll.export.raw', 'Paie', 'Exporter au format brut'],
|
||||
|
||||
// --- Administration ------------------------------------------------------
|
||||
['settings.access', 'Administration', 'Accéder aux réglages'],
|
||||
['settings.locations.manage', 'Administration', 'Gérer les établissements'],
|
||||
['settings.teams.manage', 'Administration', 'Gérer les équipes'],
|
||||
['settings.agreement.manage', 'Administration', 'Gérer la convention'],
|
||||
['settings.jobtitles.manage', 'Administration', 'Gérer les emplois'],
|
||||
['settings.templates.manage', 'Administration', 'Gérer les modèles'],
|
||||
['settings.integrations.manage', 'Administration', 'Gérer les intégrations'],
|
||||
['settings.notifications.manage', 'Administration', 'Gérer les notifications'],
|
||||
['settings.roles.manage', 'Administration', 'Gérer les rôles'],
|
||||
['role_config.assign_owner_level', 'Administration', 'Déléguer le niveau propriétaire'],
|
||||
['audit.view', 'Administration', 'Consulter le journal d’audit'],
|
||||
|
||||
// --- Communication -------------------------------------------------------
|
||||
['articles.view', 'Communication', 'Lire les articles'],
|
||||
['articles.manage', 'Communication', 'Publier des articles'],
|
||||
['conversations.access', 'Communication', 'Accéder à la messagerie'],
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>;
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[number][0];
|
||||
|
||||
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = PERMISSIONS.map(
|
||||
([code, category, label]) => ({ code, category, label }),
|
||||
);
|
||||
|
||||
export const PERMISSION_CODES: string[] = PERMISSION_DEFINITIONS.map(
|
||||
(permission) => permission.code,
|
||||
);
|
||||
|
||||
/**
|
||||
* Rôles fournis — liste exhaustive relevée à l'audit des menus (PLAN.md §5).
|
||||
*
|
||||
* Point de départ modifiable par le client : un rôle est une donnée, pas une
|
||||
* constante du code. Aucun écran ne doit tester ces clés.
|
||||
*/
|
||||
export const SYSTEM_ROLES = [
|
||||
{ key: 'owner', name: 'Propriétaire' },
|
||||
{ key: 'admin', name: 'Admin' },
|
||||
{ key: 'director', name: 'Directeur' },
|
||||
{ key: 'manager', name: 'Manager' },
|
||||
{ key: 'employee', name: 'Employé' },
|
||||
] as const;
|
||||
|
||||
export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]['key'];
|
||||
|
||||
const EMPLOYEE_PERMISSIONS: string[] = [
|
||||
'planning.view',
|
||||
'timeoff.view_own',
|
||||
'timeoff.request',
|
||||
'counters.view_own',
|
||||
'articles.view',
|
||||
'conversations.access',
|
||||
];
|
||||
|
||||
const MANAGER_PERMISSIONS: string[] = [
|
||||
...EMPLOYEE_PERMISSIONS,
|
||||
'planning.view_unpublished',
|
||||
'planning.create',
|
||||
'planning.edit',
|
||||
'planning.delete',
|
||||
'planning.duplicate',
|
||||
'planning.publish',
|
||||
'planning.unpublish',
|
||||
'planning.validate',
|
||||
'planning.invalidate',
|
||||
'planning.bulk_actions',
|
||||
'planning.unassigned.view',
|
||||
'planning.alerts.view',
|
||||
'planning.alerts.acknowledge',
|
||||
'planning.counters.view',
|
||||
'planning.notes.manage',
|
||||
'planning.print',
|
||||
'members.view',
|
||||
'hours.view',
|
||||
'hours.edit_actual',
|
||||
'hours.validate',
|
||||
'timeoff.view_others',
|
||||
'timeoff.decide',
|
||||
'timeoff.forecast.view',
|
||||
'counters.view_others',
|
||||
];
|
||||
|
||||
const DIRECTOR_PERMISSIONS: string[] = [
|
||||
...MANAGER_PERMISSIONS,
|
||||
'planning.create_on_published',
|
||||
'planning.edit_published',
|
||||
'planning.labels.manage',
|
||||
'members.create',
|
||||
'members.edit',
|
||||
'members.archive',
|
||||
'members.salary.view',
|
||||
'members.contract.create',
|
||||
'members.contract.edit',
|
||||
'members.documents.view',
|
||||
'members.documents.manage',
|
||||
'members.register.export',
|
||||
'members.dpae.check',
|
||||
'timeoff.delete',
|
||||
'timeoff.bypass_notice',
|
||||
'counters.adjust',
|
||||
'payroll.access',
|
||||
'payroll.period.create',
|
||||
'payroll.period.update',
|
||||
'payroll.period.lock',
|
||||
'payroll.export',
|
||||
'payroll.export.silae',
|
||||
'settings.access',
|
||||
'articles.manage',
|
||||
];
|
||||
|
||||
const ADMIN_PERMISSIONS: string[] = PERMISSION_CODES.filter(
|
||||
(code) => code !== 'role_config.assign_owner_level',
|
||||
);
|
||||
|
||||
/** Attribution par défaut. Le client la modifie ensuite depuis les réglages. */
|
||||
export const DEFAULT_ROLE_PERMISSIONS: Record<SystemRoleKey, string[]> = {
|
||||
employee: EMPLOYEE_PERMISSIONS,
|
||||
manager: MANAGER_PERMISSIONS,
|
||||
director: DIRECTOR_PERMISSIONS,
|
||||
admin: ADMIN_PERMISSIONS,
|
||||
// Seul le propriétaire peut déléguer le niveau propriétaire.
|
||||
owner: [...PERMISSION_CODES],
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Journal d'audit — PLAN.md §3.5 et matrice n° 22.
|
||||
*
|
||||
* Append-only, imposé par un trigger PostgreSQL. La preuve exige de pouvoir
|
||||
* reconstruire une décision telle qu'elle a été prise ; une piste que l'on peut
|
||||
* réécrire ne prouve rien.
|
||||
*/
|
||||
|
||||
export interface AuditEntry {
|
||||
actorMembershipId?: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
/** Justification, obligatoire pour les opérations qui l'exigent. */
|
||||
reason?: string | null;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
/** Actions dont la matrice exige une justification explicite. */
|
||||
const REASON_REQUIRED = new Set([
|
||||
'counter.adjust',
|
||||
'payroll.period.unlock',
|
||||
'payroll.period.delete',
|
||||
'planning.alert.acknowledge',
|
||||
'membership.delete',
|
||||
]);
|
||||
|
||||
export async function recordAudit(
|
||||
db: ScopedClient,
|
||||
entry: AuditEntry,
|
||||
): Promise<void> {
|
||||
if (REASON_REQUIRED.has(entry.action) && !entry.reason?.trim()) {
|
||||
// Refuser ici plutôt qu'accepter une entrée creuse : une justification
|
||||
// vide sur un ajustement de compteur ne vaut pas mieux que pas d'entrée,
|
||||
// et laisse croire que la traçabilité est en place.
|
||||
throw new Error(
|
||||
`L'action « ${entry.action} » exige une justification (matrice n° 21 et 22).`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actorMembershipId: entry.actorMembershipId ?? null,
|
||||
action: entry.action,
|
||||
entityType: entry.entityType,
|
||||
entityId: entry.entityId,
|
||||
before: (entry.before ?? null) as never,
|
||||
after: (entry.after ?? null) as never,
|
||||
reason: entry.reason ?? null,
|
||||
ip: entry.ip ?? null,
|
||||
userAgent: entry.userAgent ?? null,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire des valeurs à ne jamais écrire au journal.
|
||||
*
|
||||
* Le journal se relit, s'exporte et se conserve longtemps : y déposer un IBAN
|
||||
* ou un mot de passe reviendrait à créer une seconde copie non chiffrée de ce
|
||||
* que l'on a pris soin de chiffrer ailleurs.
|
||||
*/
|
||||
const REDACTED = new Set([
|
||||
'passwordHash',
|
||||
'socialSecurityNumberEnc',
|
||||
'ibanEnc',
|
||||
'bicEnc',
|
||||
'mfaSecretEnc',
|
||||
'tokenHash',
|
||||
]);
|
||||
|
||||
export function redact<T extends Record<string, unknown>>(
|
||||
value: T,
|
||||
): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
output[key] = REDACTED.has(key) ? '[masqué]' : entry;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import type { Actor, Scope } from '@/domain/access/authorize';
|
||||
import { generateToken, hashToken } from '@/server/crypto';
|
||||
import { unscoped } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Sessions serveur — PLAN.md §2 (écart assumé) et matrice n° 23.
|
||||
*
|
||||
* En base plutôt qu'en jeton signé : la matrice impose de pouvoir **révoquer**
|
||||
* une session lors d'un incident. Un JWT reste valable jusqu'à expiration quoi
|
||||
* qu'on fasse, sauf à tenir une liste de révocation — c'est-à-dire à
|
||||
* reconstruire une table de sessions, en moins fiable.
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE = 'planflow_session';
|
||||
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000; // 12 h : une journée de travail.
|
||||
const MAX_FAILED_ATTEMPTS = 8;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Paramètres argon2id. Les valeurs par défaut de la bibliothèque sont
|
||||
* délibérément basses ; celles-ci suivent les recommandations OWASP.
|
||||
*/
|
||||
const ARGON2_OPTIONS = {
|
||||
memoryCost: 19_456, // 19 Mio
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
passwordHash: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwordHash, password);
|
||||
} catch {
|
||||
// Une empreinte illisible est un échec d'authentification, pas une panne :
|
||||
// la remonter en erreur 500 renseignerait sur l'état du compte.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignInInput {
|
||||
email: string;
|
||||
password: string;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
export type SignInResult =
|
||||
| { ok: true; token: string; expiresAt: Date; userId: string }
|
||||
| { ok: false; reason: 'invalid' | 'locked' };
|
||||
|
||||
export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
const db = unscoped();
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: input.email.toLowerCase().trim() },
|
||||
});
|
||||
|
||||
if (user?.lockedUntil && user.lockedUntil > new Date()) {
|
||||
return { ok: false, reason: 'locked' };
|
||||
}
|
||||
|
||||
// Vérifier une empreinte factice quand l'utilisateur n'existe pas : sans
|
||||
// cela, un compte inconnu répond nettement plus vite qu'un mot de passe
|
||||
// faux, ce qui laisse énumérer les adresses.
|
||||
const passwordHash =
|
||||
user?.passwordHash ??
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$0000000000000000000000000000000000000000000';
|
||||
|
||||
const valid = await verifyPassword(passwordHash, input.password);
|
||||
|
||||
if (!user || !user.passwordHash || !valid) {
|
||||
if (user) {
|
||||
const failed = user.failedAttempts + 1;
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: failed,
|
||||
lockedUntil:
|
||||
failed >= MAX_FAILED_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_DURATION_MS)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
|
||||
await db.$transaction([
|
||||
db.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: hashToken(token),
|
||||
expiresAt,
|
||||
ip: input.ip ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
},
|
||||
}),
|
||||
db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastSignInAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { ok: true, token, expiresAt, userId: user.id };
|
||||
}
|
||||
|
||||
export async function signOut(token: string): Promise<void> {
|
||||
await unscoped().session.updateMany({
|
||||
where: { tokenHash: hashToken(token), revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Révoque toutes les sessions d'un utilisateur — réponse à incident. */
|
||||
export async function revokeAllSessions(
|
||||
userId: string,
|
||||
revokedBy: string,
|
||||
): Promise<number> {
|
||||
const result = await unscoped().session.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date(), revokedBy },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
actor: Actor;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout la session courante en acteur autorisable.
|
||||
*
|
||||
* Le périmètre est reconstruit ici, depuis la base, à chaque requête. Le
|
||||
* transporter dans le cookie permettrait à un client de se l'élargir.
|
||||
*/
|
||||
export async function resolveSession(
|
||||
token: string,
|
||||
): Promise<SessionContext | null> {
|
||||
const db = unscoped();
|
||||
|
||||
const session = await db.session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
memberships: {
|
||||
where: { status: 'ACTIVE', archivedAt: null },
|
||||
include: {
|
||||
role: { include: { permissions: { include: { permission: true } } } },
|
||||
scopes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const membership = session.user.memberships[0];
|
||||
if (!membership) return null;
|
||||
|
||||
const scope: Scope = {
|
||||
allLocations: membership.scopes.some((entry) => entry.allLocations),
|
||||
locationIds: membership.scopes
|
||||
.map((entry) => entry.locationId)
|
||||
.filter((id): id is string => id !== null),
|
||||
teamIds: membership.scopes
|
||||
.map((entry) => entry.teamId)
|
||||
.filter((id): id is string => id !== null),
|
||||
};
|
||||
|
||||
const actor: Actor = {
|
||||
membershipId: membership.id,
|
||||
accountId: membership.accountId,
|
||||
userId: session.user.id,
|
||||
roleKey: membership.role.key,
|
||||
permissions: new Set(
|
||||
membership.role.permissions.map((entry) => entry.permission.code),
|
||||
),
|
||||
scope,
|
||||
};
|
||||
|
||||
return { actor, sessionId: session.id };
|
||||
}
|
||||
|
||||
/** Session courante depuis le cookie, ou `null`. */
|
||||
export async function currentSession(): Promise<SessionContext | null> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
return resolveSession(token);
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
} as const;
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
createHash,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* Chiffrement applicatif des colonnes sensibles — PLAN.md §3.6.
|
||||
*
|
||||
* NIR, IBAN, BIC et secrets MFA. AES-256-GCM : le mode authentifié détecte
|
||||
* qu'un chiffré a été modifié, ce qu'un CBC laisserait passer en produisant du
|
||||
* clair corrompu.
|
||||
*
|
||||
* La clé vit hors de la base. Une sauvegarde volée ne doit pas suffire à lire
|
||||
* ces colonnes — sans quoi le chiffrement ne protège que du vol de disque nu.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function key(): Buffer {
|
||||
return Buffer.from(env.ENCRYPTION_KEY, 'base64');
|
||||
}
|
||||
|
||||
/** Format : [iv (12) | tag (16) | chiffré]. */
|
||||
export function encrypt(plaintext: string): Buffer {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key(), iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
export function decrypt(payload: Buffer): string {
|
||||
if (payload.length < IV_LENGTH + TAG_LENGTH) {
|
||||
throw new Error('Chiffré invalide : trop court pour contenir iv et tag');
|
||||
}
|
||||
|
||||
const iv = payload.subarray(0, IV_LENGTH);
|
||||
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = payload.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
export function encryptOptional(value: string | null | undefined): Buffer | null {
|
||||
return value ? encrypt(value) : null;
|
||||
}
|
||||
|
||||
export function decryptOptional(payload: Buffer | null | undefined): string | null {
|
||||
return payload ? decrypt(payload) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empreinte d'un jeton de session ou d'invitation.
|
||||
*
|
||||
* La base ne stocke que l'empreinte : une fuite de la table ne donne pas de
|
||||
* sessions utilisables. SHA-256 suffit ici — le jeton est déjà 256 bits
|
||||
* d'aléa, il n'y a pas de dictionnaire à ralentir, contrairement à un mot de
|
||||
* passe.
|
||||
*/
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function generateToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/** Comparaison à temps constant, pour ne pas fuiter par la durée. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
if (bufferA.length !== bufferB.length) return false;
|
||||
return timingSafeEqual(bufferA, bufferB);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { prisma } from '@/server/db';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* Vérifie que la base est configurée pour que la RLS s'applique réellement.
|
||||
*
|
||||
* **Un superutilisateur PostgreSQL contourne toute politique de sécurité au
|
||||
* niveau ligne, y compris avec FORCE.** Connecter l'application avec un tel
|
||||
* compte désactive silencieusement la seconde couche d'isolation : les
|
||||
* requêtes continuent de fonctionner, les tests applicatifs passent, et rien
|
||||
* n'indique que la protection a disparu.
|
||||
*
|
||||
* C'est exactement le genre de mauvaise configuration qu'on ne découvre qu'en
|
||||
* lisant les données d'un autre client. D'où ce contrôle au démarrage.
|
||||
*/
|
||||
|
||||
export interface TenantGuardResult {
|
||||
ok: boolean;
|
||||
isSuperuser: boolean;
|
||||
bypassRls: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function checkTenantIsolation(): Promise<TenantGuardResult> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ usesuper: boolean; usebypassrls: boolean }>
|
||||
>`SELECT usesuper, usebypassrls FROM pg_user WHERE usename = current_user`;
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return {
|
||||
ok: false,
|
||||
isSuperuser: false,
|
||||
bypassRls: false,
|
||||
message: 'Impossible de déterminer les privilèges du compte de connexion.',
|
||||
};
|
||||
}
|
||||
|
||||
const ok = !row.usesuper && !row.usebypassrls;
|
||||
|
||||
return {
|
||||
ok,
|
||||
isSuperuser: row.usesuper,
|
||||
bypassRls: row.usebypassrls,
|
||||
...(ok
|
||||
? {}
|
||||
: {
|
||||
message:
|
||||
'Le compte de connexion contourne la row-level security ' +
|
||||
`(superutilisateur : ${row.usesuper}, bypassrls : ${row.usebypassrls}). ` +
|
||||
"L'isolation multi-tenant ne repose plus que sur la couche applicative. " +
|
||||
'Créer un rôle dédié : CREATE ROLE planflow_app LOGIN NOSUPERUSER NOBYPASSRLS.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse de démarrer en production sur une base mal configurée.
|
||||
*
|
||||
* En développement, un avertissement suffit : la base locale est souvent créée
|
||||
* avec un compte administrateur, et bloquer rendrait la mise en route pénible
|
||||
* sans rien protéger.
|
||||
*/
|
||||
export async function assertTenantIsolation(): Promise<void> {
|
||||
const result = await checkTenantIsolation();
|
||||
if (result.ok) return;
|
||||
|
||||
const message = `Isolation multi-tenant affaiblie — ${result.message}`;
|
||||
|
||||
if (env.NODE_ENV === 'production') {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
console.warn(`⚠ ${message}`);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@/server/db';
|
||||
|
||||
/**
|
||||
* Isolation multi-tenant — PLAN.md §3.1.
|
||||
*
|
||||
* Deux couches indépendantes :
|
||||
*
|
||||
* 1. `SET LOCAL app.account_id` active les politiques RLS de PostgreSQL, qui
|
||||
* filtrent en base quoi qu'il arrive côté application.
|
||||
* 2. L'extension Prisma injecte `accountId` dans chaque `where` et chaque
|
||||
* `create`, pour que le filtre soit aussi visible dans le plan de requête
|
||||
* et que l'erreur, quand il y en a une, soit lisible.
|
||||
*
|
||||
* La première seule laisserait passer des requêtes qui ramènent zéro ligne sans
|
||||
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
|
||||
*/
|
||||
|
||||
/** Tables portant une colonne `accountId`. */
|
||||
const SCOPED_MODELS = new Set([
|
||||
'Location',
|
||||
'Team',
|
||||
'Membership',
|
||||
'MembershipScope',
|
||||
'Invitation',
|
||||
'Role',
|
||||
'AuditLog',
|
||||
'RetentionPolicy',
|
||||
'FeatureFlag',
|
||||
]);
|
||||
|
||||
const READ_OPERATIONS = new Set([
|
||||
'findFirst',
|
||||
'findFirstOrThrow',
|
||||
'findMany',
|
||||
'findUnique',
|
||||
'findUniqueOrThrow',
|
||||
'count',
|
||||
'aggregate',
|
||||
'groupBy',
|
||||
'updateMany',
|
||||
'deleteMany',
|
||||
]);
|
||||
|
||||
const CREATE_OPERATIONS = new Set(['create', 'createMany', 'upsert']);
|
||||
|
||||
export type ScopedClient = Omit<
|
||||
PrismaClient,
|
||||
'$connect' | '$disconnect' | '$transaction' | '$extends'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Un client étendu par compte. Mémorisé : `$extends` construit un proxy à
|
||||
* chaque appel, et on en ferait un par requête HTTP sinon.
|
||||
*/
|
||||
const scopedClients = new Map<string, ReturnType<typeof buildScopedClient>>();
|
||||
|
||||
function scopedClientFor(accountId: string) {
|
||||
const existing = scopedClients.get(accountId);
|
||||
if (existing) return existing;
|
||||
const client = buildScopedClient(accountId);
|
||||
scopedClients.set(accountId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
function buildScopedClient(accountId: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ model, operation, args, query }) {
|
||||
if (!model || !SCOPED_MODELS.has(model)) {
|
||||
return query(args);
|
||||
}
|
||||
|
||||
const input = args as Record<string, unknown>;
|
||||
|
||||
if (READ_OPERATIONS.has(operation)) {
|
||||
input.where = { ...(input.where as object), accountId };
|
||||
}
|
||||
|
||||
if (CREATE_OPERATIONS.has(operation)) {
|
||||
if (operation === 'createMany') {
|
||||
const data = input.data;
|
||||
input.data = Array.isArray(data)
|
||||
? data.map((row) => ({ ...(row as object), accountId }))
|
||||
: { ...(data as object), accountId };
|
||||
} else if (operation === 'upsert') {
|
||||
input.where = { ...(input.where as object), accountId };
|
||||
input.create = { ...(input.create as object), accountId };
|
||||
} else {
|
||||
input.data = { ...(input.data as object), accountId };
|
||||
}
|
||||
}
|
||||
|
||||
return query(input);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute `fn` dans une transaction portant le compte courant.
|
||||
*
|
||||
* Tout accès aux données d'un compte passe par ici. Le périmètre vient de la
|
||||
* session serveur, jamais d'un paramètre client — c'est la règle qui rend
|
||||
* l'isolation vérifiable plutôt que confiante.
|
||||
*/
|
||||
export async function withTenant<T>(
|
||||
accountId: string,
|
||||
fn: (db: ScopedClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// La transaction est ouverte **depuis le client étendu**, pour que le `tx`
|
||||
// qu'elle fournit porte l'extension et partage la connexion sur laquelle
|
||||
// `set_config` est posé. L'inverse — étendre le `tx` — n'est pas possible :
|
||||
// Prisma retire `$extends` du client de transaction.
|
||||
return scopedClientFor(accountId).$transaction(async (tx) => {
|
||||
// `set_config(..., true)` est local à la transaction, donc remis à zéro
|
||||
// automatiquement. Une connexion rendue au pool ne garde pas le compte
|
||||
// précédent — ce serait la pire fuite possible.
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
|
||||
|
||||
return fn(tx as unknown as ScopedClient) as Promise<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accès sans portée de compte, pour les opérations qui précèdent l'identité :
|
||||
* authentification par e-mail, acceptation d'invitation, migrations.
|
||||
*
|
||||
* Volontairement nommé pour se voir en revue de code.
|
||||
*/
|
||||
export function unscoped(): PrismaClient {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
export type { Prisma };
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Client } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Isolation multi-tenant au niveau base — PLAN.md §3.1.
|
||||
*
|
||||
* Ces tests parlent directement à PostgreSQL, sans passer par Prisma : ils
|
||||
* vérifient que l'isolation tient **même si la couche applicative est
|
||||
* contournée**. Un test qui passerait par l'extension Prisma ne prouverait que
|
||||
* l'extension.
|
||||
*
|
||||
* Point d'attention : un **superutilisateur contourne la RLS**, y compris avec
|
||||
* FORCE. Ces tests créent donc un rôle restreint, ce qui est aussi la
|
||||
* configuration attendue en production (voir README).
|
||||
*/
|
||||
|
||||
const APP_ROLE = 'planflow_rls_test';
|
||||
const APP_PASSWORD = 'rls-test-only';
|
||||
|
||||
const adminUrl = process.env.DATABASE_URL ?? '';
|
||||
const enabled = adminUrl.length > 0;
|
||||
|
||||
let admin: Client;
|
||||
let app: Client;
|
||||
let accountA: string;
|
||||
let accountB: string;
|
||||
|
||||
const describeIfDb = enabled ? describe : describe.skip;
|
||||
|
||||
describeIfDb('row-level security', () => {
|
||||
beforeAll(async () => {
|
||||
admin = new Client({ connectionString: adminUrl });
|
||||
await admin.connect();
|
||||
|
||||
// Rôle applicatif sans privilège particulier : c'est la seule façon de
|
||||
// voir les politiques s'appliquer.
|
||||
await admin.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
|
||||
await admin.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
|
||||
await admin.query(
|
||||
`CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS`,
|
||||
);
|
||||
await admin.query(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${APP_ROLE}`,
|
||||
);
|
||||
await admin.query(
|
||||
`GRANT USAGE ON SCHEMA public TO ${APP_ROLE}`,
|
||||
);
|
||||
|
||||
accountA = `rls-a-${Date.now()}`;
|
||||
accountB = `rls-b-${Date.now()}`;
|
||||
for (const id of [accountA, accountB]) {
|
||||
await admin.query(
|
||||
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
|
||||
[id, `Compte ${id}`],
|
||||
);
|
||||
await admin.query(
|
||||
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
|
||||
[`${id}-loc`, id, `Établissement ${id}`, 'Europe/Paris', 0],
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(adminUrl);
|
||||
url.username = APP_ROLE;
|
||||
url.password = APP_PASSWORD;
|
||||
app = new Client({ connectionString: url.toString() });
|
||||
await app.connect();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.end().catch(() => undefined);
|
||||
for (const id of [accountA, accountB]) {
|
||||
await admin
|
||||
?.query('DELETE FROM "Account" WHERE id = $1', [id])
|
||||
.catch(() => undefined);
|
||||
}
|
||||
await admin?.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
|
||||
await admin?.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
|
||||
await admin?.end().catch(() => undefined);
|
||||
}, 30_000);
|
||||
|
||||
it('le rôle applicatif n’est pas superutilisateur', async () => {
|
||||
// Sinon tous les tests suivants passeraient sans rien prouver.
|
||||
const { rows } = await app.query(
|
||||
'SELECT usesuper FROM pg_user WHERE usename = current_user',
|
||||
);
|
||||
expect(rows[0]?.usesuper).toBe(false);
|
||||
});
|
||||
|
||||
it('ne renvoie rien tant que le compte courant n’est pas posé', async () => {
|
||||
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
|
||||
expect(rows[0]?.n).toBe(0);
|
||||
});
|
||||
|
||||
it('ne renvoie que les lignes du compte courant', async () => {
|
||||
await app.query('BEGIN');
|
||||
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
|
||||
const { rows } = await app.query(
|
||||
'SELECT "accountId" FROM "Location" ORDER BY id',
|
||||
);
|
||||
await app.query('COMMIT');
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.accountId).toBe(accountA);
|
||||
});
|
||||
|
||||
it('refuse d’écrire pour un autre compte', async () => {
|
||||
await app.query('BEGIN');
|
||||
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
|
||||
|
||||
await expect(
|
||||
app.query(
|
||||
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
|
||||
[`intrus-${Date.now()}`, accountB, 'Intrusion', 'Europe/Paris', 0],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i);
|
||||
|
||||
await app.query('ROLLBACK');
|
||||
});
|
||||
|
||||
it('remet le compte à zéro à la fin de la transaction', async () => {
|
||||
// `set_config(..., true)` est local. Sans cela, une connexion rendue au
|
||||
// pool garderait le compte du client précédent — la pire fuite possible.
|
||||
await app.query('BEGIN');
|
||||
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
|
||||
await app.query('COMMIT');
|
||||
|
||||
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
|
||||
expect(rows[0]?.n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfDb('immutabilité du journal d’audit', () => {
|
||||
let client: Client;
|
||||
let accountId: string;
|
||||
let entryId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new Client({ connectionString: adminUrl });
|
||||
await client.connect();
|
||||
accountId = `audit-${Date.now()}`;
|
||||
entryId = `entry-${Date.now()}`;
|
||||
await client.query(
|
||||
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
|
||||
[accountId, 'Compte audit'],
|
||||
);
|
||||
await client.query(
|
||||
'INSERT INTO "AuditLog" (id, "accountId", action, "entityType", "entityId", "occurredAt") VALUES ($1, $2, $3, $4, $5, now())',
|
||||
[entryId, accountId, 'test.action', 'Test', 'x'],
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client
|
||||
?.query('DELETE FROM "Account" WHERE id = $1', [accountId])
|
||||
.catch(() => undefined);
|
||||
await client?.end().catch(() => undefined);
|
||||
}, 30_000);
|
||||
|
||||
it('refuse la modification d’une entrée', async () => {
|
||||
await expect(
|
||||
client.query('UPDATE "AuditLog" SET action = $1 WHERE id = $2', [
|
||||
'falsifie',
|
||||
entryId,
|
||||
]),
|
||||
).rejects.toThrow(/append-only/i);
|
||||
});
|
||||
|
||||
it('refuse la suppression d’une entrée', async () => {
|
||||
// Même un superutilisateur est arrêté : le trigger ne dépend pas des
|
||||
// privilèges, contrairement à la RLS.
|
||||
await expect(
|
||||
client.query('DELETE FROM "AuditLog" WHERE id = $1', [entryId]),
|
||||
).rejects.toThrow(/append-only/i);
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -1,5 +1,7 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -10,7 +12,10 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
// Les tests d'intégration parlent à une vraie base : ils se sautent
|
||||
// d'eux-mêmes quand DATABASE_URL est absente.
|
||||
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
|
||||
testTimeout: 30_000,
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user