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:
Claude committed 2026-08-07 22:39:33 +00:00
1 parent 93852c2602
commit 11763142d5
16 files changed
+2037 -9

No files matched your search

+121
View File
@@ -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);
}
+206
View File
@@ -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],
};