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
@@ -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],
|
||||
};
|
||||
Reference in new issue
Block a user