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:

const tenantId: 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:

const tenantId: 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions