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
221 lines
6.5 KiB
TypeScript
221 lines
6.5 KiB
TypeScript
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());
|