From e9aa805768bd9ca4cd5c9b6c77dfee50f982d04e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:38:36 +0000 Subject: [PATCH 1/3] fix(plugin-auth): the admin identity rows on the compliance ledger record the admin's decisions, never a field value of the user The explicit sys_audit_log rows written by the admin create-user and set-user-password endpoints copied values those calls write into the user's fields into free metadata, where the ledger's read-time snapshot narrowing cannot reach them. The row now carries a closed decision set plus its object_name/record_id reference; the values ride the mirror's narrowed snapshot columns. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/admin-user-endpoints.test.ts | 136 ++++++++++++++++++ .../plugin-auth/src/admin-user-endpoints.ts | 66 +++++++-- 2 files changed, 191 insertions(+), 11 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 3a332219998..7e666ea1174 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -704,3 +704,139 @@ describe('runAdminSetUserPassword', () => { expect(meta.passwordGenerated).toBe(true); }); }); + +// ── The ledger row records the admin's decisions, never a field value ────── +// +// `sys_audit_log.metadata` is free text. The ledger's read side narrows the +// before/after snapshot columns to what each reader is served; it cannot +// narrow `metadata` without mapping decision names back to fields, which would +// derive masking a second time. So the producer must not put a value it wrote +// into a field of the user there: that value is on plugin-audit's mirror row +// for the same write, in a column the read side narrows. These pins hold the +// row to its decisions and its reference (`object_name` + `record_id`). +describe('admin ledger rows: decisions and a reference, never a field value of the user', () => { + const CREATE_DECISIONS = ['event', 'membershipCreated', 'passwordGenerated', 'placeholderEmail']; + const PASSWORD_SET_DECISIONS = ['event', 'passwordGenerated']; + + /** Normalise a key so a camelCase metadata key and a snake_case field name compare equal. */ + const norm = (k: string) => k.replace(/_/g, '').toLowerCase(); + + /** Every field this call wrote on the user, with the value it wrote. */ + function userFieldWrites(m: ReturnType): Array<[string, unknown]> { + const writes: Array<[string, unknown]> = []; + for (const call of m.createUser.mock.calls) { + const { data, ...body } = call[0].body as Record; + for (const [k, v] of Object.entries({ ...body, ...(data ?? {}) })) { + if (k !== 'password') writes.push([k, v]); + } + } + for (const [object, doc] of callsOf(m.engineUpdate)) { + if (object !== 'sys_user') continue; + for (const [k, v] of Object.entries(doc as Record)) { + if (k !== 'id') writes.push([k, v]); + } + } + return writes; + } + + /** The one ledger row the call wrote, with its metadata parsed. */ + function ledgerRow(m: ReturnType, insert = m.engineCreate) { + const rows = callsOf(insert).filter(([object]) => object === 'sys_audit_log'); + expect(rows).toHaveLength(1); + const row = rows[0][1] as Record; + return { row, metadata: JSON.parse(row.metadata) as Record }; + } + + /** No metadata key names a written field, and no metadata value is a written string value. */ + function expectNoFieldValue(metadata: Record, writes: Array<[string, unknown]>) { + expect(writes.length).toBeGreaterThan(0); + const keys = new Set(Object.keys(metadata).map(norm)); + const blob = JSON.stringify(metadata); + for (const [field, value] of writes) { + expect(keys.has(norm(field)), `metadata names the written field ${field}`).toBe(false); + if (typeof value === 'string' && value.length > 0) { + expect(blob.includes(value), `metadata carries the value written to ${field}`).toBe(false); + } + } + } + + it('create-user: the row carries the closed decision set and none of the values written into the user', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ + email: 'Ledger.Subject@Example.com', + name: 'Ledger Subject', + role: 'ledgerrole', + phoneNumber: '+8613811112222', + generatePassword: true, + }), + ACTOR, + ); + expect(res.status).toBe(200); + const writes = userFieldWrites(m); + // Armed: the call really wrote the identity, the role scalar, the phone + // and the must-change-password stamp, so the row had them to copy. + expect(writes.map(([k]) => norm(k)).sort()).toEqual( + ['email', 'mustchangepassword', 'name', 'phonenumber', 'role'], + ); + + const { row, metadata } = ledgerRow(m); + expect(row.object_name).toBe('sys_user'); + expect(row.record_id).toBe('user-9'); + expect(Object.keys(metadata).sort()).toEqual(CREATE_DECISIONS); + expect(metadata).toMatchObject({ event: 'user.admin_created', placeholderEmail: false, passwordGenerated: true }); + expectNoFieldValue(metadata, writes); + }); + + it('create-user, phone-only: the placeholder decision is recorded, the generated address is not', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ phoneNumber: '+8613833334444', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const { metadata } = ledgerRow(m); + expect(Object.keys(metadata).sort()).toEqual(CREATE_DECISIONS); + expect(metadata.placeholderEmail).toBe(true); + expectNoFieldValue(metadata, userFieldWrites(m)); + }); + + it('create-user, membership bound: the organization rides as a reference beside the decisions', async () => { + const m = makeDeps(); + const engineInsert = vi.fn(async () => ({})); + const find = vi.fn(async (object: string) => (object === 'sys_organization' ? [{ id: 'org_only' }] : [])); + m.deps.getDataEngine = () => ({ update: m.engineUpdate, insert: engineInsert, find }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'bound.subject@example.com', role: 'boundrole', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const { metadata } = ledgerRow(m, engineInsert); + expect(Object.keys(metadata).sort()).toEqual([...CREATE_DECISIONS, 'organizationId'].sort()); + expect(metadata).toMatchObject({ organizationId: 'org_only', membershipCreated: true }); + expectNoFieldValue(metadata, userFieldWrites(m)); + }); + + it('set-user-password: the row carries the closed decision set and not the stamp it wrote', async () => { + const m = makeDeps(); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'user-9', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const writes = userFieldWrites(m); + // Armed: the stamp really was written on the user. + expect(writes.map(([k]) => norm(k))).toEqual(['mustchangepassword']); + + const { row, metadata } = ledgerRow(m); + expect(row.object_name).toBe('sys_user'); + expect(row.record_id).toBe('user-9'); + expect(Object.keys(metadata).sort()).toEqual(PASSWORD_SET_DECISIONS); + expect(metadata).toMatchObject({ event: 'user.admin_password_set', passwordGenerated: true }); + expectNoFieldValue(metadata, writes); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 63d6cb472b7..2b83d2467ee 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -374,6 +374,39 @@ async function bindUserToSoleOrganization( }; } +/** + * The admin's decisions, the WHOLE of what {@link writeAdminAudit} puts in a + * ledger row's `metadata`. Closed on purpose: each member is either a decision + * that no field of the user stores, or a reference to another record. + * + * - `event` — which administrative operation this is. + * - `passwordGenerated` — the system minted the password rather than the admin + * typing one. No field stores it; the credential is on `sys_account`. + * - `placeholderEmail` — the admin created a phone-only identity, so the + * address on the account is a generated placeholder. The decision, never + * the address. + * - `membershipCreated` — this call bound the membership (ADR-0093 D2). + * - `organizationId` — a reference to the organization bound to, a record of + * its own; present only when one was resolved. + * + * ⛔ Never add a member that copies a value this operation writes into a field + * of the user (see {@link writeAdminAudit}): that value is already on the + * mirror row for the same write, in the snapshot column the ledger's read + * side narrows. + */ +type AdminAuditDecisions = + | { + event: 'user.admin_created'; + placeholderEmail: boolean; + passwordGenerated: boolean; + membershipCreated: boolean; + organizationId?: string; + } + | { + event: 'user.admin_password_set'; + passwordGenerated: boolean; + }; + /** * Best-effort explicit audit row for an admin identity operation. Never * throws; never includes password material (red line). @@ -413,9 +446,20 @@ async function bindUserToSoleOrganization( * password was administratively reset. "The hook covers it, drop the * explicit insert" would silently delete that trail. * 3. **Disjoint payloads.** The generic row is a field diff / row snapshot; - * this one records the admin's DECISIONS (`event`, `passwordGenerated`, - * `mustChangePassword`, `placeholderEmail`, `membershipCreated`), none of - * which is derivable from the stored row. + * this one records the admin's DECISIONS ({@link AdminAuditDecisions}), + * none of which is stored in a field of the user. + * + * **No field value of the user rides `metadata`.** The row's reference to the + * record is `object_name` + `record_id`; `metadata` carries only the + * decisions. A value this operation writes into a field of the user — the + * identity it was created with, its legacy role scalar, the must-change-password + * flag — is recorded by plugin-audit's mirror row for that same write, whose + * before/after snapshots the ledger's read side narrows to what each reader is + * served. `metadata` is free text that no read-time narrowing can map back to + * a field without deriving masking a second time, so a copy here would serve + * the value to a ledger reader the data plane withholds it from. The closed + * {@link AdminAuditDecisions} type is the guard: a field value does not + * compile into this row. */ async function writeAdminAudit( deps: AdminUserEndpointDeps, @@ -423,7 +467,7 @@ async function writeAdminAudit( action: 'create' | 'update'; actor: AdminActor; recordId: string; - metadata: Record; + metadata: AdminAuditDecisions; }, ): Promise { const engine = deps.getDataEngine(); @@ -463,8 +507,8 @@ async function writeAdminAudit( + `${entry.recordId} was NOT written — the operation itself SUCCEEDED and the endpoint ` + 'answers 200, so nothing looks wrong. plugin-audit is installed (sys_audit_log is ' + 'registered), so this is a REFUSED write, not an absent plugin. This row carries the ' - + "admin's decisions (event, passwordGenerated, mustChangePassword, placeholderEmail, " - + 'membershipCreated), none of which is derivable from the stored row, and for ' + + "admin's decisions (event, passwordGenerated, placeholderEmail, membershipCreated), " + + 'none of which is stored in a field of the user, and for ' + '/admin/set-user-password it is the only audit record that exists at all because ' + "sys_account is in plugin-audit's SKIP_OBJECTS. Nothing retries this write, so the " + 'action stays permanently untrailed. Remedy: restore write access to sys_audit_log ' @@ -573,18 +617,17 @@ export async function runAdminCreateUser( // `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper. const membership = await bindUserToSoleOrganization(deps, userId); + // The decisions only. The values this call wrote into the user's fields — + // the identity, the role scalar, the must-change-password stamp — are on + // plugin-audit's mirror rows for those same writes (see `writeAdminAudit`). await writeAdminAudit(deps, { action: 'create', actor, recordId: userId, metadata: { event: 'user.admin_created', - email: email.toLowerCase(), - ...(phoneNumber ? { phoneNumber } : {}), - ...(role ? { role } : {}), placeholderEmail: !hasEmail, passwordGenerated: resolved.generated, - mustChangePassword: stamped, ...(membership.organizationId ? { organizationId: membership.organizationId } : {}), membershipCreated: membership.membershipCreated, }, @@ -679,10 +722,11 @@ export async function runAdminSetUserPassword( action: 'update', actor, recordId: userId, + // The must-change-password stamp is a field write on the user, recorded by + // plugin-audit's mirror row for it — not copied here (see `writeAdminAudit`). metadata: { event: 'user.admin_password_set', passwordGenerated: resolved.generated, - mustChangePassword: mustChangePassword && stamped, }, }); From e80a3b690091db29254038d207c27ff75970d4c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:50:58 +0000 Subject: [PATCH 2/3] test(dogfood): pin the admin ledger rows' decision metadata per field class on a real boot A reader of each field class (masked, capability-gated, not granted) and the read-only wildcard reader are served both explicit admin rows and no value of their class through the list, by-id and projected doors; the unmasking control still receives every class through the mirror's snapshots, and the explicit rows carry only the closed decision set. The audit-trail pin now reads the must-change-password stamp from the mirror's update row. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21174-admin-audit-metadata.md | 13 + ...admin-identity-audit-trail.dogfood.test.ts | 13 +- ...n-ledger-decision-metadata.dogfood.test.ts | 363 ++++++++++++++++++ 3 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 .changeset/21174-admin-audit-metadata.md create mode 100644 packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts diff --git a/.changeset/21174-admin-audit-metadata.md b/.changeset/21174-admin-audit-metadata.md new file mode 100644 index 00000000000..5b76956c8bb --- /dev/null +++ b/.changeset/21174-admin-audit-metadata.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): the compliance-ledger rows the admin identity endpoints write record the admin's decisions, never a value of a field of the user (#21174) + +Clause-②: no + +The admin create-user and set-user-password endpoints each write their own `sys_audit_log` row beside the rows plugin-audit's CRUD mirror writes for the same call. That row's free `metadata` copied values the call had just written into fields of the user. The ledger's read side narrows the mirror's before/after snapshots to what each reader is served, but it cannot narrow free metadata without deriving masking a second time, so a ledger reader the data plane withholds one of those fields from was served its value through the explicit row. + +The explicit row now carries only the admin's decisions — which operation ran, whether the password was generated, whether the account's address is a generated placeholder, whether the membership was bound and to which organization — plus its reference to the user (`object_name` and `record_id`). The values the call writes into the user's fields are recorded where they already were: on the mirror's `create` and `update` rows for those same writes, in the snapshot columns the read side narrows per reader. The decision set is a closed type, so a field value no longer compiles into the row. + +Migration: a reader that took a user field's value from the explicit row's metadata reads it from the mirror's row for the same write instead (its after-snapshot), served according to the reader's field access. Rows written before this release are stored data and are not rewritten. diff --git a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts index 03e77b5b49c..212939ff3bf 100644 --- a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts @@ -140,12 +140,21 @@ describe('#4940: what an admin identity operation leaves in sys_audit_log', () = // W2 — and the endpoint's own row is a SECOND row on the same record. // Kept deliberately: it records the admin's DECISIONS, none of which is - // derivable from a field diff of the created row. + // stored in a field of the created user. const explicit = creates.filter((r) => isExplicit(r, 'user.admin_created')); expect(explicit).toHaveLength(1); expect(explicit[0].user_id).toBe(adminUserId); - expect(String(explicit[0].metadata)).toContain('"mustChangePassword":true'); expect(String(explicit[0].metadata)).toContain('"passwordGenerated":false'); + // The must-change-password stamp is a write to a field of the user, so it + // rides plugin-audit's own `update` row for that write (the snapshot column + // the ledger's read side narrows per reader), never the explicit row's + // free metadata, which no read-time narrowing reaches. + expect(String(explicit[0].metadata)).not.toContain('mustChangePassword'); + await waitForRows( + async () => (await userAudit(ql, userId)).filter((r) => r.action === 'update' && isGeneric(r)), + (rows) => rows.some((r) => String(r.new_value).includes('must_change_password')), + "plugin-audit's update row for the must-change-password stamp", + ); // The overlap is exactly two — measured, so a third writer appearing on // this path is a finding rather than a silent extra ledger row. expect(creates).toHaveLength(2); diff --git a/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts new file mode 100644 index 00000000000..65c1cf81663 --- /dev/null +++ b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The compliance-ledger rows the admin identity endpoints write themselves +// record the admin's DECISIONS and a reference to the user, never a value of a +// field of that user, on a real boot. +// +// ## Why the producer, and not the read side +// +// `plugin-auth` writes its own `sys_audit_log` row for `/admin/create-user` +// and `/admin/set-user-password`, beside the rows plugin-audit's CRUD mirror +// writes for the same calls. The ledger's read side narrows the mirror's +// before/after snapshot columns to what each reader is served. The explicit +// row's `metadata` is free text keyed by decision names, so no read-time +// narrowing can map it back to fields without deriving masking a second time. +// A field value copied there was served to a ledger reader the data plane +// withholds that field from. The fix is at the producer: the row carries the +// decisions and its `object_name` + `record_id` reference, and the values ride +// the mirror's narrowed snapshots. +// +// ## The composition +// +// `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and +// auth layers (the admin plugin on, through the SCIM switch that forces it), +// plus `AuditPlugin`. The platform declares no mask and no capability gate on +// the user fields these endpoints write, so the fixture layers them the way an +// app does, through an object extension (a field the target already declares +// is replaced): one written field per class. +// +// - MASKED: a `maskingRule` whose unmask gate is one capability; +// - CAPABILITY-GATED: `requiredPermissions` naming another, no mask; +// - NOT GRANTED: plain, and marked non-readable by a set the reader holds. +// +// One reader per class holds exactly what makes ITS class apply; the control +// holds both capabilities and no withholding set. A wildcard reader takes its +// ledger read from the platform read-only set beside a withholding set, with +// no capability, so every class applies to it. The seeded platform admin +// creates one user and then resets its password, so both explicit rows exist. +// +// ## What is asserted +// +// - `beforeAll` (`assertArmed`): both explicit rows exist at rest, and the +// mirror rows at rest carry every class; the mirror rows served to each +// reader withhold exactly its class (the security service's own answer is +// engaged for it), and the control is served every class. +// - Per class, through the list door, the by-id door and a list projected to +// the metadata and snapshot columns: both explicit rows are served to the +// reader, and no row served to it carries a value of its class. The other +// two classes still reach it through the mirror's snapshots. +// - The control is served every class through the mirror's snapshots, and the +// explicit rows carry the closed decision set and nothing else. +// +// Fixtures are synthetic. ⚠️ No test title states a value. +// `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve through +// their BUILT output here (ledgered pairs in `scripts/check-test-source-alias.mjs`), +// so a verdict on a change to either is a verdict on its last build. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { assertArmed, armedWhen } from './armed.js'; + +const LEDGER = 'sys_audit_log'; +const USER = 'sys_user'; +const CAP_UNMASK = 'ald_unmask'; +const CAP_READ = 'ald_read_gated'; +const SYS = { context: { isSystem: true } } as const; + +type ClassName = 'masked' | 'gated' | 'withheld'; +const CLASSES: ClassName[] = ['masked', 'gated', 'withheld']; +/** The user field the endpoints write that carries each class in this fixture. */ +const FIELD: Record = { masked: 'role', gated: 'must_change_password', withheld: 'email' }; +/** Synthetic values the admin writes. The gated field is a flag, so it is detected by its key. */ +const SUBJECT = { email: 'ald.subject.kq@example.com', name: 'Ald Subject', role: 'aldrolekq' }; +const VALUE: Record = { masked: SUBJECT.role, gated: null, withheld: SUBJECT.email }; + +/** The explicit rows' events, and the decision keys each may carry. */ +const CREATE_EVENT = 'user.admin_created'; +const PASSWORD_SET_EVENT = 'user.admin_password_set'; +const DECISIONS: Record = { + [CREATE_EVENT]: { required: ['event', 'membershipCreated', 'passwordGenerated', 'placeholderEmail'], optional: ['organizationId'] }, + [PASSWORD_SET_EVENT]: { required: ['event', 'passwordGenerated'], optional: [] }, +}; + +const Anchor = ObjectSchema.create({ + name: 'ald_anchor', + label: 'ALD Anchor', + pluralLabel: 'ALD Anchors', + fields: { name: Field.text({ label: 'Name' }) }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.admin-ledger-decision-metadata', + namespace: 'ald', + version: '0.0.0', + type: 'app', + name: 'Admin Ledger Decision Metadata Fixture', + description: 'Layers a masked and a capability-gated class over two user fields the admin endpoints write.', + }, + objects: [Anchor], + objectExtensions: [ + { + extend: USER, + fields: { + role: Field.text({ + label: 'Platform Role', + readonly: true, + maxLength: 64, + maskingRule: { keepHead: 1, keepTail: 1 }, + requiredPermissions: [CAP_UNMASK], + }), + must_change_password: Field.boolean({ + label: 'Must Change Password', + defaultValue: false, + readonly: true, + requiredPermissions: [CAP_READ], + }), + }, + }, + ], +}); + +const read = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }; +const grants = { [USER]: read, [LEDGER]: read }; +const withheldField = { [`${USER}.${FIELD.withheld}`]: { readable: false, editable: false } }; +const unmaskSet = PermissionSetSchema.parse({ name: 'ald_unmask_set', label: 'ALD unmask', objects: grants, systemPermissions: [CAP_UNMASK] }); +const gatedReadSet = PermissionSetSchema.parse({ name: 'ald_gated_read_set', label: 'ALD gated read', objects: grants, systemPermissions: [CAP_READ] }); +const withholdSet = PermissionSetSchema.parse({ name: 'ald_withhold_set', label: 'ALD withhold', objects: grants, fields: withheldField }); +/** Withholds the field and grants no object: beside the platform read-only set. */ +const withholdOnlySet = PermissionSetSchema.parse({ name: 'ald_withhold_only_set', label: 'ALD withhold only', objects: {}, fields: withheldField }); + +/** Each reader, and the sets that make exactly its class apply. */ +const READERS: Record = { + masked: [gatedReadSet.name], + gated: [unmaskSet.name], + withheld: [unmaskSet.name, gatedReadSet.name, withholdSet.name], + control: [unmaskSet.name, gatedReadSet.name], + wildcard: ['viewer_readonly', withholdOnlySet.name], +}; +type Reader = keyof typeof READERS; + +type Row = Record; +const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []); +const recordOf = (body: any): Row => body?.record ?? body; + +/** A camelCase decision key and a snake_case field name compare equal. */ +const norm = (k: string): string => k.replace(/_/g, '').toLowerCase(); + +function keysDeep(value: unknown, out: string[] = []): string[] { + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + out.push(k); + keysDeep(v, out); + } + } + return out; +} + +/** Does one stored column carry a value of the class — its value, or a key naming its field? */ +function columnCarries(text: unknown, c: ClassName): boolean { + if (typeof text !== 'string' || text.length === 0) return false; + const value = VALUE[c]; + if (value !== null && text.includes(value)) return true; + try { + return keysDeep(JSON.parse(text)).some((k) => norm(k) === norm(FIELD[c])); + } catch { + return false; + } +} + +const rowCarries = (row: Row, c: ClassName): boolean => + columnCarries(row.metadata, c) || columnCarries(row.old_value, c) || columnCarries(row.new_value, c); + +const eventOf = (row: Row): string | null => { + try { + return typeof row.metadata === 'string' ? (JSON.parse(row.metadata)?.event ?? null) : null; + } catch { + return null; + } +}; +const isExplicit = (row: Row): boolean => eventOf(row) !== null; +const isMirror = (row: Row): boolean => row.metadata == null && (row.action === 'create' || row.action === 'update'); + +describe('the admin identity rows on the compliance ledger carry decisions, never a value of a user field', () => { + let stack: VerifyStack; + let ql: any; + let priorScim: string | undefined; + let subjectId = ''; + const token: Record = {}; + + const filter = () => encodeURIComponent(JSON.stringify({ object_name: USER, record_id: subjectId })); + + /** Every ledger row about the subject a reader is served, through three doors. */ + const servedRows = async (who: Reader) => { + const doors: Record<'list' | 'byId' | 'projected', Row[]> = { list: [], byId: [], projected: [] }; + const list = await stack.apiAs(token[who], 'GET', `/data/${LEDGER}?$filter=${filter()}`); + expect(list.status).toBe(200); + doors.list = rowsOf(await list.json()); + for (const row of doors.list) { + const res = await stack.apiAs(token[who], 'GET', `/data/${LEDGER}/${row.id}`); + expect(res.status).toBe(200); + doors.byId.push(recordOf(await res.json())); + } + const projected = await stack.apiAs( + token[who], + 'GET', + `/data/${LEDGER}?$filter=${filter()}&$select=id,metadata,old_value,new_value`, + ); + expect(projected.status).toBe(200); + doors.projected = rowsOf(await projected.json()); + return doors; + }; + + const ledgerAtRest = async (): Promise => + ql.find(LEDGER, { where: { object_name: USER, record_id: subjectId }, context: { isSystem: true } }); + + beforeAll(async () => { + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootStack(fixtureStack as unknown as Parameters[0], { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, unmaskSet, gatedReadSet, withholdSet, withholdOnlySet], + }), + extraPlugins: [new AuditPlugin()], + }); + ql = await stack.kernel.getServiceAsync('objectql'); + const idOf = async (object: string, where: Record) => + String((await ql.findOne(object, { where, context: { isSystem: true } }))?.id ?? ''); + + // The seeded platform admin signs in first, so no reader can be the first + // account on the deployment. + const admin = await stack.signIn(); + for (const who of Object.keys(READERS) as Reader[]) { + const email = `ald-${who}@verify.test`; + token[who] = await stack.signUp(email); + const userId = await idOf(USER, { email }); + for (const name of READERS[who]) { + const setId = await idOf('sys_permission_set', { name }); + expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: setId }, SYS); + } + } + + // The admin drives both endpoints through their HTTP doors. + const created = await stack.apiAs(admin, 'POST', '/auth/admin/create-user', { + ...SUBJECT, + password: 'Ald!Subject12345', + }); + expect(created.status, await created.clone().text()).toBe(200); + subjectId = String((await created.json()).data.user.id); + const reset = await stack.apiAs(admin, 'POST', '/auth/admin/set-user-password', { + userId: subjectId, + newPassword: 'Ald!Rotated67890', + }); + expect(reset.status, await reset.clone().text()).toBe(200); + + // The mirror's rows can settle after the response; wait until every row is down. + for (let i = 0; i < 40; i++) { + const rows = await ledgerAtRest(); + const events = rows.map(eventOf); + if (events.includes(CREATE_EVENT) && events.includes(PASSWORD_SET_EVENT) && + CLASSES.every((c) => rows.filter(isMirror).some((r) => rowCarries(r, c)))) break; + await new Promise((r) => setTimeout(r, 250)); + } + + await assertArmed([ + armedWhen({ + control: 'both explicit rows exist at rest, and the mirror rows at rest carry every class', + disarmedBy: 'a missing explicit row would let every negative case pass on a row that was never written, and a mirror that stopped carrying a class would hide where the value went', + observe: async () => { + const rows = await ledgerAtRest(); + return { + events: rows.map(eventOf).filter((e): e is string => e !== null).sort(), + mirrorCarries: CLASSES.filter((c) => rows.filter(isMirror).some((r) => rowCarries(r, c))), + }; + }, + armed: (o) => o.events.includes(CREATE_EVENT) && o.events.includes(PASSWORD_SET_EVENT) && o.mirrorCarries.length === 3, + describe: (o) => `events: ${o.events.join(',') || 'none'}; mirror carries: ${o.mirrorCarries.join(',') || 'none'}`, + }), + armedWhen({ + control: 'the mirror rows served to each reader withhold exactly its class, and the control is served every class', + disarmedBy: 'a reader whose grants did not resolve would be served every value, and its negative cases would measure an unrestricted reader', + observe: async () => { + const served: Record = {}; + for (const who of Object.keys(READERS) as Reader[]) { + const { list } = await servedRows(who); + served[who] = CLASSES.filter((c) => list.filter(isMirror).some((r) => rowCarries(r, c))); + } + return served; + }, + armed: (o) => + CLASSES.every((c) => !o[c].includes(c) && CLASSES.filter((x) => x !== c).every((x) => o[c].includes(x))) && + o.control.length === 3 && + o.wildcard.length === 0, + describe: (o) => JSON.stringify(o), + }), + ]); + }, 240_000); + + afterAll(async () => { + if (stack) await stack.stop(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + for (const c of CLASSES) { + it(`${c}: both explicit rows are served to the reader, and no row served to it carries a value of its class, through any door`, async () => { + const doors = await servedRows(c); + for (const [door, rows] of Object.entries(doors)) { + const events = rows.map(eventOf); + expect(events, `${door}: the explicit rows are served`).toContain(CREATE_EVENT); + expect(events, `${door}: the explicit rows are served`).toContain(PASSWORD_SET_EVENT); + for (const row of rows) { + expect(rowCarries(row, c), `${door}: a ${eventOf(row) ?? row.action} row carries the ${c} class`).toBe(false); + } + } + }); + + it(`${c}: the two other classes still reach the reader, through the mirror's snapshots`, async () => { + const { list } = await servedRows(c); + for (const other of CLASSES.filter((o) => o !== c)) { + expect(list.filter(isMirror).some((r) => rowCarries(r, other)), `the ${other} class`).toBe(true); + } + }); + } + + it('wildcard: a reader granted the ledger by the platform read-only set is served no value of any class, through any door', async () => { + const doors = await servedRows('wildcard'); + for (const [door, rows] of Object.entries(doors)) { + expect(rows.filter(isExplicit).length, `${door}: the explicit rows are served`).toBe(2); + for (const row of rows) { + for (const c of CLASSES) expect(rowCarries(row, c), `${door}: the ${c} class`).toBe(false); + } + } + }); + + it('control: every class reaches the unmasking reader through the mirror, and the explicit rows carry only the closed decision set', async () => { + const doors = await servedRows('control'); + for (const c of CLASSES) { + expect(doors.list.filter(isMirror).some((r) => rowCarries(r, c)), `the ${c} class`).toBe(true); + } + for (const [door, rows] of Object.entries(doors)) { + const explicit = rows.filter(isExplicit); + expect(explicit.length, `${door}: the explicit rows are served`).toBe(2); + for (const row of explicit) { + const allowed = DECISIONS[eventOf(row) as string]; + expect(allowed, `${door}: a known event`).toBeTruthy(); + const keys = Object.keys(JSON.parse(row.metadata)); + for (const k of allowed.required) expect(keys, `${door}: the ${k} decision`).toContain(k); + for (const k of keys) expect([...allowed.required, ...allowed.optional], `${door}: an undeclared key`).toContain(k); + // The reference to the record is the row's own columns (not projected on the third door). + if (door !== 'projected') { + expect(row.object_name).toBe(USER); + expect(row.record_id).toBe(subjectId); + } + } + } + }); +}); From 9091023719208ee18c35404fc195ef0f094af26e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:01:39 +0000 Subject: [PATCH 3/3] docs(plugin-auth): state the reach of the admin ledger row's closed decision type The excess-property check refuses a literal key but not a conditional spread; the unit pins are the guard for that spelling. Measured by the ablation legs: a literal-key leg fails the declaration build, a spread leg builds and is caught by the pins. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/plugins/plugin-auth/src/admin-user-endpoints.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 2b83d2467ee..ef2e9952dbc 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -457,9 +457,12 @@ type AdminAuditDecisions = * before/after snapshots the ledger's read side narrows to what each reader is * served. `metadata` is free text that no read-time narrowing can map back to * a field without deriving masking a second time, so a copy here would serve - * the value to a ledger reader the data plane withholds it from. The closed - * {@link AdminAuditDecisions} type is the guard: a field value does not - * compile into this row. + * the value to a ledger reader the data plane withholds it from. Two guards + * hold that: the closed {@link AdminAuditDecisions} type refuses a field value + * written as a literal key at compile time (a conditional spread passes + * TypeScript's excess-property check, so it does not stop that spelling), and + * the pins in `admin-user-endpoints.test.ts` fail on any key outside the + * decision set and on any value this call wrote into the user. */ async function writeAdminAudit( deps: AdminUserEndpointDeps,