WP-02: locations, teams and the legal configuration register

Adds the referential models, the first database-backed settings
screens, and the register the compliance matrix requires before any
parameter is enforceable.

The register is the point of the lot. The matrix is explicit that
copying another product's configuration is not enough — each parameter
must carry its value, source, effective date, population and an
approver. Approval records the session's actor, never a form field: a
signature you can type yourself is worth nothing. The screen names the
domains that have no approved parameter yet, so the gap is visible
rather than assumed closed.

Two bugs of the same family, both now structurally impossible:

- The Prisma scoping extension read a hand-written list of models
  carrying accountId. The four models added here were missing from it,
  so writes failed with an opaque Prisma error — and a read would have
  silently returned every account's rows. The list is now derived from
  the schema itself.
- The RLS policies were likewise per-table. A new integration test
  fails if any table with an accountId column lacks forced RLS and both
  policies, which is the failure mode that hides best: nobody writes a
  wrong rule, someone forgets to write one.

An end-to-end test signs in as a manager and confirms the settings
screens refuse to render — the sidebar hiding them is a convenience,
the server check is the control.

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 23:04:26 +00:00
1 parent 7ab036379a
commit 92667b16b9
20 files changed
+1475 -17

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
authorize,
AuthorizationError,
can,
canForMember,
inScope,
type Actor,
} from '@/domain/access/authorize';
import {
DEFAULT_ROLE_PERMISSIONS,
PERMISSION_CODES,
SYSTEM_ROLES,
} from '@/domain/access/permissions';
function actor(overrides: Partial<Actor> = {}): Actor {
return {
membershipId: 'm1',
accountId: 'acc1',
userId: 'u1',
roleKey: 'manager',
permissions: new Set(['planning.view']),
scope: { allLocations: false, locationIds: ['loc1'], teamIds: [] },
...overrides,
};
}
describe('can', () => {
it('refuse une capacité absente', () => {
expect(can(actor(), 'planning.publish')).toBe(false);
});
it('accorde une capacité présente', () => {
expect(can(actor(), 'planning.view')).toBe(true);
});
it('refuse hors du compte, même avec la capacité', () => {
// Le cas qui compte : détenir le droit ne dit rien du périmètre.
expect(
can(actor(), 'planning.view', { accountId: 'autre-compte' }),
).toBe(false);
});
it('refuse un établissement hors périmètre', () => {
expect(can(actor(), 'planning.view', { locationId: 'loc2' })).toBe(false);
expect(can(actor(), 'planning.view', { locationId: 'loc1' })).toBe(true);
});
it('accorde tous les établissements au périmètre global', () => {
const director = actor({
scope: { allLocations: true, locationIds: [], teamIds: [] },
});
expect(can(director, 'planning.view', { locationId: 'loc99' })).toBe(true);
});
});
describe('authorize', () => {
it('lève quand la capacité manque', () => {
expect(() => authorize(actor(), 'planning.publish')).toThrow(
AuthorizationError,
);
});
it('lève quand le périmètre ne couvre pas la ressource', () => {
expect(() =>
authorize(actor(), 'planning.view', { locationId: 'loc2' }),
).toThrow(/périmètre/);
});
it('ne lève pas quand tout est réuni', () => {
expect(() =>
authorize(actor(), 'planning.view', { locationId: 'loc1' }),
).not.toThrow();
});
});
describe('canForMember', () => {
const employee = actor({
permissions: new Set(['counters.view_own']),
});
it('permet de voir ses propres compteurs', () => {
expect(
canForMember(employee, 'counters.view_own', 'counters.view_others', 'm1'),
).toBe(true);
});
it('refuse ceux des autres sans la capacité dédiée', () => {
// L'audit relève ces deux droits explicitement séparés : les confondre
// ouvrirait les compteurs de toute l'équipe à chaque salarié.
expect(
canForMember(employee, 'counters.view_own', 'counters.view_others', 'm2'),
).toBe(false);
});
});
describe('inScope', () => {
it('accepte une ressource sans périmètre précisé', () => {
expect(inScope(actor())).toBe(true);
});
});
describe('catalogue de capacités', () => {
it('n’attribue que des capacités existantes', () => {
// Une faute de frappe dans une attribution donnerait un rôle qui ne peut
// rien faire, sans erreur au démarrage.
const known = new Set(PERMISSION_CODES);
for (const role of SYSTEM_ROLES) {
for (const code of DEFAULT_ROLE_PERMISSIONS[role.key]) {
expect(known.has(code), `${role.key} : capacité inconnue ${code}`).toBe(
true,
);
}
}
});
it('réserve la délégation du niveau propriétaire', () => {
for (const role of SYSTEM_ROLES) {
const has = DEFAULT_ROLE_PERMISSIONS[role.key].includes(
'role_config.assign_owner_level',
);
expect(has, `${role.key}`).toBe(role.key === 'owner');
}
});
it('ne donne pas les réglages au manager', () => {
const manager = DEFAULT_ROLE_PERMISSIONS.manager;
for (const code of [
'settings.access',
'settings.locations.manage',
'settings.agreement.manage',
'members.salary.view',
'payroll.access',
]) {
expect(manager, `manager ne doit pas détenir ${code}`).not.toContain(code);
}
});
it('donne à l’employé le strict nécessaire', () => {
const employee = DEFAULT_ROLE_PERMISSIONS.employee;
expect(employee).toContain('timeoff.request');
expect(employee).toContain('counters.view_own');
expect(employee).not.toContain('counters.view_others');
expect(employee).not.toContain('timeoff.decide');
expect(employee).not.toContain('planning.publish');
});
it('utilise des codes stables de la forme ressource.action', () => {
for (const code of PERMISSION_CODES) {
expect(code, `${code} doit être en minuscules avec des points`).toMatch(
/^[a-z_]+(\.[a-z_]+)+$/,
);
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { Prisma } from '@prisma/client';
import { describe, expect, it } from 'vitest';
/**
* Le scoping multi-tenant se dérive du schéma Prisma.
*
* Ce test protège la dérivation elle-même : le mode de défaillance n'est pas
* d'écrire une mauvaise règle, c'est d'ajouter un modèle et d'oublier de le
* déclarer quelque part. Il a déjà été rencontré une fois — quatre modèles
* ajoutés au WP-02 échappaient au filtre.
*/
describe('modèles scopés', () => {
const scoped = Prisma.dmmf.datamodel.models.filter((model) =>
model.fields.some(
(field) => field.name === 'accountId' && field.kind === 'scalar',
),
);
it('détecte tous les modèles portant accountId', () => {
expect(scoped.length).toBeGreaterThanOrEqual(13);
});
it('couvre les modèles connus du périmètre', () => {
const names = new Set(scoped.map((model) => model.name));
for (const model of [
'Location',
'Team',
'Membership',
'Role',
'AuditLog',
'JobTitle',
'Label',
'AbsenceType',
'LegalConfigEntry',
'RetentionPolicy',
]) {
expect(names.has(model), `${model} doit être scopé`).toBe(true);
}
});
it('n’inclut pas les modèles volontairement globaux', () => {
const names = new Set(scoped.map((model) => model.name));
// Permission est un référentiel produit ; User et Session vivent avant
// qu'un compte soit connu, au moment de l'authentification.
for (const model of ['Permission', 'User', 'Session', 'Account']) {
expect(names.has(model), `${model} ne doit pas être scopé`).toBe(false);
}
});
});