diff --git a/app/database/seed-marketing.ts b/app/database/seed-marketing.ts index 8baeb8d2..3e4d0c28 100644 --- a/app/database/seed-marketing.ts +++ b/app/database/seed-marketing.ts @@ -21,6 +21,8 @@ import { seedDefaultTemplates } from '../features/events/server/seed-templates.s import { EntranceKind } from '../features/territories/model/entrance-kind.type' import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-kind.type' import { TerritoryKind } from '../features/territories/model/territory-kind.type' +import { syncBuiltInRoleAssignments } from '../shared/domain/built-in-roles.server' +import { seedBuiltInRoles } from '../shared/domain/setup.server' import { Permission } from '../shared/types/permission' import { PublisherType } from '../shared/types/publisher-type' import { stripDiacritics } from '../shared/utils/strip-diacritics' @@ -625,6 +627,11 @@ async function main() { console.log(` ✓ Congregation "${congregation.name}" (id=${congId})`) + // Ensure built-in identity roles (`member`, `publisher`, `brother`, …) exist + // for this congregation even when the marketing seed runs on a fresh DB + // without the regular seed having run first. + await seedBuiltInRoles(prisma, congId) + // ── Event templates ─────────────────────────────────────────────────── await seedDefaultTemplates(prisma, congId, 'fr') @@ -676,6 +683,11 @@ async function main() { }, }) + // Populate MemberRoleAssignment rows from the member's flags. The regular + // aggregate does this via syncBuiltInRoleAssignments; the direct + // prisma.member.create above bypasses it, so call it explicitly here. + await syncBuiltInRoleAssignments(prisma, member.id, congId, null) + const account = await prisma.userAccount.upsert({ where: { email }, update: {}, diff --git a/app/shared/domain/built-in-roles.server.test.ts b/app/shared/domain/built-in-roles.server.test.ts index b87b540a..f9ef3c8c 100644 --- a/app/shared/domain/built-in-roles.server.test.ts +++ b/app/shared/domain/built-in-roles.server.test.ts @@ -214,6 +214,23 @@ describe('syncBuiltInRoleAssignments', () => { ) }) + it('scopes the built-in role lookup to the congregation', async () => { + // Guards against cross-tenant assignments when the caller bypasses RLS + // (e.g. seed scripts running as the DB owner). + const db = makeDb({ + member: { ...BASE, isPublisher: true }, + builtInRoles: [{ id: 14, key: 'member' }], + existingAssignments: [], + }) + + await syncBuiltInRoleAssignments(db as never, 42, 7, 99) + + expect(db.role.findMany).toHaveBeenCalledWith({ + where: { isBuiltIn: true, congregationId: 7 }, + select: { id: true, key: true }, + }) + }) + it('removes stale assignments and audits the diff', async () => { const db = makeDb({ // Sister publisher only (no elder, since female cannot be elder) diff --git a/app/shared/domain/built-in-roles.server.ts b/app/shared/domain/built-in-roles.server.ts index 62726cbf..9491d765 100644 --- a/app/shared/domain/built-in-roles.server.ts +++ b/app/shared/domain/built-in-roles.server.ts @@ -94,8 +94,12 @@ export async function syncBuiltInRoleAssignments( }) if (!member) return + // Scope by congregationId explicitly. Under RLS-scoped callers this is a + // no-op (rows are already filtered), but callers that bypass RLS — e.g. the + // seed scripts running as the DB owner — would otherwise match every + // congregation's built-in roles and write cross-tenant assignments. const builtInRoles = await db.role.findMany({ - where: { isBuiltIn: true }, + where: { isBuiltIn: true, congregationId }, select: { id: true, key: true }, }) diff --git a/scripts/check-aggregate-boundaries.test.ts b/scripts/check-aggregate-boundaries.test.ts index faa67250..aa8f68f7 100644 --- a/scripts/check-aggregate-boundaries.test.ts +++ b/scripts/check-aggregate-boundaries.test.ts @@ -42,6 +42,20 @@ describe('analyzeSource — rule 1: writes on aggregate models', () => { } }) + it('flags prisma.member.create outside an aggregate/allowlist file', () => { + const source = 'const m = await prisma.member.create({ data: {} })' + const v = analyzeSource('app/features/publishers/server/thing.server.ts', source) + expect(v).toHaveLength(1) + expect(v[0].rule).toBe('write-outside-aggregate') + }) + + it('allows the write inside an app/database seed script', () => { + const source = 'const m = await prisma.member.create({ data: {} })' + for (const path of ['app/database/seed.ts', 'app/database/seed-marketing.ts']) { + expect(analyzeSource(path, source)).toHaveLength(0) + } + }) + it('does not flag writes on non-aggregate models', () => { const source = 'await db.role.update({ where, data })' const v = analyzeSource('app/features/settings/server/roles.server.ts', source) diff --git a/scripts/check-aggregate-boundaries.ts b/scripts/check-aggregate-boundaries.ts index fae31211..0d32ecdc 100644 --- a/scripts/check-aggregate-boundaries.ts +++ b/scripts/check-aggregate-boundaries.ts @@ -2,10 +2,11 @@ // CQRS-lite boundary check. Enforces two rules from // docs/development/architecture-conventions.md#cqrs-lite: // -// 1. Writes to aggregate-owned models (`db.member.*` / `db.attribution.*` +// 1. Writes to aggregate-owned models (`db.member.*` / `prisma.member.*` / +// `db.attribution.*` / `prisma.attribution.*` with // create/update/updateMany/delete/deleteMany/upsert) are allowed only // inside *.aggregate.ts files or on the allowlist (import-*.server.ts -// orchestrators, tests). +// orchestrators, tests, and app/database/** seed scripts). // // 2. UI-shaped reads inside *.aggregate.ts files (`findMany`, `count`, // `aggregate`) are forbidden, unless the enclosing function is prefixed @@ -27,13 +28,17 @@ const AGGREGATE_MODELS = ['member', 'attribution'] as const const WRITE_METHODS = ['create', 'update', 'updateMany', 'delete', 'deleteMany', 'upsert'] as const const UI_READ_METHODS = ['findMany', 'count', 'aggregate'] as const -const WRITE_RE = new RegExp(`\\bdb\\.(${AGGREGATE_MODELS.join('|')})\\.(${WRITE_METHODS.join('|')})\\b`) +const WRITE_RE = new RegExp(`\\b(?:db|prisma)\\.(${AGGREGATE_MODELS.join('|')})\\.(${WRITE_METHODS.join('|')})\\b`) const READ_RE = new RegExp(`\\bdb\\.\\w+\\.(${UI_READ_METHODS.join('|')})\\b`) const FUNCTION_DECL_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/ const ALLOW_COMMENT_RE = /\/\/\s*aggregate-boundaries-allow\b/ const TEST_FILE_RE = /\.(?:test|integration\.test|spec)\.tsx?$/ const TEST_INFRA_RE = /^app\/tests\// const IMPORT_ORCHESTRATOR_RE = /\/import-[a-z-]+\.server\.ts$/ +// Seed scripts run before any aggregate is wired (no CongregationInfo / services) +// and legitimately write via the raw client. This exemption used to live only +// in prose in docs/development/architecture-conventions.md — enforce it here. +const DB_SEED_RE = /^app\/database\// const TS_EXTENSION_RE = /\.tsx?$/ function isCommentLine(line: string): boolean { @@ -61,7 +66,8 @@ export function analyzeSource(relPath: string, source: string): Violation[] { relPath.endsWith('.aggregate.ts') || IMPORT_ORCHESTRATOR_RE.test(relPath) || TEST_FILE_RE.test(relPath) || - TEST_INFRA_RE.test(relPath) + TEST_INFRA_RE.test(relPath) || + DB_SEED_RE.test(relPath) const isAggregate = relPath.endsWith('.aggregate.ts') let currentFunction = ''