Skip to content

audit: the tenant fallback in writeAudit reads session.tenantId, a key removed in v11 — the guard against NULL-tenant audit rows can never fire #9516

Description

@os-project-manager

Found while implementing #8992 (record-view auditing) — reading audit-writers.ts to match its tenant-stamping precedence on the new read row.

The defect

packages/plugins/plugin-audit/src/audit-writers.ts, in writeAudit:

consttenantId: string|undefined=recordOrgId??sess.tenantId;

sess is HookContext.session, and that object has no tenantId key. The engine builds it in ObjectQL.buildSession (packages/objectql/src/engine.ts) as an object literal with a fixed key set — userId, organizationId, positions, accessToken, and the conditional isSystem / actor / skipTriggers / skipAutomations / preserveAudit. There is no spread of the execution context, so no other key can arrive. The blessed name is organizationId, and buildSession's own comment records the removal:

The deprecated session.tenantId alias (#3280) was removed here in v11 (#3290) — the driver-layer execCtx.tenantId knob is a separate axis and stays.

HookContext's schema agrees (packages/spec/src/data/hook.zod.ts): it declares organizationId and documents the alias as removed in the v11 major.

So the right-hand arm is dead. The expression is exactly recordOrgId.

Why the dead arm matters — it is a guard, not a nicety

The block's own comment states the consequence it exists to prevent:

Audit rows must never be written with organization_id = NULL, or the SecurityPlugin's RLS predicate hides them forever and the audit log UI reads permanently empty while writes succeed.

and names the cases the session arm was written to cover:

  1. objects with no organization column at all (single-tenant stacks, and ADR-0066 platform-global objects — resolveRecordOrganizationField returns null for both);
  2. a row whose organization column is NULL or empty.

On those, recordOrgId is undefined, the dead arm contributes nothing, and the row is stamped tenant_id: null / organization_id: null — the permanently-invisible ledger row that #8245 and #8247 are about, reached by a different route. Writes keep succeeding, so nothing anywhere reports it.

There is a second occurrence in the same file, in writeCommentMentions:

consttenantId: string|null=sess.tenantId??row.organization_id??null;

Same dead first arm. Lower stakes — it feeds resolveWriteLocale, so the symptom is a mention notification falling back to the workspace-default locale lookup with no tenant scope rather than an invisible row — but it is the same removed key.

Why it survived

The comment block above the first site is unusually thorough about precedence and even carries a directive written on the assumption that the arm is live:

⛔ Do not flip this back to sess.tenantId ?? recordOrgId.

#8707's ruling changed the ORDER of the two arms and the reasoning is recorded in full — but reordering two expressions does not evaluate either of them, so an arm that had already stopped resolving stayed invisible through a careful review of exactly this code. The #8011 shape: a declaration sitting next to a mechanism, with nothing enforcing the agreement.

Nothing goes red today. Every sys_audit_log field is readonly: true and validateRecord skips readonly fields, so a null tenant is accepted silently; and the write path is wrapped in a swallow-and-report, so even a refusal would not surface as a test failure.

Repro

git grep -n "sess.tenantId" -- packages/plugins/plugin-audit/src/audit-writers.ts
git grep -n "tenantId" -- packages/objectql/src/engine.ts | grep buildSession -A 40

Then read buildSession's object literal and confirm no tenantId key is emitted.

Suggested fix

Read sess.organizationId at both sites, keeping #8707's precedence (the record's own organization still wins). The stronger half is a detector, since this class recurs: a pin that asserts an audit row written on an object with no organization column, under a session that HAS an active organization, lands with a non-null organization_id. That assertion fails today and is what would have caught the alias removal.

Worth deciding at triage whether the same removed alias is read anywhere else — git grep -rn "session.tenantId\|sess.tenantId" across the repo — since #3290 removed it repo-wide and any other reader would be dead the same way.

Not fixed in #8992's PR

Different defect class (write-side tenant stamping vs. the read seam), and the correct form needs a decision about the detector, so it does not meet the in-place bar. #8992's own read-audit writer reads session.organizationId and is unaffected.

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions