feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1)
Schema: Person golden record + User portal-account overlay (1-to-0-or-1)
+ UserScope (the table behind the ADR-0025 scope axis). All in the
public schema. UserScope.value is opaque (no FK to ADR-0027 tables -
historical scope rows survive structure decommissioning).
Migration: hand-written, follows the existing convention. FKs with
correct ON DELETE actions (RESTRICT on User.personId for the required
relation, CASCADE on UserScope.userId via explicit onDelete: Cascade
in the schema).
Catalogue: PERSON_SOURCES = [self-signin, admin-ui, seed] as const
under apps/portal-bff/src/users/person-source.ts. Defense in depth =
TS type union + drift gate scanner extension (property literal
"source: 'X'" in files importing person-source.ts).
PersonAndUserProvisioner: blocking lazy-create at first OIDC callback.
Fast path = findUnique by entraOid + update lastSignInAt. Cold path =
nested create of Person + User in one transaction. Race-condition
handling: catches P2002 on User.entraOid unique constraint and re-runs
ensureUser. Defense-in-depth isPersonSource check on the constant.
PrincipalBuilder signature: build(user, identity: { userId, personId }).
Principal.user.{id, personId} populated from real UUIDs. ScopeResolver
seam moved from { entraOid } to { userId } so PR 2's PrismaScopeResolver
can key queries on User.id.
SessionEstablisher: new constructor arg personUserProvisioner.
establish() calls ensureUser BEFORE principalBuilder.build so the
identity is available. Entra preferred_username (= AuthenticatedUser.
username) maps to Person.email per ADR-0026 lifecycle. Provisioner
failure short-circuits the whole flow before save / cookie / audit /
directory.
UserDirectoryService stays as the ADR-0020 admin-list cache - folding
it into Person+User is a follow-up after ADR-0026 PR 2 stabilises.
Local verification: drift gate clean (4 / 24 / 7 / 3), 29 drift-gate
spec tests passing, portal-bff lint 0 errors, 766 portal-bff specs
passing.
This commit is contained in:
@@ -15,7 +15,9 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
extractCatalogues,
|
||||
extractPersonSources,
|
||||
extractStructureKinds,
|
||||
findPersonSourceViolationsInFile,
|
||||
findStructureKindViolationsInFile,
|
||||
findViolationsInFile,
|
||||
scanWorkspace,
|
||||
@@ -59,6 +61,17 @@ function makeFixture({ files }) {
|
||||
export type StructureKind = (typeof STRUCTURE_KINDS)[number];
|
||||
`,
|
||||
);
|
||||
mkdirSync(join(root, 'apps/portal-bff/src/users'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'apps/portal-bff/src/users/person-source.ts'),
|
||||
`export const PERSON_SOURCES = [
|
||||
'self-signin',
|
||||
'admin-ui',
|
||||
'seed',
|
||||
] as const;
|
||||
export type PersonSource = (typeof PERSON_SOURCES)[number];
|
||||
`,
|
||||
);
|
||||
for (const [relativePath, contents] of Object.entries(files)) {
|
||||
const full = join(root, relativePath);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
@@ -262,6 +275,91 @@ describe('findStructureKindViolationsInFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPersonSources', () => {
|
||||
it('reads the PERSON_SOURCES catalogue from person-source.ts', () => {
|
||||
const root = makeFixture({ files: {} });
|
||||
const sources = extractPersonSources(join(root, 'apps/portal-bff/src/users/person-source.ts'));
|
||||
assert.deepEqual([...sources].sort(), ['admin-ui', 'seed', 'self-signin']);
|
||||
});
|
||||
|
||||
it('throws when the constant is missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'drift-fixture-'));
|
||||
mkdirSync(join(root, 'apps/portal-bff/src/users'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'apps/portal-bff/src/users/person-source.ts'),
|
||||
`export const NOT_PERSON_SOURCES = ['foo'] as const;\n`,
|
||||
);
|
||||
assert.throws(
|
||||
() => extractPersonSources(join(root, 'apps/portal-bff/src/users/person-source.ts')),
|
||||
/PERSON_SOURCES/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPersonSourceViolationsInFile', () => {
|
||||
const validSources = new Set(['self-signin', 'admin-ui', 'seed']);
|
||||
|
||||
it('skips files that do not import person-source (would false-positive)', () => {
|
||||
// `source: 'X'` is an extremely common pattern (event sources, log
|
||||
// sources, etc.). Without the import filter, every such literal
|
||||
// would be flagged.
|
||||
const text = `
|
||||
const evt = { source: 'logger', message: 'hello' };
|
||||
`;
|
||||
const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text);
|
||||
assert.equal(violations.length, 0);
|
||||
});
|
||||
|
||||
it('returns no violations when every source literal is in the catalogue', () => {
|
||||
const text = `
|
||||
import type { PersonSource } from '../users/person-source';
|
||||
const a = { source: 'self-signin' as PersonSource };
|
||||
const b = { source: 'seed' as PersonSource };
|
||||
`;
|
||||
const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text);
|
||||
assert.equal(violations.length, 0);
|
||||
});
|
||||
|
||||
it('flags every source literal not in the catalogue', () => {
|
||||
const text = `
|
||||
import { PERSON_SOURCES } from '../users/person-source';
|
||||
const a = { source: 'self-signin' };
|
||||
const b = { source: 'pleiades' };
|
||||
const c = { source: 'rogue-source' };
|
||||
`;
|
||||
const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text);
|
||||
assert.equal(violations.length, 2);
|
||||
const values = violations.map((v) => v.value).sort();
|
||||
assert.deepEqual(values, ['pleiades', 'rogue-source']);
|
||||
assert.equal(violations[0].callee, 'Person.source');
|
||||
assert.equal(violations[0].catalogue, 'PERSON_SOURCES');
|
||||
});
|
||||
|
||||
it('skips non-literal initialisers (variable indirection)', () => {
|
||||
const text = `
|
||||
import { PERSON_SOURCES } from '../users/person-source';
|
||||
const s = 'something';
|
||||
const a = { source: s };
|
||||
`;
|
||||
const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text);
|
||||
assert.equal(violations.length, 0);
|
||||
});
|
||||
|
||||
it('reports line and column of the offending literal', () => {
|
||||
const text = [
|
||||
`import { PERSON_SOURCES } from '../users/person-source';`,
|
||||
`const x = {`,
|
||||
` source: 'rogue-source',`,
|
||||
`};`,
|
||||
].join('\n');
|
||||
const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].line, 3);
|
||||
// 'rogue-source' literal starts at column 11 (0-indexed 10, +1 in the report).
|
||||
assert.equal(violations[0].column, 11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanWorkspace', () => {
|
||||
it('returns 0 violations on a clean fixture', () => {
|
||||
const root = makeFixture({
|
||||
@@ -333,25 +431,28 @@ describe('scanWorkspace', () => {
|
||||
assert.equal(violations.length, 1);
|
||||
});
|
||||
|
||||
it('aggregates decorator + Structure.kind violations together', () => {
|
||||
it('aggregates decorator + Structure.kind + Person.source violations together', () => {
|
||||
const root = makeFixture({
|
||||
files: {
|
||||
'apps/portal-bff/src/foo.ts': `
|
||||
import { STRUCTURE_KINDS } from './structures/structure-kind';
|
||||
import { PERSON_SOURCES } from './users/person-source';
|
||||
@RequirePrivilege('Portal.Ghost') class Bad {}
|
||||
const s = { kind: 'phantom_kind' };
|
||||
const p = { source: 'rogue-source' };
|
||||
`,
|
||||
},
|
||||
});
|
||||
const { violations } = scanWorkspace(root);
|
||||
assert.equal(violations.length, 2);
|
||||
assert.equal(violations.length, 3);
|
||||
const values = violations.map((v) => v.value).sort();
|
||||
assert.deepEqual(values, ['Portal.Ghost', 'phantom_kind']);
|
||||
assert.deepEqual(values, ['Portal.Ghost', 'phantom_kind', 'rogue-source']);
|
||||
});
|
||||
|
||||
it('returns the structureKinds set alongside catalogues', () => {
|
||||
it('returns the structureKinds and personSources sets alongside catalogues', () => {
|
||||
const root = makeFixture({ files: {} });
|
||||
const { structureKinds } = scanWorkspace(root);
|
||||
const { structureKinds, personSources } = scanWorkspace(root);
|
||||
assert.deepEqual([...structureKinds].sort(), ['antenne', 'medico_social', 'siege']);
|
||||
assert.deepEqual([...personSources].sort(), ['admin-ui', 'seed', 'self-signin']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user