Skip to content

Commit 55012df

Browse files
fix(plugin-auth): the admin identity rows on the compliance ledger record the admin's decisions, never a value of a user field (#21174) (#21195)
Fixes #21174 Clause-②: no The compliance-ledger rows that `plugin-auth`'s admin identity endpoints write themselves now record the admin's decisions and a reference to the user, never a value of a field of that user. The values those calls write into the user's fields stay where the CRUD mirror already records them: on its `create` and `update` rows for the same writes, in the before/after snapshot columns that PR #21171 narrows per reader. This follows triage's direction on the card (`5932908541`): the row records the decision or a reference to the record, never a parent field value in free metadata, and there is no read-time mapping of decision names back to fields. ## Measured first, on a real boot Re-measured privately on `main` at `b9087d77` (PR #21171 in). The stack was `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and auth layers (the admin plugin on), plus `AuditPlugin`. The seeded platform admin called the create-user and set-user-password doors. Readers read the ledger through the generic list door. Readings stay private to the dispatch; this table gives classes only. | Reader class | Mirror rows about the user (PR #21171) | Explicit admin create row, before | After | |---|---|---|---| | A user field it is not granted (one reader per written field, four fields) | that field absent | that field's value in `metadata` | absent | | A user field served masked (layered by an object extension) | absent | value in `metadata` | absent | | A capability-gated user field, no mask (layered the same way) | absent | value in `metadata` | absent | | Platform read-only wildcard plus a withholding set | every class absent | every class's value in `metadata` | absent | | Unrestricted reader (control) | every value | every value in `metadata` | every value, through the mirror rows only | The set-user-password row carried the value of one written field the same way. The data plane answered every reader `404` for the created user, the control included (see the acceptance notes). ## What changed - `packages/plugins/plugin-auth/src/admin-user-endpoints.ts`: - `runAdminCreateUser` no longer copies the four values it writes into the user's fields into the row's `metadata`. `runAdminSetUserPassword` no longer copies the one it writes. - `writeAdminAudit` takes a closed `AdminAuditDecisions` type: the operation, whether the password was generated, whether the account's address is a generated placeholder, whether the membership was bound, and the bound organization as a reference. Each member is either a decision no field of the user stores, or a reference to another record. The row's reference to the user is its own `object_name` and `record_id`. - The header that justifies the explicit row, and the durability warning that lists its decisions, now say the same. - `.changeset/21174-admin-audit-metadata.md`: `patch` for `@objectstack/plugin-auth`, with the migration line for a reader that took a value from these rows. The type refuses a field value written as a literal key at compile time. A conditional spread passes TypeScript's excess-property check, so the type does not stop that spelling; the unit pins do. The ablations below measured both. ## Census: every non-mirror ledger writer in `domain:services` Every `sys_audit_log` insert outside the CRUD mirror, found by grepping the lane's packages (`plugin-auth`, `plugin-security`, `plugin-sharing`, `plugin-approvals`, `plugin-audit`, `plugin-webhooks`, `plugin-email`, `embedder-openai`, `knowledge-*`, `services/*`, `connectors/*`, `triggers/*`) for the ledger's name and for its constants. `plugin-sharing`, `plugin-approvals`, the connectors, the triggers and every other service name the ledger in comments only. | Writer | Row | What `metadata` carries | Verdict | |---|---|---|---| | `plugin-auth` `admin-user-endpoints.ts#writeAdminAudit`, create-user | `create` on the user | the decisions, plus four values written into the user's fields | **fixed here** | | same, set-user-password | `update` on the user | the decisions, plus one value written into the user | **fixed here** | | `plugin-auth` `admin-import-users.ts`, run-level row | `import`, `record_id` null | the run's mode, match key name and password policy, and counts | already clean: no record value | | `plugin-auth` `auth-session-audit.ts` events, written by `plugin-audit` `auth-event-audit.ts` | `login` / `logout` on the session | the endpoint path; on an impersonation session, the impersonating admin's id | clean of a field value: the id is a reference and is the row's own `actor` column. The client fingerprint rides the ledger's own columns, not `metadata` | | `plugin-audit` `read-audit.ts` | record-view rows | nothing | already clean | | `service-settings` `config-change-audit.ts` | `config_change`, `record_id` null | the setting's composite identity (its reference), a flag derived from the setting's declaration, the request id | already clean: the value rides the snapshot column as a digest only | | `plugin-security` `platform-admin-standing-audit.ts` | `platform_admin_standing_change` | the event and two counts | already clean; `plugin-security` is outside this card's file surface and untouched | No in-lane sibling needed an edit, so no edit outside `plugin-auth` was made beyond the tests and the changeset. ## Downstream readers of the dropped values - `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` asserted one dropped value on the explicit create row. That case now asserts the explicit row does not carry it, and that the mirror's `update` row for the same write does. - The console's audit-log browser (objectui `apps/console/src/pages/system/AuditLogPage.tsx`, read at the pinned `.objectui-sha`) renders `metadata` as pretty-printed JSON. It reads no key by name. - ADR-0093's two membership decisions on this row are kept. - No other reader in this repository names any dropped key. ## Pins - `packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts`, 4 new cases. For each row it asserts: - the row's reference to the user; - the exact decision key set; - that no key names a field this call wrote, and no value equals a string the call wrote. The cases cover create-user with every optional input, phone-only create, a create that binds a membership, and set-user-password. Each case first checks that the call really wrote those fields. - `packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts`, 8 cases, on a real boot. - The platform declares no mask and no capability gate on the user fields these endpoints write. So an object extension layers one written field masked and one capability-gated. A permission set withholds a third. - There is one reader per class, the read-only-wildcard reader, and the control. The seeded admin drives both doors. - `beforeAll` arms it with `assertArmed`. The 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, and the control is served every class. - Per class, through the list, by-id and projected doors: both explicit rows are served to the reader, and no row served to it carries a value of its class. Separate cases show the two other classes still reach it through the mirror. - The control is served every class through the mirror, and the explicit rows carry only the closed decision set. - No test title states a value. ## Ablations: every negative pin, put back and shown red All legs went through `scripts/ablation-replace.mjs`. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. The first unit attempt was a no-op. Its replacement text contained its own anchor, so the tool refused before running anything. Every leg was re-spelled and run again. Unit legs, `src`-resolved, over the 41-case file: | Put back | Red | |---|---| | the created account's sign-in identifier, create row | 3 of the 4 new cases | | its phone identifier, create row | 2 | | its legacy role scalar, create row | 2 | | its force-password-change flag, create row | 3 | | the same flag, password-set row | 1 | | a written value smuggled inside a declared decision key | 4; the value detector names the leak in the phone-only case | | the identifier as a literal key, under `tsc --noEmit` | TS2353 on the closed type | Every other case stayed green, and the restore run read 41/41. Dogfood legs, resolved through `dist`. Each leg rebuilt `plugin-auth`, and `scripts/ablation-dist-preflight.mjs` proved the marker present in `dist`. Three legs used a literal key, and their declaration build failed on the closed type: the JS emitted, the preflight proved it, and the suite ran on it. The spread leg built clean. | Put back | Red, of 11 (the new 8 plus the audit-trail file's 3) | |---|---| | the not-granted class's value, create row | 3: that class, the wildcard, the control | | the masked class's value, create row (a spread) | 3: that class, the wildcard, the control | | the gated class's flag, create row | 4: that class, the wildcard, the control, and the audit-trail case | | the gated class's flag, password-set row | 3: that class, the wildcard, the control | In every leg the preservation cases and the other classes' cases stayed green. The restore leg rebuilt `plugin-auth` and proved all four markers absent with `--absent` on a clean whole tree. It then went 11/11 green. The same 11 cases were also run against the pre-fix build. Red: the 5 negative cases and the audit-trail case. Green: the 3 preservation cases and the 2 untouched audit-trail cases. ## Verification at head `0f314432` `origin/main` was merged once, with no overlap with this diff; then a reinstall and a rebuild of the dogfood closure. - `pnpm --filter @objectstack/plugin-auth test`: 116 files, 2484 tests passed. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. The test-typecheck debt is held unchanged: 10 files, 94 errors, 23 signatures. - Dogfood `vitest --project isolated`, 9 files and 59 tests passed: - the new pin and the audit-trail file; - the five other suites that drive the admin endpoints (`admin-credential-lifecycle`, `admin-platform-admin-standing`, `admin-route-nonadmin-refusal`, `bearer-lane-password-change`, `membership-reconciler`); - the ledger readers `audit-log-field-values` and `auth-session-audit-trail`. - Dogfood typecheck: exit 0. - Gates: `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 67 families, with no paths. All 67 were run with exit codes captured before any pipe, and all exited 0. `--ran` reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) because eight unrelated packages had no `dist`. Those packages were built and it re-ran to exit 0. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 4 changed TS files reported 4 files, none ignored, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. The repo-wide lint is CI's. - Size: 594 changed lines (+581 / -13) across 5 files against merge base `2c1cef33`. No governed surface. ## Acceptance notes - **Rows written before this release.** They keep the values they copied. The ledger is append-only and the fix is producer-side, so nothing rewrites them, and a read-time narrowing of `metadata` is ruled out. Whether those stored rows are scrubbed is a decision for the maintainer. It is reported to the seat. - **The parent-record gate.** Every reader in the pin, the control included, is answered `404` by the data plane for the created user, and is still served the ledger rows about it. That is the class of #21175 (the ledger's parent-record gate), which this seat holds separately. #21175 remains open and is not addressed here. - **Judged, not changed:** - The create row's placeholder decision states whether the admin created a phone-only account. No field stores it, and the generated address itself is no longer copied. - The login row's impersonating admin's id is a reference that the same row's `actor` column carries. - The settings row's encrypted flag is derived from the setting's declaration, not from stored data. Each stays. - **The fixture's object extension** replaces two platform field definitions on the user object. `content/docs/data-modeling/object-extensions.mdx` documents that replace semantics ("A name the target already has is replaced, not merged"). This is how a masked and a capability-gated class reach those fields on a real deployment; the platform declares neither. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f115b1f commit 55012df

5 files changed

Lines changed: 581 additions & 13 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
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.

‎packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,139 @@ describe('runAdminSetUserPassword', () => {
704704
expect(meta.passwordGenerated).toBe(true);
705705
});
706706
});
707+
708+
// ── The ledger row records the admin's decisions, never a field value ──────
709+
//
710+
// `sys_audit_log.metadata` is free text. The ledger's read side narrows the
711+
// before/after snapshot columns to what each reader is served; it cannot
712+
// narrow `metadata` without mapping decision names back to fields, which would
713+
// derive masking a second time. So the producer must not put a value it wrote
714+
// into a field of the user there: that value is on plugin-audit's mirror row
715+
// for the same write, in a column the read side narrows. These pins hold the
716+
// row to its decisions and its reference (`object_name` + `record_id`).
717+
describe('admin ledger rows: decisions and a reference, never a field value of the user', () => {
718+
const CREATE_DECISIONS = ['event', 'membershipCreated', 'passwordGenerated', 'placeholderEmail'];
719+
const PASSWORD_SET_DECISIONS = ['event', 'passwordGenerated'];
720+
721+
/** Normalise a key so a camelCase metadata key and a snake_case field name compare equal. */
722+
const norm = (k: string) => k.replace(/_/g, '').toLowerCase();
723+
724+
/** Every field this call wrote on the user, with the value it wrote. */
725+
function userFieldWrites(m: ReturnType<typeof makeDeps>): Array<[string, unknown]> {
726+
const writes: Array<[string, unknown]> = [];
727+
for (const call of m.createUser.mock.calls) {
728+
const { data, ...body } = call[0].body as Record<string, any>;
729+
for (const [k, v] of Object.entries({ ...body, ...(data ?? {}) })) {
730+
if (k !== 'password') writes.push([k, v]);
731+
}
732+
}
733+
for (const [object, doc] of callsOf(m.engineUpdate)) {
734+
if (object !== 'sys_user') continue;
735+
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
736+
if (k !== 'id') writes.push([k, v]);
737+
}
738+
}
739+
return writes;
740+
}
741+
742+
/** The one ledger row the call wrote, with its metadata parsed. */
743+
function ledgerRow(m: ReturnType<typeof makeDeps>, insert = m.engineCreate) {
744+
const rows = callsOf(insert).filter(([object]) => object === 'sys_audit_log');
745+
expect(rows).toHaveLength(1);
746+
const row = rows[0][1] as Record<string, any>;
747+
return { row, metadata: JSON.parse(row.metadata) as Record<string, unknown> };
748+
}
749+
750+
/** No metadata key names a written field, and no metadata value is a written string value. */
751+
function expectNoFieldValue(metadata: Record<string, unknown>, writes: Array<[string, unknown]>) {
752+
expect(writes.length).toBeGreaterThan(0);
753+
const keys = new Set(Object.keys(metadata).map(norm));
754+
const blob = JSON.stringify(metadata);
755+
for (const [field, value] of writes) {
756+
expect(keys.has(norm(field)), `metadata names the written field ${field}`).toBe(false);
757+
if (typeof value === 'string' && value.length > 0) {
758+
expect(blob.includes(value), `metadata carries the value written to ${field}`).toBe(false);
759+
}
760+
}
761+
}
762+
763+
it('create-user: the row carries the closed decision set and none of the values written into the user', async () => {
764+
const m = makeDeps({ phoneNumberEnabled: () => true });
765+
const res = await runAdminCreateUser(
766+
m.deps,
767+
makeRequest({
768+
email: 'Ledger.Subject@Example.com',
769+
name: 'Ledger Subject',
770+
role: 'ledgerrole',
771+
phoneNumber: '+8613811112222',
772+
generatePassword: true,
773+
}),
774+
ACTOR,
775+
);
776+
expect(res.status).toBe(200);
777+
const writes = userFieldWrites(m);
778+
// Armed: the call really wrote the identity, the role scalar, the phone
779+
// and the must-change-password stamp, so the row had them to copy.
780+
expect(writes.map(([k]) => norm(k)).sort()).toEqual(
781+
['email', 'mustchangepassword', 'name', 'phonenumber', 'role'],
782+
);
783+
784+
const { row, metadata } = ledgerRow(m);
785+
expect(row.object_name).toBe('sys_user');
786+
expect(row.record_id).toBe('user-9');
787+
expect(Object.keys(metadata).sort()).toEqual(CREATE_DECISIONS);
788+
expect(metadata).toMatchObject({ event: 'user.admin_created', placeholderEmail: false, passwordGenerated: true });
789+
expectNoFieldValue(metadata, writes);
790+
});
791+
792+
it('create-user, phone-only: the placeholder decision is recorded, the generated address is not', async () => {
793+
const m = makeDeps({ phoneNumberEnabled: () => true });
794+
const res = await runAdminCreateUser(
795+
m.deps,
796+
makeRequest({ phoneNumber: '+8613833334444', generatePassword: true }),
797+
ACTOR,
798+
);
799+
expect(res.status).toBe(200);
800+
const { metadata } = ledgerRow(m);
801+
expect(Object.keys(metadata).sort()).toEqual(CREATE_DECISIONS);
802+
expect(metadata.placeholderEmail).toBe(true);
803+
expectNoFieldValue(metadata, userFieldWrites(m));
804+
});
805+
806+
it('create-user, membership bound: the organization rides as a reference beside the decisions', async () => {
807+
const m = makeDeps();
808+
const engineInsert = vi.fn(async () => ({}));
809+
const find = vi.fn(async (object: string) => (object === 'sys_organization' ? [{ id: 'org_only' }] : []));
810+
m.deps.getDataEngine = () => ({ update: m.engineUpdate, insert: engineInsert, find });
811+
const res = await runAdminCreateUser(
812+
m.deps,
813+
makeRequest({ email: 'bound.subject@example.com', role: 'boundrole', generatePassword: true }),
814+
ACTOR,
815+
);
816+
expect(res.status).toBe(200);
817+
const { metadata } = ledgerRow(m, engineInsert);
818+
expect(Object.keys(metadata).sort()).toEqual([...CREATE_DECISIONS, 'organizationId'].sort());
819+
expect(metadata).toMatchObject({ organizationId: 'org_only', membershipCreated: true });
820+
expectNoFieldValue(metadata, userFieldWrites(m));
821+
});
822+
823+
it('set-user-password: the row carries the closed decision set and not the stamp it wrote', async () => {
824+
const m = makeDeps();
825+
const res = await runAdminSetUserPassword(
826+
m.deps,
827+
makeRequest({ userId: 'user-9', generatePassword: true }),
828+
ACTOR,
829+
);
830+
expect(res.status).toBe(200);
831+
const writes = userFieldWrites(m);
832+
// Armed: the stamp really was written on the user.
833+
expect(writes.map(([k]) => norm(k))).toEqual(['mustchangepassword']);
834+
835+
const { row, metadata } = ledgerRow(m);
836+
expect(row.object_name).toBe('sys_user');
837+
expect(row.record_id).toBe('user-9');
838+
expect(Object.keys(metadata).sort()).toEqual(PASSWORD_SET_DECISIONS);
839+
expect(metadata).toMatchObject({ event: 'user.admin_password_set', passwordGenerated: true });
840+
expectNoFieldValue(metadata, writes);
841+
});
842+
});

‎packages/plugins/plugin-auth/src/admin-user-endpoints.ts‎

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,39 @@ async function bindUserToSoleOrganization(
374374
};
375375
}
376376

377+
/**
378+
* The admin's decisions, the WHOLE of what {@link writeAdminAudit} puts in a
379+
* ledger row's `metadata`. Closed on purpose: each member is either a decision
380+
* that no field of the user stores, or a reference to another record.
381+
*
382+
* - `event` — which administrative operation this is.
383+
* - `passwordGenerated` — the system minted the password rather than the admin
384+
* typing one. No field stores it; the credential is on `sys_account`.
385+
* - `placeholderEmail` — the admin created a phone-only identity, so the
386+
* address on the account is a generated placeholder. The decision, never
387+
* the address.
388+
* - `membershipCreated` — this call bound the membership (ADR-0093 D2).
389+
* - `organizationId` — a reference to the organization bound to, a record of
390+
* its own; present only when one was resolved.
391+
*
392+
* ⛔ Never add a member that copies a value this operation writes into a field
393+
* of the user (see {@link writeAdminAudit}): that value is already on the
394+
* mirror row for the same write, in the snapshot column the ledger's read
395+
* side narrows.
396+
*/
397+
type AdminAuditDecisions =
398+
| {
399+
event: 'user.admin_created';
400+
placeholderEmail: boolean;
401+
passwordGenerated: boolean;
402+
membershipCreated: boolean;
403+
organizationId?: string;
404+
}
405+
| {
406+
event: 'user.admin_password_set';
407+
passwordGenerated: boolean;
408+
};
409+
377410
/**
378411
* Best-effort explicit audit row for an admin identity operation. Never
379412
* throws; never includes password material (red line).
@@ -413,17 +446,31 @@ async function bindUserToSoleOrganization(
413446
* password was administratively reset. "The hook covers it, drop the
414447
* explicit insert" would silently delete that trail.
415448
* 3. **Disjoint payloads.** The generic row is a field diff / row snapshot;
416-
* this one records the admin's DECISIONS (`event`, `passwordGenerated`,
417-
* `mustChangePassword`, `placeholderEmail`, `membershipCreated`), none of
418-
* which is derivable from the stored row.
449+
* this one records the admin's DECISIONS ({@link AdminAuditDecisions}),
450+
* none of which is stored in a field of the user.
451+
*
452+
* **No field value of the user rides `metadata`.** The row's reference to the
453+
* record is `object_name` + `record_id`; `metadata` carries only the
454+
* decisions. A value this operation writes into a field of the user — the
455+
* identity it was created with, its legacy role scalar, the must-change-password
456+
* flag — is recorded by plugin-audit's mirror row for that same write, whose
457+
* before/after snapshots the ledger's read side narrows to what each reader is
458+
* served. `metadata` is free text that no read-time narrowing can map back to
459+
* a field without deriving masking a second time, so a copy here would serve
460+
* the value to a ledger reader the data plane withholds it from. Two guards
461+
* hold that: the closed {@link AdminAuditDecisions} type refuses a field value
462+
* written as a literal key at compile time (a conditional spread passes
463+
* TypeScript's excess-property check, so it does not stop that spelling), and
464+
* the pins in `admin-user-endpoints.test.ts` fail on any key outside the
465+
* decision set and on any value this call wrote into the user.
419466
*/
420467
async function writeAdminAudit(
421468
deps: AdminUserEndpointDeps,
422469
entry: {
423470
action: 'create' | 'update';
424471
actor: AdminActor;
425472
recordId: string;
426-
metadata: Record<string, unknown>;
473+
metadata: AdminAuditDecisions;
427474
},
428475
): Promise<void> {
429476
const engine = deps.getDataEngine();
@@ -463,8 +510,8 @@ async function writeAdminAudit(
463510
+ `${entry.recordId} was NOT written — the operation itself SUCCEEDED and the endpoint `
464511
+ 'answers 200, so nothing looks wrong. plugin-audit is installed (sys_audit_log is '
465512
+ 'registered), so this is a REFUSED write, not an absent plugin. This row carries the '
466-
+ "admin's decisions (event, passwordGenerated, mustChangePassword, placeholderEmail, "
467-
+ 'membershipCreated), none of which is derivable from the stored row, and for '
513+
+ "admin's decisions (event, passwordGenerated, placeholderEmail, membershipCreated), "
514+
+ 'none of which is stored in a field of the user, and for '
468515
+ '/admin/set-user-password it is the only audit record that exists at all because '
469516
+ "sys_account is in plugin-audit's SKIP_OBJECTS. Nothing retries this write, so the "
470517
+ 'action stays permanently untrailed. Remedy: restore write access to sys_audit_log '
@@ -573,18 +620,17 @@ export async function runAdminCreateUser(
573620
// `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper.
574621
const membership = await bindUserToSoleOrganization(deps, userId);
575622

623+
// The decisions only. The values this call wrote into the user's fields —
624+
// the identity, the role scalar, the must-change-password stamp — are on
625+
// plugin-audit's mirror rows for those same writes (see `writeAdminAudit`).
576626
await writeAdminAudit(deps, {
577627
action: 'create',
578628
actor,
579629
recordId: userId,
580630
metadata: {
581631
event: 'user.admin_created',
582-
email: email.toLowerCase(),
583-
...(phoneNumber ? { phoneNumber } : {}),
584-
...(role ? { role } : {}),
585632
placeholderEmail: !hasEmail,
586633
passwordGenerated: resolved.generated,
587-
mustChangePassword: stamped,
588634
...(membership.organizationId ? { organizationId: membership.organizationId } : {}),
589635
membershipCreated: membership.membershipCreated,
590636
},
@@ -679,10 +725,11 @@ export async function runAdminSetUserPassword(
679725
action: 'update',
680726
actor,
681727
recordId: userId,
728+
// The must-change-password stamp is a field write on the user, recorded by
729+
// plugin-audit's mirror row for it — not copied here (see `writeAdminAudit`).
682730
metadata: {
683731
event: 'user.admin_password_set',
684732
passwordGenerated: resolved.generated,
685-
mustChangePassword: mustChangePassword && stamped,
686733
},
687734
});
688735

‎packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,21 @@ describe('#4940: what an admin identity operation leaves in sys_audit_log', () =
140140

141141
// W2 — and the endpoint's own row is a SECOND row on the same record.
142142
// Kept deliberately: it records the admin's DECISIONS, none of which is
143-
// derivable from a field diff of the created row.
143+
// stored in a field of the created user.
144144
const explicit = creates.filter((r) => isExplicit(r, 'user.admin_created'));
145145
expect(explicit).toHaveLength(1);
146146
expect(explicit[0].user_id).toBe(adminUserId);
147-
expect(String(explicit[0].metadata)).toContain('"mustChangePassword":true');
148147
expect(String(explicit[0].metadata)).toContain('"passwordGenerated":false');
148+
// The must-change-password stamp is a write to a field of the user, so it
149+
// rides plugin-audit's own `update` row for that write (the snapshot column
150+
// the ledger's read side narrows per reader), never the explicit row's
151+
// free metadata, which no read-time narrowing reaches.
152+
expect(String(explicit[0].metadata)).not.toContain('mustChangePassword');
153+
await waitForRows(
154+
async () => (await userAudit(ql, userId)).filter((r) => r.action === 'update' && isGeneric(r)),
155+
(rows) => rows.some((r) => String(r.new_value).includes('must_change_password')),
156+
"plugin-audit's update row for the must-change-password stamp",
157+
);
149158
// The overlap is exactly two — measured, so a third writer appearing on
150159
// this path is a finding rather than a silent extra ledger row.
151160
expect(creates).toHaveLength(2);

0 commit comments

Comments
 (0)