Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/tenant-audit-scope-by-object-classification.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/objectql": minor
---

fix(objectql): cut the tenant-audit control's scope by the OBJECT's tenancy, not the caller's `isSystem` flag (#13491)

Maintainer ruling, 2026-08-31 (联案 #13491 + #13497, verbatim「同意」). The batch #9
ruling — "`isSystem` writes are out of this control's scope" — was narrowed by its
own look-back clause, which #13497's measurement fired:

- **`isSystem` x a tenant-scoped object = IN scope.** A system write that lands an
org-less row on a tenant-scoped object is the defect class the control exists
for. It has occurred five times (#12745, #12928, #10673, #8617, cloud#1239 — one
a credentials table) and every instance was found by a person reading call
sites, never by the control.
- **`isSystem` x a genuinely global object = OUT of scope.** #8672's reasoning
("an org-less row is defensible for `sys_permission_set`") now inherits **per
object**; the wholesale `sys_` / `cloud_` / `ai_` namespace exemption is
withdrawn.

Two gates narrow by that one classification:

1. `resolveSystemInsertOrganization`'s blanket
`isPlatformNamespaceObject(object)` short-circuit, so an admitted platform
object reaches #8844's existing derive/refuse machinery (multi-organization
refusal branch included);
2. the engine's `bypassTenantAudit` `isSystem` mute, which used to silence EVERY
elevated write — the #13178 census measured 135 of 175 write call sites (77%)
silenced at it, the control's largest gate sitting ahead of the condition the
control is about.

**Why a hand-adjudicated ledger and not a schema read.** `applySystemFields`
provisions the `organization_id` COLUMN unconditionally — its existence was
deliberately decoupled from whether tenancy is on. Measured by AST census of every
`ObjectSchema.create` in `packages/`: of 84 platform-namespace objects, 59 carry a
tenant column, `sys_permission_set` among them. A schema read therefore replaces a
wholesale exemption with a wholesale inclusion. The ruling's source is "有列**且有
写手填**", and the writer half is a fact about the code, established once by
inventory (`packages/objectql/src/tenancy/platform-object-tenancy.ts`).

**Direction is one-way.** What this adds is refusals and warnings only; no write's
target or payload changes. An object the inventory could not adjudicate is
`unclassified`, which keeps today's behaviour exactly and goes back to the
maintainer on a list — so the blast radius equals the seven admitted objects
(`sys_file`, `sys_upload_session`, `sys_approval_request`, `sys_approval_action`,
`sys_approval_approver`, `sys_automation_run`, `sys_notification_delivery`), each
admitted on a citable writer fact.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10581` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10743` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9474` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10616` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10778` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9509` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1576` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9511`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5603` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3571`, `:3573`, `:3600` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9546`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5638` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3573`, `:3583`, `:3610` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6301` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11329` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11258` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6336` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11364` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11293` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them.

| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3403` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13670` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 62 | `objectql/src/engine.ts:3405` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13705` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9457`–`9474` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9492`–`9509` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1451` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:273` |
Expand Down
51 changes: 43 additions & 8 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,11 +128,13 @@ import { redactBoundStatement } from './driver-fault-redaction.js';
import {
resolveSystemWriteOrganization,
resolveTenantFieldName,
isPlatformNamespaceObject,
carriesOrganization,
SystemWriteOrganizationRequiredError,
ORGANIZATION_OBJECT,
} from './tenancy/system-write-organization.js';
// [#13491] The per-object tenancy inventory that replaced the blanket
// namespace exemption — the ONE reading both narrowed gates consult.
import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js';
import { resolveTenancyPosture } from '@objectstack/types';
import { normalizeTenancyPosture, type TenancyPosture } from '@objectstack/spec/security';

Expand DownExpand Up@@ -3569,6 +3571,14 @@ export class ObjectQL implements IObjectQLEngine {
!isFederated;
const hasTz = execCtx?.timezone !== undefined;
const isSystem = execCtx?.isSystem === true;
// [#13491] The object's tenancy classification — the ONE reading both the
// mute below and `resolveSystemInsertOrganization` narrow by. An object
// with no tenant field is out by construction; a platform object is out
// unless the inventory adjudicated it tenant-scoped.
const isTenantAuditInScope =
resolveTenantFieldName(objectSchema) !== null &&
!isFederated &&
!isPlatformObjectOutOfTenantAuditScope(object);
const preserveAudit = (execCtx as any)?.preserveAudit === true;
if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base;
const opts: any = base && typeof base === 'object' ? { ...base } : {};
Expand DownExpand Up@@ -3597,10 +3607,28 @@ export class ObjectQL implements IObjectQLEngine {
// (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly.
opts.timezone = execCtx!.timezone;
}
if (isSystem && opts.bypassTenantAudit === undefined) {
// System-elevated writes (boot-time seeds, internal mirrors, scheduled
// hooks) are unscoped by design — silence the audit warn for them but
// still flag genuine user-path bugs.
if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {
// [#13491] The isSystem mute NARROWED by the object's tenancy
// classification, in the same stroke as the guard above and by the same
// reading (2026-08-31 ruling, execution point 1).
//
// It used to mute EVERY elevated write, whatever the object was. The
// #13178 census measured what that cost: 135 of 175 write call sites
// (77%) were silenced here — at the control's LARGEST gate, sitting
// ahead of the condition the control is about — and the control has
// never produced a finding, while all five known instances of the defect
// were found by a person reading call sites.
//
// What still mutes: an object with no tenant field at all (nothing to be
// unscoped from — the driver would exit at `resolveTenantField` anyway),
// and a platform object the inventory classified `global` or has not
// adjudicated. What no longer mutes: a tenant-scoped object, platform
// namespace or not. A caller that means it still passes
// `bypassTenantAudit: true` explicitly and is honoured above — this
// branch only fills in a value nobody supplied.
//
// ⚠️ Direction, per execution point 3: what this ADDS is a driver WARN
// line. It changes nothing about what any write touches.
opts.bypassTenantAudit = true;
}
if (preserveAudit && opts.preserveAudit === undefined) {
Expand DownExpand Up@@ -3760,9 +3788,16 @@ export class ObjectQL implements IObjectQLEngine {
// system write that threaded one. Nothing to resolve; this is the shape the
// ruling asks every system write to reach.
if (carriesOrganization(execCtx?.tenantId)) return undefined;
// Platform namespaces stay global by design (#8672's reasoning, which the
// #8844 ruling confirms does not generalize to application objects).
if (isPlatformNamespaceObject(object)) return undefined;
// [#13491] Platform-namespace objects are excluded PER OBJECT, not
// wholesale. The 2026-08-31 ruling withdrew the blanket
// `isPlatformNamespaceObject(object)` exemption that used to stand here:
// #8672's "an org-less row is defensible for `sys_permission_set`"
// inherits per object, and the namespace also holds objects whose org-less
// rows are a defect — five instances of that class, all found by hand.
// `unclassified` keeps the old exclusion so an unadjudicated object's
// behaviour does not move; see `platform-object-tenancy.ts` for the
// inventory and why the classification cannot be read off the schema.
if (isPlatformObjectOutOfTenantAuditScope(object)) return undefined;
const objectSchema = this._registry.getObject(object) as any;
// A federated object's schema is the REMOTE's (ADR-0015); the platform's
// injected column says nothing about it, which is the same reason
Expand Down
13 changes: 13 additions & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -323,6 +323,19 @@ export {
GLOBAL_TENANT,
DEFAULT_TENANT_FIELD,
} from './tenancy/system-write-organization.js';

// [#13491] The per-object tenancy inventory for the platform namespaces — the
// 2026-08-31 ruling's replacement for the wholesale `sys_ / cloud_ / ai_`
// exemption. Exported so the inventory is readable (and testable) from outside
// the engine; the ledger itself is hand-adjudicated, never derived.
export {
PLATFORM_OBJECT_TENANCY,
classifyPlatformObjectTenancy,
isPlatformObjectOutOfTenantAuditScope,
tenantScopedPlatformObjects,
type PlatformObjectTenancy,
type PlatformObjectTenancyEntry,
} from './tenancy/platform-object-tenancy.js';
export type {
SystemWriteOrganizationDecision,
SystemWriteRefusalReason,
Expand Down
12 changes: 11 additions & 1 deletion packages/objectql/src/system-write-organization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,7 +104,16 @@ const DISPATCH_ORDER = {
},
} as any;

/** A platform-namespace object: its rows are deliberately org-less (#8672). */
/**
* A platform-namespace object the #13491 inventory did NOT adjudicate.
*
* ⚠️ Its exclusion below is `unclassified`, not `global`. Until the 2026-08-31
* ruling this file read it as "platform namespace ⇒ deliberately org-less
* (#8672)"; that wholesale reading was withdrawn, and what these cases now pin
* is that an UNADJUDICATED object's behaviour did not move — which is what
* bounds the reclassification's blast radius to the admitted list. The admitted
* and `global` sides are pinned in `tenancy-by-object-classification.test.ts`.
*/
const SYS_LEDGER = {
name: 'sys_audit_entry',
fields: { subject: { type: 'text' } },
Expand DownExpand Up@@ -329,6 +338,7 @@ describe('#8844 the exclusions — populations the refusal must not touch', () =
it.each(['isolated', 'single'] as const)(
'a platform-namespace object stays org-less on the %s posture',
async (posture) => {
// ⚠️ [#13491] Reads as an UNCLASSIFIED verdict now, not a namespace one.
// #8672 measured this primitive on `sys_permission_set` and filed it as an
// observation because an org-less row is defensible there. The #8844
// ruling confirms that reasoning holds for platform objects and does NOT
Expand Down
Loading
Loading