diff --git a/.changeset/15207-audit-log-attribution-field.md b/.changeset/15207-audit-log-attribution-field.md new file mode 100644 index 00000000000..a3df8112aa6 --- /dev/null +++ b/.changeset/15207-audit-log-attribution-field.md @@ -0,0 +1,30 @@ +--- +'@objectstack/plugin-audit': minor +'@objectstack/plugin-security': minor +'@objectstack/service-settings': minor +'@objectstack/spec': minor +'@objectstack/objectql': patch +--- + +feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + +Clause-②: no (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + +Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + +- **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. +- **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. +- **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. +- **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. +- **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + +**What moves for consumers.** + +- **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. +- **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. +- **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. +- **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 665fb1c9f1d..2e8c39f4b5f 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -149,7 +149,9 @@ const READERS: Record = { }, 'lifecycle/lifecycle-service.ts#tenantWindowsFor :: organization_id': { disposition: 'skips', - why: 'the column the partition predicates name, asked about before any partition is built', + why: + 'the column the partition predicates name, asked about before any partition is built; the reap and ' + + 'archive passes name only the column this decision returns (#15207), so they hold no seam of their own', }, 'lifecycle/lifecycle-service.ts#tenantWindowsFor :: resolveInjectedColumnProvenance()': { disposition: 'skips', @@ -157,16 +159,6 @@ const READERS: Record = { "an object with no organization_id at all (provenance 'absent': no injection, no declaration), " + 'federated or local, has no tenant partition either', }, - 'lifecycle/lifecycle-service.ts#reap :: organization_id': { - disposition: 'skips', - via: 'tenantWindowsFor', - why: 'the per-tenant reap passes, built only from the windows the shared decision returns', - }, - 'lifecycle/lifecycle-service.ts#archiveObject :: organization_id': { - disposition: 'skips', - via: 'tenantWindowsFor', - why: 'the per-tenant archive passes, built only from the windows the shared decision returns', - }, // ── Readers that already asked whether the object is federated ────────── 'engine.ts#buildDriverOptions :: isFederatedObject()': { @@ -548,8 +540,6 @@ describe('[#21918] every engine reader of an injected column has a disposition t expect([...skipping].sort()).toEqual([ 'engine.ts#cascadeDeleteRelations', 'engine.ts#planCascadeAtomicity', - 'lifecycle/lifecycle-service.ts#archiveObject', - 'lifecycle/lifecycle-service.ts#reap', 'lifecycle/lifecycle-service.ts#tenantWindowsFor', ]); }); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts new file mode 100644 index 00000000000..5e245ea0760 --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15207] Per-tenant retention on the compliance ledger partitions on its + * attribution field. + * + * ADR-0131 D7 takes the injected `organization_id` off `sys_audit_log`: the + * organization a row is ABOUT stays in the plain attribution field + * `tenant_id`, and a row about a deployment-level action leaves it NULL. A + * tenant-scope `lifecycle.retention_overrides` entry naming the ledger must + * still give that tenant its own window, so the per-tenant passes select on + * `tenant_id`, and the global pass keeps everyone else, the NULL rows + * included. A column-less object answered no partition since the plumbing + * tables lost their column; without the attribution partition the ledger's + * tenant override would silently stop applying. + * + * Every case runs on a REAL `ObjectQL` engine and registry, so the object the + * sweep reads is the one the registry registered, after its system-field + * injection. The stub driver provisions each table from that registered + * object's fields and refuses a filter on a column the table lacks, as the SQL + * driver does, so a pass naming the retired column fails here as it would + * there. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from '../engine.js'; +import { LifecycleService } from './lifecycle-service.js'; +import { parseLifecycleDuration } from './duration.js'; + +const FIXED_NOW = 1_700_000_000_000; +const PACKAGE_ID = 'lifecycle-attribution-partition'; +const ATTRIBUTION = { tenant_id: { type: 'lookup', reference: 'sys_organization' } }; + +/** The ledger as ADR-0131 D7 declares it: no tenant column, the attribution field declared. */ +const LEDGER = { + name: 'sys_audit_log', + systemFields: { tenant: false }, + fields: { action: { type: 'text' }, ...ATTRIBUTION }, + lifecycle: { class: 'audit', retention: { maxAge: '90d' } }, +}; +/** The same ledger with the archiver declared, the shape it ships with. */ +const LEDGER_ARCHIVED = { + ...LEDGER, + lifecycle: { class: 'audit', retention: { maxAge: '90d' }, archive: { after: '90d', to: 'archive', keep: '7y' } }, +}; +/** CONTROL: the ledger without the attribution field — nothing to partition on. */ +const LEDGER_WITHOUT_FIELD = { ...LEDGER, fields: { action: { type: 'text' } } }; +/** CONTROL: a column-less object carrying a field of the same name — the name alone decides nothing. */ +const OTHER_WITH_FIELD = { + name: 'sys_job_run', + systemFields: { tenant: false }, + fields: { status: { type: 'text' }, ...ATTRIBUTION }, + lifecycle: { class: 'telemetry', retention: { maxAge: '90d' } }, +}; + +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } }; + +const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString(); + +/** One organization keeps its rows longer than the declared window. */ +function fakeSettings() { + const tenantValues: Record> = { + org_reg: { retention_overrides: { sys_audit_log: { maxAge: '365d' }, sys_job_run: { maxAge: '365d' } } }, + }; + return { + async get(_ns: string, key: string, ctx?: Record) { + const tenantId = ctx?.tenantId as string | undefined; + if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) { + return { value: tenantValues[tenantId][key], source: 'tenant' }; + } + return { value: undefined, source: 'default' }; + }, + }; +} + +/** Every column a filter names: the non-operator keys, at any depth of `$or` / `$and`. */ +function filteredColumns(where: unknown): string[] { + if (Array.isArray(where)) return where.flatMap(filteredColumns); + if (!where || typeof where !== 'object') return []; + return Object.entries(where as Record).flatMap(([key, value]) => + key.startsWith('$') ? filteredColumns(value) : [key], + ); +} + +async function lifecycleEngine(object: Record) { + const engine = new ObjectQL(); + const name = object.name as string; + /** The table's columns: the REGISTERED object's fields, as schema sync provisions them, plus the key. */ + const columnsOf = (table: string): Set => { + const registered = engine.registry.getObject(table) as { fields?: Record } | undefined; + return new Set(['id', ...Object.keys(registered?.fields ?? {})]); + }; + /** The `where` of every read the driver served for the swept table — the archiver reads the hot store directly. */ + const driverReads: unknown[] = []; + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(table: string, ast: { where?: unknown } | undefined) { + if (table === 'sys_organization') return [{ id: 'org_reg' }]; + const missing = filteredColumns(ast?.where).find((column) => !columnsOf(table).has(column)); + if (missing !== undefined) { + throw Object.assign( + new Error(`A filter on object '${table}' names a column the database could not resolve (${missing}).`), + { code: 'INVALID_FILTER', status: 400 }, + ); + } + if (table === name) driverReads.push(ast?.where); + return []; + }, + async findOne() { return null; }, + async count() { return 0; }, + async create(_t: string, data: Record) { return { id: 'r_1', ...data }; }, + async update(_t: string, id: string, data: Record) { return { id, ...data }; }, + async delete() { return true; }, + async bulkCreate(_t: string, rows: unknown[]) { return rows; }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async upsert(_t: string, row: Record) { return row; }, + async syncSchema() {}, + }; + const archive = { + ...driver, + name: 'archive', + async find() { return []; }, + async deleteMany() { return 0; }, + }; + + engine.registerDriver(driver as unknown as Parameters[0], true); + engine.registerDriver(archive as unknown as Parameters[0], false); + await engine.init(); + engine.registry.registerObject(object as unknown as Parameters[0], PACKAGE_ID); + engine.registry.registerObject(ORG_OBJECT as unknown as Parameters[0], PACKAGE_ID); + const find = vi.spyOn(engine, 'find'); + return { + engine, + driverReads, + /** The `where` of every candidate read the reaper issued through the engine, as it issued them. */ + reapReads: () => + find.mock.calls.filter((call) => call[0] === name).map((call) => (call[1] as { where?: unknown } | undefined)?.where), + }; +} + +function sweepOnce(engine: ObjectQL) { + return new LifecycleService({ + getEngine: () => engine, + logger: { info: () => {}, warn: () => {}, debug: () => {} }, + now: () => FIXED_NOW, + initialDelayMs: 1, + sweepIntervalMs: 10, + getSettings: () => fakeSettings(), + referenceAudit: { enabled: false }, + }).sweep(); +} + +/** The two passes a partitioned sweep issues: the tenant's own window, then everyone else's. */ +const partitioned = (column: string) => [ + { created_at: { $lt: isoCutoff('365d') }, [column]: 'org_reg' }, + { created_at: { $lt: isoCutoff('90d') }, $or: [{ [column]: { $nin: ['org_reg'] } }, { [column]: null }] }, +]; +const onePass = [{ created_at: { $lt: isoCutoff('90d') } }]; + +describe('LifecycleService.sweep — the ledger partitions per-tenant retention on its attribution field (#15207)', () => { + it('premise: the registered ledger has tenant_id and no organization_id', async () => { + const { engine } = await lifecycleEngine(LEDGER); + const fields = Object.keys((engine.registry.getObject('sys_audit_log') as { fields?: object })?.fields ?? {}); + expect(fields).toContain('tenant_id'); + expect(fields).not.toContain('organization_id'); + }); + + it('reap: the tenant gets its own window on the rows about it, and the global pass keeps the NULL rows', async () => { + const box = await lifecycleEngine(LEDGER); + const report = await sweepOnce(box.engine); + expect(report.errors).toEqual([]); + expect(box.reapReads()).toEqual(partitioned('tenant_id')); + }); + + it('archive: the archiver selects the same two partitions from the hot store', async () => { + const box = await lifecycleEngine(LEDGER_ARCHIVED); + const report = await sweepOnce(box.engine); + expect(report.errors).toEqual([]); + expect(box.driverReads).toEqual(partitioned('tenant_id')); + expect(report.swept.find((e) => e.object === 'sys_audit_log')?.policy).toBe('archive'); + }); + + it('CONTROL: the ledger without the attribution field runs one global pass, never a phantom partition', async () => { + const box = await lifecycleEngine(LEDGER_WITHOUT_FIELD); + const report = await sweepOnce(box.engine); + expect(report.errors).toEqual([]); + expect(box.reapReads()).toEqual(onePass); + }); + + it('CONTROL: another column-less object with a field of the same name runs one global pass', async () => { + const box = await lifecycleEngine(OTHER_WITH_FIELD); + const report = await sweepOnce(box.engine); + expect(report.errors).toEqual([]); + expect(box.reapReads()).toEqual(onePass); + }); + + it('CONTROL: the ledger WITH the injected column partitions on it, as every walled object does', async () => { + const { systemFields: _optOut, ...withColumn } = LEDGER; + const box = await lifecycleEngine(withColumn); + const report = await sweepOnce(box.engine); + expect(report.errors).toEqual([]); + expect(box.reapReads()).toEqual(partitioned('organization_id')); + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index f9a2aa01061..f0bbbeb9512 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -228,6 +228,25 @@ const DEFAULT_GOVERNANCE: GovernanceSnapshot = { /** Cap on tenants scanned for per-tenant overrides each sweep. */ const TENANT_SCAN_LIMIT = 200; +/** + * [#15207, ADR-0131 D7] Deployment-level ledgers whose rows name the + * organization they are ABOUT in a plain attribution field, never the tenancy + * anchor: the object carries no `organization_id`, so per-tenant retention + * windows (ADR-0057 §3.2) partition on this field instead. Honoured only where + * the author really declares the field ({@link LifecycleService} asks its + * provenance). `sys_audit_log` (plugin-audit) is the one such object: every + * writer stamps `tenant_id`, and a deployment-level row leaves it NULL. + */ +const ATTRIBUTION_PARTITION_COLUMNS: Readonly> = Object.freeze({ + sys_audit_log: 'tenant_id', +}); + +/** One object's per-tenant windows and the column they partition its rows on. */ +interface TenantPartition { + column: string; + windows: Array<{ tenantId: string; maxAge?: string; expireAfter?: string }>; +} + /** * [#5195] A **retention floor**: the shortest window a consumer's own contract * can survive on an object it does not own. @@ -1478,12 +1497,13 @@ export class LifecycleService { // // [#21918] Which tenants get a pass of their own is {@link tenantWindowsFor}, // the one decision `reap()` asks too. - const tenantWindows = this.tenantWindowsFor(obj, overrideKey); + const partition = this.tenantWindowsFor(obj, overrideKey); let archived = 0; - if (tenantWindows.length === 0) { + if (partition === null) { archived += await archivePass({ [dueField]: { $lt: cutoff } }); } else { - for (const t of tenantWindows) { + const { column, windows } = partition; + for (const t of windows) { // [#5195] A tenant-scoped override goes through the same floor — the // identical side door, one scope down. Its fallback is the // already-resolved global window, which has itself passed the floor. @@ -1496,13 +1516,13 @@ export class LifecycleService { report, ); const tCutoff = new Date(this.now() - tMs).toISOString(); - archived += await archivePass({ [dueField]: { $lt: tCutoff }, organization_id: t.tenantId }); + archived += await archivePass({ [dueField]: { $lt: tCutoff }, [column]: t.tenantId }); } archived += await archivePass({ [dueField]: { $lt: cutoff }, $or: [ - { organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } }, - { organization_id: null }, + { [column]: { $nin: windows.map((t) => t.tenantId) } }, + { [column]: null }, ], }); } @@ -1547,7 +1567,7 @@ export class LifecycleService { * both ask, so the two cannot partition the same object differently. * * A partition is a predicate on the row's `organization_id`, the column the - * passes below name. On a federated (ADR-0015 `external`) object that column + * passes name by default. On a federated (ADR-0015 `external`) object that column * is the registry's injection and the remote does not provision it * ({@link isFederatedUnprovisionedInjectedColumn}, which reads the #7865 * provenance), so every partitioned pass was refused by the driver as an @@ -1566,16 +1586,34 @@ export class LifecycleService { * plan honours) and the author declared none, so the provenance answers * `'absent'`. Its table has no such column, so a partitioned pass is the * same unknown-column refusal, and no row of it belongs to an organization. + * + * [ADR-0131 D7] …unless it is a deployment-level ledger whose rows name the + * organization they are ABOUT in an attribution field the author declared + * ({@link ATTRIBUTION_PARTITION_COLUMNS}): that field is then the partition, + * and a tenant's override selects the rows about that tenant. The global + * pass's NULL arm keeps the rows about no organization in the global window. + * + * Answers `null` when there is no partition: no override, or no column. */ private tenantWindowsFor( obj: LifecycleObjectLike, overrideKey: 'maxAge' | 'expireAfter', - ): Array<{ tenantId: string; maxAge?: string; expireAfter?: string }> { - if (isFederatedUnprovisionedInjectedColumn(obj, 'organization_id')) return []; - if (resolveInjectedColumnProvenance(obj, 'organization_id') === 'absent') return []; - return (this.governance.tenantOverrides.get(obj.name) ?? []).filter( + ): TenantPartition | null { + const windows = (this.governance.tenantOverrides.get(obj.name) ?? []).filter( (t) => typeof t[overrideKey] === 'string', ); + if (windows.length === 0) return null; + if ( + !isFederatedUnprovisionedInjectedColumn(obj, 'organization_id') + && resolveInjectedColumnProvenance(obj, 'organization_id') !== 'absent' + ) { + return { column: 'organization_id', windows }; + } + const attribution = ATTRIBUTION_PARTITION_COLUMNS[obj.name]; + if (attribution !== undefined && resolveInjectedColumnProvenance(obj, attribution) === 'author') { + return { column: attribution, windows }; + } + return null; } private async reap( @@ -1591,7 +1629,7 @@ export class LifecycleService { const object = obj.name; const cutoff = new Date(this.now() - windowMs).toISOString(); const overrideKey = policy === 'ttl' ? 'expireAfter' : 'maxAge'; - const tenantWindows = this.tenantWindowsFor(obj, overrideKey); + const partition = this.tenantWindowsFor(obj, overrideKey); // `retention.onlyWhen` / `ttl.onlyWhen` [commit 801296050] narrow every delete to // the declared row filter — rows outside it (live workflow state, audit // tombstones) are retained regardless of age/expiry. @@ -1630,12 +1668,13 @@ export class LifecycleService { ? this.batchedReap(engine, object, guards, where) : countDeleted(await engine.delete(object, { where, multi: true, context: { ...SYSTEM_CTX } })); - if (tenantWindows.length === 0) { + if (partition === null) { accumulate(await reapWhere({ [field]: { $lt: cutoff }, ...scope })); } else { // Tenant-level windows (P4): each overriding tenant gets its own // cutoff on its own rows… - for (const t of tenantWindows) { + const { column, windows } = partition; + for (const t of windows) { // [#5195] Tenant-scoped overrides go through the same floor: a // per-tenant `maxAge: '1h'` is the identical side door, one scope down. const tMs = this.effectiveWindowMs( @@ -1647,7 +1686,7 @@ export class LifecycleService { report, ); const tCutoff = new Date(this.now() - tMs).toISOString(); - accumulate(await reapWhere({ [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope })); + accumulate(await reapWhere({ [field]: { $lt: tCutoff }, [column]: t.tenantId, ...scope })); } // …and the global pass covers everyone else, INCLUDING rows with no // organization (a bare `$nin` would silently skip NULL-org rows). @@ -1655,8 +1694,8 @@ export class LifecycleService { await reapWhere({ [field]: { $lt: cutoff }, $or: [ - { organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } }, - { organization_id: null }, + { [column]: { $nin: windows.map((t) => t.tenantId) } }, + { [column]: null }, ], ...scope, }), diff --git a/packages/plugins/plugin-audit/README.md b/packages/plugins/plugin-audit/README.md index 5fbeee5ff96..bc5f0dbe98b 100644 --- a/packages/plugins/plugin-audit/README.md +++ b/packages/plugins/plugin-audit/README.md @@ -101,7 +101,7 @@ written only by internal system hooks running under `sudo()`, never through UI f | `new_value` | textarea | JSON-serialized new state | | `ip_address` | text | Auth events only — see below | | `user_agent` | textarea | Auth events only — see below | -| `tenant_id` | lookup → `sys_organization` | Tenant context for multi-tenant isolation | +| `tenant_id` | lookup → `sys_organization` | Organization the event is about (the attribution field, ADR-0131 D7); empty for a deployment-level action | | `metadata` | textarea | JSON-serialized additional context | **Secret masking.** `old_value` / `new_value` are written through a ledger view that masks diff --git a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts index 3df8d74fe05..dbda97d3e2d 100644 --- a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts +++ b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts @@ -18,7 +18,7 @@ * Every door onto the ledger reads it through the engine — the Setup "Audit * Logs" object view, the console's audit-log browser and a record page's * history tab all list `sys_audit_log` through the generic data API — under the - * LEDGER's own object grant and tenant wall. A reader whose sets grant the + * LEDGER's own object grant and organization row scope. A reader whose sets grant the * ledger read was therefore served every snapshot key, including a parent field * the data plane serves the same reader masked, gated off by * `requiredPermissions`, or not at all because a set it holds withholds it. diff --git a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts index d5293ba1958..5fbe4840b72 100644 --- a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts +++ b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts @@ -9,9 +9,10 @@ * Every door onto the compliance ledger reads it through the engine — the * Setup "Audit Logs" object view, the console's audit-log browser and a record * page's history tab all list `sys_audit_log` through the generic data API — - * under the LEDGER's own object grant and tenant wall. The ledger has no owner - * column and its parent is a different object on every row, so neither - * OWD/sharing nor RLS narrows it: a reader whose sets grant the ledger read was + * under the LEDGER's own object grant and organization row scope (`tenant_id`, + * ADR-0131 D7). The ledger has no owner column and its parent is a different + * object on every row, so neither OWD/sharing nor RLS narrows it to the record: + * a reader whose sets grant the ledger read was * served the rows about a record the data plane answers it `404` for — the * record's create, update and delete rows, whose snapshots keep every field the * field-level redaction (`audit-log-field-redaction.ts`) does not withhold. diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 721593c0a64..77f162c882e 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -91,7 +91,10 @@ const SINGLE_TENANT = { }; const MULTI_TENANT = { - sys_audit_log: [...SINGLE_TENANT.sys_audit_log, 'organization_id'], + // [ADR-0131 D7] The ledger has no organization column on ANY posture + // (`systemFields: { tenant: false }`): the organization a row is about is + // its attribution field `tenant_id`. The activity stream keeps the column. + sys_audit_log: SINGLE_TENANT.sys_audit_log, sys_activity: [...SINGLE_TENANT.sys_activity, 'organization_id'], }; @@ -118,7 +121,7 @@ describe('audit writers — organization_id stamping (#1532)', () => { expect('tenant_id' in audit!.row).toBe(true); }); - it('stamps organization_id on multi-tenant tables when the column exists', async () => { + it('stamps the activity row\'s organization_id, and the ledger row\'s organization into tenant_id alone (ADR-0131 D7)', async () => { const { engine, fire, created } = makeEngine(MULTI_TENANT); installAuditWriters(engine as any, 'test.audit'); @@ -131,7 +134,8 @@ describe('audit writers — organization_id stamping (#1532)', () => { const audit = created.find((c) => c.object === 'sys_audit_log'); const activity = created.find((c) => c.object === 'sys_activity'); - expect(audit?.row.organization_id).toBe('org-9'); + expect(audit?.row.tenant_id).toBe('org-9'); + expect('organization_id' in audit!.row).toBe(false); expect(activity?.row.organization_id).toBe('org-9'); }); }); @@ -1686,8 +1690,8 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 }); const { audit, activity } = stampOf(created); - expect(audit?.organization_id).toBe('org-A'); expect(audit?.tenant_id).toBe('org-A'); + expect('organization_id' in (audit ?? {})).toBe(false); // The activity mirror is read through the same wall and must agree. expect(activity?.organization_id).toBe('org-A'); }); @@ -1707,7 +1711,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-A'); + expect(stampOf(created).audit?.tenant_id).toBe('org-A'); }); it('stamps the record\'s organization on update too', async () => { @@ -1723,7 +1727,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-A'); + expect(stampOf(created).audit?.tenant_id).toBe('org-A'); }); it('agrees with the session on the ordinary write, where both name one org', async () => { @@ -1742,7 +1746,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-A', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-A'); + expect(stampOf(created).audit?.tenant_id).toBe('org-A'); }); // ── the RLS fallback the flip must not weaken ────────────────────────── @@ -1761,7 +1765,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-B'); + expect(stampOf(created).audit?.tenant_id).toBe('org-B'); }); it('falls back to the session tenant when the object has no organization column', async () => { @@ -1776,7 +1780,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-B'); + expect(stampOf(created).audit?.tenant_id).toBe('org-B'); }); it('still uses the record\'s organization when the session carries no tenant', async () => { @@ -1795,7 +1799,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: {}, }); - expect(stampOf(created).audit?.organization_id).toBe('org-A'); + expect(stampOf(created).audit?.tenant_id).toBe('org-A'); }); // ── which column carries the organization ────────────────────────────── @@ -1819,7 +1823,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-B'); + expect(stampOf(created).audit?.tenant_id).toBe('org-B'); }); it('honours a declared `tenancy.tenantField`, and only when the field exists', async () => { @@ -1835,7 +1839,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 result: { id: 'lead-1', name: 'Acme', workspace_id: 'ws-1' }, session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('ws-1'); + expect(stampOf(created).audit?.tenant_id).toBe('ws-1'); // A declared name pointing at a column the object does not have falls // through to the canonical one — the same guard `computeTenantField` @@ -1851,7 +1855,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 result: { id: 'lead-1', name: 'Acme', organization_id: 'org-A' }, session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(stampOf(missing.created).audit?.organization_id).toBe('org-A'); + expect(stampOf(missing.created).audit?.tenant_id).toBe('org-A'); }); it('⛔ never reads a `sys_organization` lookup that is not the tenant column', async () => { @@ -1878,8 +1882,8 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-self', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-self'); - expect(stampOf(created).audit?.organization_id).not.toBe('org-parent'); + expect(stampOf(created).audit?.tenant_id).toBe('org-self'); + expect(stampOf(created).audit?.tenant_id).not.toBe('org-parent'); }); // ── the platform stamp column — `sys_api_key` (commit 7901b2dd2, #19054) ── @@ -1924,7 +1928,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-key'); + expect(stampOf(created).audit?.tenant_id).toBe('org-key'); }); it('honours the platform stamp column only when the field exists (#5315 guard), falling through intact', async () => { @@ -1949,7 +1953,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-actor'); + expect(stampOf(created).audit?.tenant_id).toBe('org-actor'); }); it('⛔ the stamp table is a CLOSED SET: an application object stamps from its own wall (#19054)', async () => { @@ -1978,7 +1982,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('ws-1'); + expect(stampOf(created).audit?.tenant_id).toBe('ws-1'); }); it('control: the stamp follows the OBJECT, not a declaration — a tenancy block is no longer part of it (#19054)', async () => { @@ -2007,7 +2011,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-key'); + expect(stampOf(created).audit?.tenant_id).toBe('org-key'); // The column still has to EXIST — the #5315 guard is the half that did not // move. Without it the credential table falls through to the actor's org, @@ -2021,7 +2025,7 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 result: { id: 'key-2', name: 'ci', revoked: true }, session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(bare.created).audit?.organization_id).toBe('org-actor'); + expect(stampOf(bare.created).audit?.tenant_id).toBe('org-actor'); }); }); @@ -2081,8 +2085,7 @@ describe('audit writers — the writer reads the session key the engine emits (# const audit = created.find((c) => c.object === 'sys_audit_log')?.row; // The assertion the card is about, stated as the consequence rather than // the mechanism: a null here is the permanently-invisible ledger row. - expect(audit?.organization_id).not.toBeNull(); - expect(audit?.organization_id).toBe('org-B'); + expect(audit?.tenant_id).not.toBeNull(); expect(audit?.tenant_id).toBe('org-B'); // The activity mirror is read through the same wall and must agree. expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBe('org-B'); @@ -2103,8 +2106,8 @@ describe('audit writers — the writer reads the session key the engine emits (# }); const audit = created.find((c) => c.object === 'sys_audit_log')?.row; - expect(audit?.organization_id).not.toBeNull(); - expect(audit?.organization_id).toBe('org-B'); + expect(audit?.tenant_id).not.toBeNull(); + expect(audit?.tenant_id).toBe('org-B'); }); it('keeps #8707\'s precedence — the record\'s own organization still wins', async () => { @@ -2124,7 +2127,7 @@ describe('audit writers — the writer reads the session key the engine emits (# session: { organizationId: 'org-B', userId: 'user-1' }, }); - expect(created.find((c) => c.object === 'sys_audit_log')?.row.organization_id).toBe('org-A'); + expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBe('org-A'); }); it('⛔ does not resolve the v16-removed `tenantId` alias', async () => { @@ -2148,7 +2151,7 @@ describe('audit writers — the writer reads the session key the engine emits (# session: { tenantId: 'org-B', userId: 'user-1' } as any, }); - expect(created.find((c) => c.object === 'sys_audit_log')?.row.organization_id).toBeNull(); + expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBeNull(); }); // ── the second site: the @mention notification scope ────────────────── diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 47497d1d8ee..facf776b723 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -1636,9 +1636,10 @@ export function installAuditWriters( // flip this back to `sess.organizationId ?? recordOrgId`. // // An audit row describes a change to a RECORD, and it is read through - // `sys_audit_log`'s own tenant wall. Stamped with the ACTOR's active - // organization, a row about an org-A record written by someone whose active - // org is B lands behind B's wall: the tenant admin of A — the one party the + // `sys_audit_log`'s own organization row scope (`tenant_id`, ADR-0131 D7). + // Stamped with the ACTOR's active organization, a row about an org-A record + // written by someone whose active org is B lands in B's scope: the tenant + // admin of A — the one party the // row concerns and the only one who can act on it — cannot see it, while B, // which has no claim to the record, can. That is the same // invisible-audit-row defect the fallback below was added for, one layer @@ -1647,9 +1648,10 @@ export function installAuditWriters( // // The fallback's ORIGINAL rationale is unchanged and still load-bearing — // flipping the order strengthens it rather than competing with it. 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. The session tenant still + // rows about an organization's record must never be written with a NULL + // `tenant_id`, or the ledger's row policy hides them from that + // organization's readers and the audit log UI reads empty while writes + // succeed. The session tenant still // answers whenever the record has no organization of its own: // 1. objects with no organization column at all (single-tenant stacks, // and ADR-0066 platform-global objects — see @@ -1714,23 +1716,15 @@ export function installAuditWriters( record_id: recordId ?? null, old_value: oldValue ? safeStringify(oldValue) : null, new_value: newValue ? safeStringify(newValue) : null, - // `tenant_id` is the schema-declared "tenant context" lookup. + // [ADR-0131 D7] The organization this row is ABOUT — the ledger's plain + // attribution field and its ONLY organization column (`systemFields: + // { tenant: false }`, so no `organization_id` is injected). The + // `sys_audit_log_org` row policy scopes organization readers on it, so + // an unstamped row is served under a wall to platform admins only. tenant_id: tenantId ?? null, }; - // The platform-default `organization_id` column is what RLS gates on - // (`organization_id = current_user.organization_id`). The audit writer - // runs through `api.sudo()` which bypasses the SecurityPlugin's - // auto-stamping of `organization_id`, so we stamp it explicitly here — - // without it, non-admin members would see 0 rows on Setup dashboards - // because RLS would deny every audit row as wrong-tenant. But the column - // only exists in multi-tenant deployments (the SchemaRegistry auto-injects - // it conditionally); stamping it on a single-tenant table that lacks the - // column made every audit INSERT fail. Only stamp it when declared. - if (objectHasField('sys_audit_log', 'organization_id')) { - auditRow.organization_id = tenantId ?? null; - } - // First-class principal label (ADR-0014 D2). Conditionally stamped — same - // rationale as organization_id: older audit tables predate the column. + // First-class principal label (ADR-0014 D2). Conditionally stamped: older + // audit tables predate the column. if (objectHasField('sys_audit_log', 'actor')) { auditRow.actor = actorLabel; } diff --git a/packages/plugins/plugin-audit/src/auth-event-audit.test.ts b/packages/plugins/plugin-audit/src/auth-event-audit.test.ts index 4677f5caa1a..c9b89a1c9d4 100644 --- a/packages/plugins/plugin-audit/src/auth-event-audit.test.ts +++ b/packages/plugins/plugin-audit/src/auth-event-audit.test.ts @@ -29,10 +29,15 @@ import { createAuthEventAuditSink } from './auth-event-audit.js'; const OWNER_PACKAGE = 'com.objectstack.test.auth-event-audit'; -/** The ledger, in the shape a MULTI-tenant stack registers it. */ +/** + * The ledger, in the shape a MULTI-tenant stack registers it — which since + * ADR-0131 D7 is the shape every stack registers: no organization column + * (`systemFields: { tenant: false }`); `tenant_id` is the attribution field. + */ const sysAuditLogMultiTenant = { name: 'sys_audit_log', label: 'Audit Log', + systemFields: { tenant: false }, fields: { id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, action: { name: 'action', label: 'Action', type: 'text' as const }, @@ -46,36 +51,22 @@ const sysAuditLogMultiTenant = { user_agent: { name: 'user_agent', label: 'UA', type: 'textarea' as const }, tenant_id: { name: 'tenant_id', label: 'Tenant', type: 'text' as const }, metadata: { name: 'metadata', label: 'Metadata', type: 'textarea' as const }, - organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, }, }; /** - * An OLDER ledger table: no `actor` column, and none declared for - * `organization_id` either. - * - * Both columns are stamped conditionally by the writer, for the same reason and - * with different fates today — worth stating because the fixture looks - * redundant otherwise: + * An OLDER ledger table: no `actor` column. * - * - `organization_id` comes BACK. `applySystemFields` provisions the tenant - * column unconditionally now (only `systemFields: false` / `managedBy: - * 'better-auth'` / `tenancy.enabled: false` opt out) — precisely so "sudo - * writers (audit / messaging / inbox / outbox …)" stop failing with "no - * column named organization_id" on single-tenant stacks. So this fixture - * measures that the probe reads the REGISTERED schema (post-injection), - * not the document an author wrote. - * - `actor` does NOT. It is a plain declared field that older audit tables - * predate, so it is the live half of the conditional stamp: writing it into - * a table that lacks it fails the INSERT, and the writer swallows — audit - * logging would just stop, with nothing in the response to show it. + * `actor` is a plain declared field that older audit tables predate, so it is + * the conditionally stamped column: writing it into a table that lacks it + * fails the INSERT, and the writer swallows — audit logging would just stop, + * with nothing in the response to show it. (`organization_id` is no longer + * stamped at all: the ledger has no organization column, ADR-0131 D7.) */ const sysAuditLogNoActor = { ...sysAuditLogMultiTenant, fields: Object.fromEntries( - Object.entries(sysAuditLogMultiTenant.fields).filter( - ([k]) => k !== 'organization_id' && k !== 'actor', - ), + Object.entries(sysAuditLogMultiTenant.fields).filter(([k]) => k !== 'actor'), ), }; @@ -206,7 +197,8 @@ describe('[#8144] createAuthEventAuditSink writes a login row that names its act expect(row.action).toBe('login'); expect(row.user_id).toBe('usr_1'); expect(row.tenant_id).toBe('org_1'); - expect(row.organization_id).toBe('org_1'); + // [ADR-0131 D7] The attribution field is the row's only organization column. + expect(row.organization_id).toBeUndefined(); // ADR-0014 D2's principal label — the subject acted for themselves here. expect(row.actor).toBe('usr_1'); // Navigable back to the session it is about. @@ -274,11 +266,8 @@ describe('[#8144] createAuthEventAuditSink writes a login row that names its act const rows = await ledgerRows(engine, { action: 'login' }); expect(rows).toHaveLength(1); expect(rows[0].user_id).toBe('usr_1'); - // The declared lookup carries the tenant either way… + // The declared lookup carries the organization the event is about. expect(rows[0].tenant_id).toBe('org_1'); - // …and the probe reads the REGISTERED schema, so the unconditionally - // provisioned tenant column is stamped even though the fixture omits it. - expect(rows[0].organization_id).toBe('org_1'); // The one column that really is absent is left alone. expect(rows[0].actor).toBeUndefined(); }); diff --git a/packages/plugins/plugin-audit/src/auth-event-audit.ts b/packages/plugins/plugin-audit/src/auth-event-audit.ts index ba07853195c..f136d1944e8 100644 --- a/packages/plugins/plugin-audit/src/auth-event-audit.ts +++ b/packages/plugins/plugin-audit/src/auth-event-audit.ts @@ -304,16 +304,13 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE new_value: null, ip_address: event.ipAddress ?? null, user_agent: event.userAgent ?? null, + // [ADR-0131 D7] The ledger's attribution field, and the column its + // `sys_audit_log_org` row policy scopes organization readers on. tenant_id: tenantId, metadata: event.context && Object.keys(event.context).length > 0 ? safeStringify(event.context) : null, }; - // Both columns are conditionally present — see `createFieldPresenceProbe`. - // `organization_id` is what the SecurityPlugin's RLS predicate gates on, - // so an unstamped row is a row non-admin members can never see. - if (objectHasField('sys_audit_log', 'organization_id')) { - row.organization_id = tenantId; - } + // `actor` is conditionally present — see `createFieldPresenceProbe`. if (objectHasField('sys_audit_log', 'actor')) { // ADR-0014 D2's principal label. For an ordinary sign-in the subject IS // the principal; for an impersonation session the admin who started it diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-attribution.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-attribution.test.ts new file mode 100644 index 00000000000..b6bbecbe224 --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-attribution.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7] The compliance ledger carries no organization column; the + * organization a row is ABOUT is the plain attribution field `tenant_id`. + * + * ## Why the column is asserted through the injection plan AND the table + * + * The tenant column is INJECTED at registration, never authored, so asserting + * on `fields` alone is a phantom check. `resolveInjectedSystemColumns` is the + * derivation `applySystemFields` consumes, so it decides whether the column is + * registered; the provisioned table, introspected after a real schema sync, is + * what the DDL produced. Each has a control: the same declaration without the + * opt-out gets the column. + * + * ## What the read scope rests on, pinned here for plugin-security + * + * plugin-security's `sys-audit-log-row-scope.test.ts` measures the read scope + * over a stand-in carrying three declarations of this object, because that + * package does not depend on this one. Those three are pinned below against + * the shipped declaration: the name, `systemFields: { tenant: false }`, and + * `tenant_id` as a lookup to the organization object. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin, resolveTenantFieldName } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { AuditPlugin } from '../audit-plugin.js'; +import { SysAuditLog } from './sys-audit-log.object.js'; + +const LEDGER = 'sys_audit_log'; +const SYS = { context: { isSystem: true } } as const; + +describe('[ADR-0131 D7] sys_audit_log declaration: no organization column, tenant_id is the attribution field', () => { + it('opts out of the tenant column through `systemFields.tenant`, not the platform-global posture', () => { + expect(SysAuditLog.name).toBe(LEDGER); + expect((SysAuditLog as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((SysAuditLog as { tenancy?: unknown }).tenancy).toBeUndefined(); + }); + + it('the injection plan carries no organization column; CONTROL: without the opt-out it does', () => { + const plan = resolveInjectedSystemColumns(SysAuditLog); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + // Anti-vacuity: the plan still injects the identity and audit columns. + expect([...plan.names]).toEqual(expect.arrayContaining(['id', 'created_at'])); + const { systemFields: _optOut, ...withoutOptOut } = SysAuditLog as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + expect([...resolveInjectedSystemColumns(withoutOptOut).names]).toContain('organization_id'); + }); + + it('tenant_id stays a lookup to the organization object, and the tenant-field resolver does not claim it', () => { + const field = (SysAuditLog.fields as Record).tenant_id; + expect(field?.type).toBe('lookup'); + expect(field?.reference).toBe('sys_organization'); + expect(Object.keys(SysAuditLog.fields ?? {})).not.toContain('organization_id'); + expect(resolveTenantFieldName(SysAuditLog)).toBeNull(); + }); +}); + +describe('[ADR-0131 D7] sys_audit_log on a real engine: the table, the write, a real writer', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + let driver: SqliteWasmDriver; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + engine = kernel.getService('objectql'); + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject( + { name: 'att_note', label: 'Note', fields: { title: { name: 'title', label: 'Title', type: 'text' } } } as any, + 'com.objectstack.audit.test.attribution', + ); + await engine.syncSchemas(); + }); + + afterAll(async () => { + await kernel?.shutdown?.(); + }); + + const columnsOf = async (table: string): Promise => { + const schema = await driver.introspectSchema(); + return (schema.tables[table]?.columns ?? []).map((c: { name: string }) => c.name); + }; + + it('the registered object and the provisioned table carry tenant_id and no organization_id', async () => { + expect(Object.keys((engine.getSchema(LEDGER) as { fields?: object })?.fields ?? {})).not.toContain('organization_id'); + const columns = await columnsOf(LEDGER); + expect(columns).toContain('tenant_id'); + expect(columns).not.toContain('organization_id'); + // CONTROL: an ordinary object on the same sync is provisioned WITH the column. + expect(await columnsOf('att_note')).toContain('organization_id'); + }); + + it('a row about a deployment-level action is written with no organization and no refusal', async () => { + const row = await engine.insert(LEDGER, { + action: 'platform_admin_standing_change', + user_id: null, + object_name: 'sys_user', + record_id: null, + tenant_id: null, + new_value: '[]', + }, SYS as never) as { id: string }; + const stored = await engine.findOne(LEDGER, { where: { id: row.id }, ...SYS } as never) as Record | null; + expect(stored?.action).toBe('platform_admin_standing_change'); + expect(stored?.tenant_id ?? null).toBeNull(); + }); + + it('a write still naming the retired column is refused loudly, never stored silently', async () => { + const refusal = await engine + .insert(LEDGER, { action: 'config_change', tenant_id: null, organization_id: 'org_1' }, SYS as never) + .then(() => undefined, (error: unknown) => error as { code?: unknown; status?: unknown }); + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.status).toBe(400); + }); + + it('a read naming the retired column is refused, not answered as an empty set', async () => { + const refusal = await engine + .find(LEDGER, { where: { organization_id: 'org_1' }, ...SYS } as never) + .then(() => undefined, (error: unknown) => error as { code?: unknown; status?: unknown }); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + }); + + it('the record mirror stamps the organization a row is about into tenant_id', async () => { + await engine.insert('att_note', { title: 'hello' }, { context: { userId: 'usr_1', tenantId: 'org_1', positions: [] } } as never); + const rows = await engine.find(LEDGER, { where: { object_name: 'att_note' }, ...SYS } as never) as Array>; + expect(rows).toHaveLength(1); + expect(rows[0].action).toBe('create'); + expect(rows[0].tenant_id).toBe('org_1'); + expect(Object.keys(rows[0])).not.toContain('organization_id'); + }); +}); diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 118f2b636eb..fce54f2eb67 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -20,6 +20,15 @@ export const SysAuditLog = ObjectSchema.create({ icon: 'scroll-text', isSystem: true, managedBy: 'append-only', + // [ADR-0131 D7] Deployment-level ledger: NO injected organization column. + // Some rows are about actions no organization owns (`config_change` on the + // settings global rung, `platform_admin_standing_change` at boot, + // plugin-auth's run-level `import`), so the tenancy anchor cannot be this + // object's. The organization a row is ABOUT is the plain attribution field + // `tenant_id` below, which the tenant-field resolver does not claim: Layer 0 + // is inert here, and the read scope is object permission plus the platform + // row policy `sys_audit_log_org` in plugin-security's default sets. + systemFields: { tenant: false }, // ADR-0057: compliance ledger — retain hot 90d, then archive-then-delete. // The LifecycleService NEVER hot-deletes rows with `archive` declared until // the archive copy succeeded; deployments without an 'archive' datasource @@ -347,11 +356,15 @@ export const SysAuditLog = ObjectSchema.create({ }), // ── Context ────────────────────────────────────────────────── + // [ADR-0131 D7] The attribution field, never the tenancy anchor: every + // writer stamps the organization the row is about here, and NULL means a + // deployment-level action. Organization readers are scoped on it by the + // `sys_audit_log_org` row policy; per-tenant retention partitions on it. tenant_id: Field.lookup('sys_organization', { label: 'Tenant', required: false, readonly: true, - description: 'Tenant context for multi-tenant isolation', + description: 'Organization this event is about; empty for a deployment-level action', group: 'Context', }), diff --git a/packages/plugins/plugin-audit/src/read-audit.test.ts b/packages/plugins/plugin-audit/src/read-audit.test.ts index 2603b9cabd1..7bf47b18f14 100644 --- a/packages/plugins/plugin-audit/src/read-audit.test.ts +++ b/packages/plugins/plugin-audit/src/read-audit.test.ts @@ -142,10 +142,14 @@ const invoiceObject = { label: 'Invoice', }; -/** The ledger, declared with the columns the writer conditionally stamps. */ +/** + * The ledger, declared with the column the writer conditionally stamps + * (`actor`) and, like the shipped object, no organization column (ADR-0131 D7). + */ const auditLogObject = { name: 'sys_audit_log', label: 'Audit Log', + systemFields: { tenant: false }, fields: { id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, created_at: { name: 'created_at', label: 'At', type: 'datetime' as const }, @@ -157,7 +161,6 @@ const auditLogObject = { old_value: { name: 'old_value', label: 'Old', type: 'textarea' as const }, new_value: { name: 'new_value', label: 'New', type: 'textarea' as const }, tenant_id: { name: 'tenant_id', label: 'Tenant', type: 'text' as const }, - organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, }, }; @@ -489,7 +492,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { it("the row is stamped with the RECORD's organization, not just the viewer's", async () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; // #8287's ruling, carried onto the read row: stamped with the VIEWER's - // active org, a row about an org_a record would land behind org_b's wall — + // active org, a row about an org_a record would land in org_b's scope — // invisible to the one tenant admin it concerns. await engine.findOne( 'contact', @@ -499,7 +502,8 @@ describe('#8992 who the row names — and who it deliberately does not', () => { const rows = await ledgerRows(engine); expect(rows[0].tenant_id).toBe('org_a'); - expect(rows[0].organization_id).toBe('org_a'); + // [ADR-0131 D7] The attribution field is the row's only organization column. + expect(rows[0].organization_id).toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-audit/src/read-audit.ts b/packages/plugins/plugin-audit/src/read-audit.ts index fe7789f78ce..1cb3795dedf 100644 --- a/packages/plugins/plugin-audit/src/read-audit.ts +++ b/packages/plugins/plugin-audit/src/read-audit.ts @@ -646,14 +646,11 @@ export function installReadAuditWriter( // `{}` would be a claim about a record that never changed. old_value: null, new_value: null, + // [ADR-0131 D7] The ledger's attribution field, and the column its + // `sys_audit_log_org` row policy scopes organization readers on. tenant_id: tenantId, }; - // Both columns are conditionally present — see `createFieldPresenceProbe`. - // `organization_id` is what the SecurityPlugin's RLS predicate gates on, so - // an unstamped row is a row non-admin members can never see. - if (objectHasField('sys_audit_log', 'organization_id')) { - row.organization_id = tenantId; - } + // `actor` is conditionally present — see `createFieldPresenceProbe`. if (objectHasField('sys_audit_log', 'actor')) { row.actor = event.actor ?? event.userId ?? null; } @@ -740,14 +737,15 @@ export function installReadAuditWriter( * Deliberately the same precedence the CRUD writer settled on under the #8287 * ruling: the audited RECORD'S organization wins, the acting session's active * organization is the fallback. An audit row is read through `sys_audit_log`'s - * own tenant wall, so a row about an org-A record stamped with the viewer's - * active org B lands behind B's wall — invisible to the one tenant admin the - * row concerns. + * own organization row scope, so a row about an org-A record stamped with the + * viewer's active org B lands in B's scope — invisible to the one tenant admin + * the row concerns. * * Read straight off the returned record rather than through * `resolveRecordOrganizationField`: a read result is the materialized row, so - * the column is either on it or it is not, and the platform-default - * `organization_id` is the only spelling `sys_audit_log`'s own wall gates on. + * the column is either on it or it is not. What is read is the audited + * record's `organization_id`; it lands in the ledger's attribution field + * `tenant_id`, which the ledger's row policy scopes on (ADR-0131 D7). */ function readRecordOrganization(record: unknown): string | undefined { if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined; diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 305f981652b..0a2970b0702 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -70,7 +70,7 @@ export const enObjects: NonNullable = { }, tenant_id: { label: "Tenant", - help: "Tenant context for multi-tenant isolation" + help: "Organization this event is about; empty for a deployment-level action" }, metadata: { label: "Metadata", diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 5f7589ec20b..d8c852f7831 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -70,7 +70,7 @@ export const esESObjects: NonNullable = { }, tenant_id: { label: "Inquilino", - help: "Contexto del tenant para el aislamiento multi-tenant." + help: "Organización a la que se refiere este evento; vacío en una acción a nivel de despliegue." }, metadata: { label: "Metadatos", diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index b741e95be48..1924d25a147 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -70,7 +70,7 @@ export const jaJPObjects: NonNullable = { }, tenant_id: { label: "テナント", - help: "マルチテナント分離のためのテナントコンテキスト" + help: "このイベントの対象となる組織。デプロイメントレベルの操作では空です" }, metadata: { label: "メタデータ", diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 50d960c0dbc..3e2c2f24d0f 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -70,7 +70,7 @@ export const zhCNObjects: NonNullable = { }, tenant_id: { label: "租户", - help: "用于多租户隔离的租户上下文" + help: "此事件所涉及的组织;部署级操作为空" }, metadata: { label: "元数据", diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 4b4edfaedbd..d76b311f789 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -210,7 +210,6 @@ async function recordPlatformAdminStandingChange( const row = buildPlatformAdminStandingRow({ snapshot, previousSerialized, - declaresOrganizationId: declaresField('organization_id'), declaresActor: declaresField('actor'), }); // ⛔ Through this file's ONE write door, not a second `ql.insert` beside it. diff --git a/packages/plugins/plugin-security/src/managed-object-write-denies.ts b/packages/plugins/plugin-security/src/managed-object-write-denies.ts index 6f35808eaaf..3de833423f4 100644 --- a/packages/plugins/plugin-security/src/managed-object-write-denies.ts +++ b/packages/plugins/plugin-security/src/managed-object-write-denies.ts @@ -49,15 +49,20 @@ * would wrongly widen `sys_user`). This byte-preserves the prior behavior. * * ── Deliberately NOT covered: engine-owned system / append-only objects ── - * ADR-0103's engine-owned objects (`sys_audit_log`, `sys_automation_run`, …) - * are also reached by the wildcard in the target sets that carry one, but we do - * NOT inject deny entries for them: a per-object entry FULLY OVERRIDES the - * wildcard (lookup, not merge — see `default-permission-sets.ts`), so injecting + * ADR-0103's engine-owned objects (`sys_automation_run`, …) are also reached by + * the wildcard in the target sets that carry one, but we do NOT inject deny + * entries for them: a per-object entry FULLY OVERRIDES the wildcard (lookup, + * not merge — see `default-permission-sets.ts`), so injecting * `{ allowRead: true, ...writes:false }` would silently drop * `organization_admin`'s `viewAllRecords` / `modifyAllRecords` — a read-side * narrowing. Their user-context writes are already rejected at the * engine by `assertEngineOwnedWriteAllowed` (ADR-0103) and reflected in the * `/me/permissions` clamp, so the permission-set layer needs no change for them. + * The compliance ledger `sys_audit_log` is the one engine-owned object + * `organization_admin` names explicitly, read only and WITHOUT the superuser + * bits, in its static declaration — that narrowing is deliberate there + * (ADR-0131 D7: the ledger has no tenant column, so the wildcard's bypass would + * skip its organization row scope), and this module injects nothing for it. */ import type { PermissionSet } from '@objectstack/spec/security'; diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 78e4274c8ae..43611a67ec5 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -245,6 +245,56 @@ const privateCredentialRowScope = () => [ { name: 'sys_jwks_none', object: 'sys_jwks', operation: 'select', using: 'id == null' }, ]; +/** + * [ADR-0131 D7] The organization read scope of the compliance ledger, + * `sys_audit_log`. + * + * The ledger carries no organization column (`systemFields: { tenant: false }`): + * some of its rows are about deployment-level actions no organization owns, so + * the tenant wall (Layer 0) is inert on it, and D7 governs it by object + * permission. The organization a row is ABOUT is the plain attribution field + * `tenant_id`, which every writer stamps (NULL for a deployment-level action). + * This policy scopes an organization reader to the rows about its active + * organization; it is the ONE spelling of that scope, so no reader re-derives + * the wall's posture ladder (D8). + * + * - **A platform tenant policy**, recognised by provenance + * (`isPlatformTenantPolicy`), so `collectRLSPolicies` strips it when no wall + * is enforced (ADR-0105 D3): under `single` the one organization's readers + * read the ledger as they did before. + * - **The platform administrator reads every row**, deployment-level rows + * included: `admin_full_access`'s wildcard carries the superuser read bypass, + * which skips Layer 1 on an object with no tenant column. + * - **`organization_admin` names the ledger explicitly, without the superuser + * bits** (its `objects` below). Its wildcard's bypass would otherwise skip + * this policy too and hand each organization's admin every organization's + * rows, as it did on the seven plumbing tables before their capability gate. + * - **Where it is spread**: every shipped set that carries row-level security — + * `organization_admin` (and so its derived no-bypass variant), + * `viewer_readonly`, whose wildcard reads the ledger, and `member_default`, + * the baseline every authenticated human resolves, so a ledger read an + * application set grants is scoped as well. A set with no policy for an + * object leaves it unfiltered, and `member_default` is absent where no + * platform baseline is composed (the reason `scimProjectionRowScope` gives). + * ⚠️ Out of reach: an application set that grants the superuser read bypass + * on the ledger (`viewAllRecords` on it or on a wildcard) skips Layer 1, as + * every such grant does on an object Layer 0 does not wall. + * - **Under `group`** `current_user.organization_id` is the active + * organization, so the scope is that organization's rows, not the union. + * + * `select` (not `all`): a user-context write on this engine-owned object is + * refused at the engine (ADR-0103); its writers write under system context, + * which no row policy reaches. A fresh array per set. + */ +const auditLedgerRowScope = () => [ + { + name: 'sys_audit_log_org', + object: 'sys_audit_log', + operation: 'select', + using: 'tenant_id == current_user.organization_id', + }, +]; + /** The identity object whose `Admin` field group the sets below withhold or keep. */ const IDENTITY_OBJECT = 'sys_user'; /** The field group the identity object's declaration marks as admin-review data. */ @@ -427,6 +477,12 @@ const baseDefaultPermissionSets: PermissionSet[] = [ sys_position_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + // [ADR-0131 D7] The compliance ledger, WITHOUT the superuser bits: the + // ledger has no tenant column, so the wildcard's `viewAllRecords` would + // skip its organization row scope (`sys_audit_log_org`, see + // `auditLedgerRowScope`) and read every organization's rows. Read only: + // the ledger is append-only and written under system context. + sys_audit_log: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, }, systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], // [#21237] Keeps the identity object's `Admin` group for an org admin, who @@ -611,6 +667,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // does not reach them — the blanket's explicit entry, not the wildcard, // is what resolves for them. See `privateCredentialRowScope` above. ...privateCredentialRowScope(), + // [ADR-0131 D7] The compliance ledger: the rows about the active + // organization. See `auditLedgerRowScope` above. + ...auditLedgerRowScope(), ], }), PermissionSetSchema.parse({ @@ -1166,6 +1225,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [ ...scimProjectionRowScope(), // [#20027] The credential tables: no row — see `privateCredentialRowScope`. ...privateCredentialRowScope(), + // [ADR-0131 D7] The compliance ledger: the rows about the active + // organization, for a ledger read any set grants — see `auditLedgerRowScope`. + ...auditLedgerRowScope(), ], }), PermissionSetSchema.parse({ @@ -1296,6 +1358,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [ ...scimProjectionRowScope(), // [#20027] Repeated here for the same reason: the credential tables, no row. ...privateCredentialRowScope(), + // [ADR-0131 D7] Repeated here for the same reason: the compliance ledger's + // organization scope, which this set's wildcard read reaches. + ...auditLedgerRowScope(), ], }), diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index df0a05ff995..52324ab6e41 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -101,6 +101,12 @@ describe('default permission sets', () => { 'owner_only_writes', 'sys_account_self', 'sys_api_key_self', + // [ADR-0131 D7] The compliance ledger's organization scope on its + // attribution field `tenant_id`: the ledger has no tenant column, so + // Layer 0 is inert on it and this is the row scope for a ledger read any + // set grants. Behaviour is pinned end to end in + // `sys-audit-log-row-scope.test.ts`; this list only records presence. + 'sys_audit_log_org', // [commit c25b2d52a] The one DELETE-class per-object policy, and the only entry here // that widens rather than narrows. `owner_only_deletes` above is a // parent-blind second implementation of "who may remove this row", and on diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts index 0795e59e2dc..acf226fa36f 100644 --- a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts @@ -7,7 +7,7 @@ * * - the ROW SHAPE, tested through the writer's own builder rather than a * hand-written copy of it — including the one property a later author is - * most likely to "repair": `organization_id` is NULL, by maintainer ruling + * most likely to "repair": `tenant_id` is NULL, by maintainer ruling * (director batch #153 item 2, 2026-09-18) and by ADR-0131 §1.5, which * rejects inventing a platform organization in its own words. A pin is the * only thing that can notice that exception being undone, because every @@ -34,7 +34,10 @@ import type { PlatformAdminStandingEntry } from './platform-admin-service.js'; const LEDGER = PLATFORM_ADMIN_STANDING_LEDGER; -/** The `sys_audit_log` field set a multi-tenant deployment declares. */ +/** + * The `sys_audit_log` field set a deployment registers — with no organization + * column on any posture since ADR-0131 D7 (`tenant_id` is the attribution field). + */ const TENANTED_LEDGER_FIELDS = [ 'created_at', 'action', @@ -46,7 +49,6 @@ const TENANTED_LEDGER_FIELDS = [ 'new_value', 'tenant_id', 'metadata', - 'organization_id', 'id', ]; @@ -187,7 +189,6 @@ describe('the platform-admin standing row (#18412)', () => { const row = buildPlatformAdminStandingRow({ snapshot, previousSerialized: null, - declaresOrganizationId: true, declaresActor: true, }); expect(row.action).toBe('platform_admin_standing_change'); @@ -220,7 +221,6 @@ describe('the platform-admin standing row (#18412)', () => { const row = buildPlatformAdminStandingRow({ snapshot: platformAdminStandingSnapshot([entry()]), previousSerialized: previous, - declaresOrganizationId: true, declaresActor: true, }); expect(row.old_value).toBe(previous); @@ -240,32 +240,30 @@ describe('the platform-admin standing row (#18412)', () => { * … exists only to give NULL a new name」); the maintainer ruled this exact * shape on 2026-09-18. */ - it('⛔ organization_id and tenant_id are NULL — the ruled deployment-level shape, not an oversight', () => { + it('⛔ tenant_id is NULL and no organization column is stamped — the ruled deployment-level shape, not an oversight', () => { const row = buildPlatformAdminStandingRow({ snapshot: platformAdminStandingSnapshot([entry()]), previousSerialized: null, - declaresOrganizationId: true, declaresActor: true, }); expect( - row.organization_id, + row.tenant_id, 'the boot-time platform-admin standing row is DEPLOYMENT-LEVEL: there is no platform ' + 'organization on this tree (ADR-0131 §1.5 rejects inventing one), a tenant id would ' - + 'file a whole-deployment fact behind one tenant, and the first-boot baseline is ' + + 'serve a whole-deployment fact to one tenant, and the first-boot baseline is ' + 'written before any sys_organization row exists at all. NULL is the ruled shape ' - + '(#18412, director batch #153 item 2) and the shape ADR-0131 D7 will later make ' - + 'structural by dropping the column.', + + '(#18412, director batch #153 item 2).', ).toBeNull(); - expect(row.tenant_id).toBeNull(); + // ADR-0131 D7: the ledger has no organization column, so none is stamped. + expect(Object.keys(row)).not.toContain('organization_id'); // ADR-0118 D1/D5 keeps `actor` two-valued; the boot is the system. expect(row.actor).toBeNull(); }); - it('omits both conditional columns on a deployment whose ledger does not declare them', () => { + it('omits the conditional actor column on a ledger that does not declare it', () => { const row = buildPlatformAdminStandingRow({ snapshot: platformAdminStandingSnapshot([entry()]), previousSerialized: null, - declaresOrganizationId: false, declaresActor: false, }); // Stamping a column the table lacks fails the INSERT outright — the @@ -313,7 +311,8 @@ describe('boot records a CHANGE of standing, and only a change (#18412)', () => const rows = ql.auditRows(); expect(rows).toHaveLength(1); expect(rows[0].old_value).toBeNull(); - expect(rows[0].organization_id).toBeNull(); + expect(rows[0].tenant_id).toBeNull(); + expect(rows[0]).not.toHaveProperty('organization_id'); expect(JSON.parse(rows[0].new_value)[0]).toMatchObject({ email: 'operator@corp.example', verified: true, @@ -429,7 +428,8 @@ describe('boot records a CHANGE of standing, and only a change (#18412)', () => await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); const rows = ql.auditRows(); expect(rows).toHaveLength(1); - // Nothing could be probed, so neither conditional column is stamped. + // Nothing could be probed, so the conditional actor column is not stamped; + // the ledger has no organization column to stamp at all (ADR-0131 D7). expect(rows[0]).not.toHaveProperty('organization_id'); expect(rows[0]).not.toHaveProperty('actor'); }); diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts index 38bd8cf5adc..952f6114c54 100644 --- a/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts @@ -158,9 +158,7 @@ export interface PlatformAdminStandingRowInput { snapshot: readonly PlatformAdminStandingSnapshotEntry[]; /** The last recorded snapshot's stored string, or `null` on the baseline row. */ previousSerialized: string | null; - /** Does the registered `sys_audit_log` schema declare `organization_id`? */ - declaresOrganizationId: boolean; - /** Does it declare `actor`? */ + /** Does the registered `sys_audit_log` schema declare `actor`? */ declaresActor: boolean; } @@ -194,8 +192,8 @@ export function buildPlatformAdminStandingRow( // answerable from the row itself rather than from a scan of the ledger. old_value: input.previousSerialized, new_value: serialized, - // ⛔ NULL, deliberately — see the block on `organization_id` below. This is - // the schema-declared "tenant context" lookup and this fact has no tenant. + // ⛔ NULL, deliberately — see the block below. This is the ledger's + // attribution field (ADR-0131 D7), and this fact is about no organization. tenant_id: null, metadata: serializePlatformAdminStandingMetadata({ event: baseline @@ -206,17 +204,17 @@ export function buildPlatformAdminStandingRow( }), }; - // ⭐ THE DECLARED EXCEPTION — ⛔ do not "repair" this to a non-NULL value. + // ⭐ THE DECLARED EXCEPTION — ⛔ do not "repair" `tenant_id` to a non-NULL value. // - // This row carries `organization_id: null`, and that is the RULED shape, not - // an oversight and not a gap waiting for an owner. + // This row is about no organization, and that is the RULED shape, not an + // oversight and not a gap waiting for an owner. // // ADR-0131 §1.5 「The rejected middle: a platform organization」 considered // inventing an organization to own deployment-level rows and rejected it in // its own words: 「it is the natural repair and the wrong one … exists only to // give NULL a new name」. There is no platform organization on this tree, by // that decision. Stamping some tenant's id instead would be a lie — this is a - // fact about the whole deployment, filed behind one tenant's wall — and one + // fact about the whole deployment, served to one tenant's readers — and one // row per organization is the fan-out §1.5 names as wrong. The first-boot // BASELINE settles it structurally: it is written before any `sys_organization` // row exists, so at that instant there is no id in the world to stamp. @@ -224,18 +222,16 @@ export function buildPlatformAdminStandingRow( // The maintainer ruled this directly (#18412, director batch #153 item 2, // 「其他同意」 2026-09-18): the earlier clause requiring a non-NULL // organization was WITHDRAWN as an error, and this entry 「follows the tree's - // existing deployment-level writing (`organization` NULL), exactly as the four - // writers above do」 — `plugin-audit`'s `audit-writers.ts` and `read-audit.ts`, - // its `auth-event-audit.ts`, and `service-settings`' `config-change-audit.ts`, - // every one of which stamps `tenantId ?? null`. ADR-0131 D7 will later drop - // this column from `sys_audit_log` outright; NULL is how that shape is - // expressed until it does. + // existing deployment-level writing (`organization` NULL)」. ADR-0131 D7 has + // since dropped the ledger's injected organization column outright: the + // organization a row is about is the attribution field `tenant_id` alone, and + // a NULL there is served to platform administrators only under a wall + // (plugin-security's `sys_audit_log_org` row policy), which is the audience + // this record is for. // - // Conditionally stamped for the same mechanical reason every other writer - // states: the SchemaRegistry injects `organization_id` only where the object - // and the posture admit it, and stamping a column the table lacks makes the - // INSERT fail outright. - if (input.declaresOrganizationId) row.organization_id = null; + // `actor` is conditionally stamped for the mechanical reason every other + // writer states: older ledger tables predate the column, and stamping a + // column the table lacks makes the INSERT fail outright. if (input.declaresActor) row.actor = null; return row; diff --git a/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts new file mode 100644 index 00000000000..99443c8ba74 --- /dev/null +++ b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7] The compliance ledger's organization read scope — measured + * through a real `ObjectQL` over a real SQL driver, with the real + * `SecurityPlugin` middleware and the real shipped permission sets in front. + * + * `sys_audit_log` carries no organization column (`systemFields: { tenant: + * false }`), so the tenant wall (Layer 0) is inert on it. The organization a + * row is about is the attribution field `tenant_id`; the shipped sets scope an + * organization reader on it with the platform row policy `sys_audit_log_org`, + * and `organization_admin` names the ledger without the superuser bits. + * + * ## The ledger stand-in + * + * This package does not depend on `@objectstack/plugin-audit`, so the object + * here is a stand-in carrying the three declarations that decide the read + * scope: the name, `systemFields: { tenant: false }`, and `tenant_id` as a + * lookup to the organization object. The shipped declaration is pinned to + * those three in plugin-audit's `sys-audit-log-attribution.test.ts`. + * + * Rows: one about each of two organizations, and one about a deployment-level + * action (no `tenant_id`), all written as the platform writes them: as the + * system. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { isPlatformTenantPolicy } from './platform-tenant-policies.js'; + +const LEDGER = 'sys_audit_log'; +const POLICY = 'sys_audit_log_org'; +const SYS = { context: { isSystem: true } } as never; + +const ORG_ADMIN_A = { + userId: 'usr_oadmin_a', tenantId: 'org_a', positions: ['org_admin'], + permissions: ['organization_admin'], posture: 'TENANT_ADMIN', +}; +/** The wall-less variant `auto-org-admin-grant` hands an organization's admin under `single`. */ +const ORG_ADMIN_A_NO_BYPASS = { ...ORG_ADMIN_A, permissions: ['organization_admin_no_bypass'] }; +const PLATFORM_ADMIN = { + userId: 'usr_padmin', tenantId: 'org_a', positions: ['platform_admin'], + permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN', +}; +const VIEWER_A = { + userId: 'usr_viewer_a', tenantId: 'org_a', positions: ['org_member'], + permissions: ['viewer_readonly'], posture: 'MEMBER', +}; + +/** The ledger as the registry registers it after this change: no organization column. */ +const LEDGER_NOW = { + name: LEDGER, + label: 'Audit Log', + managedBy: 'append-only', + systemFields: { tenant: false }, + fields: { + action: { name: 'action', type: 'text' }, + object_name: { name: 'object_name', type: 'text' }, + tenant_id: { name: 'tenant_id', type: 'lookup', reference: 'sys_organization' }, + }, +}; +/** CONTROL: the ledger as it was registered before — the injected organization column, walled. */ +const LEDGER_BEFORE = { ...LEDGER_NOW, systemFields: undefined }; + +/** Every shipped set with the ledger policy removed — the read scope without it. */ +const withoutPolicy = (): PermissionSet[] => + defaultPermissionSets.map((ps) => ({ + ...ps, + rowLevelSecurity: (ps.rowLevelSecurity ?? []).filter((p) => p.name !== POLICY), + })); +/** Every shipped set with `organization_admin`'s explicit ledger entry removed — its wildcard resolves. */ +const withoutExplicitEntry = (): PermissionSet[] => + defaultPermissionSets.map((ps) => { + if (!ps.objects?.[LEDGER]) return ps; + const { [LEDGER]: _entry, ...objects } = ps.objects; + return { ...ps, objects }; + }); +/** The sets as they shipped before: neither the policy nor the explicit entry. */ +const asBefore = (): PermissionSet[] => { + const entryless = new Map(withoutExplicitEntry().map((ps) => [ps.name, ps])); + return withoutPolicy().map((ps) => ({ ...ps, objects: entryless.get(ps.name)?.objects ?? ps.objects })); +}; + +type Posture = 'isolated' | 'single'; +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const engine of engines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +async function boot(opts: { posture: Posture; sets?: PermissionSet[]; ledger?: Record }) { + // `single` is the one-organization topology (a second organization is the + // ambiguous shape the boot refuses), so its fixture holds one. + const single = opts.posture === 'single'; + const sets = opts.sets ?? defaultPermissionSets; + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.sys-audit-log-row-scope', + name: 'Compliance ledger row scope', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { name: 'sys_organization', label: 'Organization', fields: { name: { name: 'name', type: 'text' } } }, + opts.ledger ?? LEDGER_NOW, + ], + } as never); + await engine.syncSchemas(); + await engine.insert('sys_organization', [{ id: 'org_a', name: 'A' }, ...(single ? [] : [{ id: 'org_b', name: 'B' }])], SYS); + const before = opts.ledger === LEDGER_BEFORE; + await engine.insert(LEDGER, [ + { id: 'a1', action: 'update', tenant_id: 'org_a', ...(before ? { organization_id: 'org_a' } : {}) }, + ...(single ? [] : [{ id: 'b1', action: 'update', tenant_id: 'org_b', ...(before ? { organization_id: 'org_b' } : {}) }]), + { id: 'd1', action: 'platform_admin_standing_change', tenant_id: null }, + ], SYS); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + tenancy: { posture: opts.posture }, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => sets, + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default', defaultPermissionSets: sets }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + + return { + engine, + /** The ledger row ids `caller` is served, sorted. */ + read: async (caller: Record): Promise => { + const rows = (await engine.find(LEDGER, { context: { ...caller } } as never)) as Array<{ id: string }>; + return rows.map((r) => r.id).sort(); + }, + }; +} + +describe('[ADR-0131 D7] sys_audit_log: the organization scope rides tenant_id, under an organization wall', () => { + it('premise: the registered ledger has no organization column, and tenant_id is a plain field', async () => { + const { engine } = await boot({ posture: 'isolated' }); + const fields = Object.keys((engine.getSchema(LEDGER) as { fields?: object })?.fields ?? {}); + expect(fields).toContain('tenant_id'); + expect(fields).not.toContain('organization_id'); + }); + + it('an organization admin reads the rows about its own organization, and not the other organization\'s or the deployment-level row', async () => { + const r = await boot({ posture: 'isolated' }); + expect(await r.read(ORG_ADMIN_A)).toEqual(['a1']); + }); + + it('CONTROL: the same read with the policy removed from every set serves every row', async () => { + const r = await boot({ posture: 'isolated', sets: withoutPolicy() }); + expect(await r.read(ORG_ADMIN_A)).toEqual(['a1', 'b1', 'd1']); + }); + + it('the explicit organization_admin entry is load-bearing: without it the wildcard superuser bypass skips the policy', async () => { + const r = await boot({ posture: 'isolated', sets: withoutExplicitEntry() }); + expect(await r.read(ORG_ADMIN_A)).toEqual(['a1', 'b1', 'd1']); + }); + + it('a viewer, whose wildcard read reaches the ledger, is scoped the same way', async () => { + const r = await boot({ posture: 'isolated' }); + expect(await r.read(VIEWER_A)).toEqual(['a1']); + }); + + it('a global settings change is not served to an organization admin; a tenant-scope change is', async () => { + // The two `config_change` shapes the settings writer produces since + // ADR-0131 D7: a GLOBAL-scope change is about no organization, so it + // carries no `tenant_id` whatever organization the writer had active; a + // tenant-scope change carries the writer's organization. + const r = await boot({ posture: 'isolated' }); + await r.engine.insert(LEDGER, [ + { id: 'g1', action: 'config_change', object_name: 'sys_platform_setting', tenant_id: null }, + { id: 't1', action: 'config_change', object_name: 'sys_setting', tenant_id: 'org_a' }, + ], SYS); + const settingsRows = (ids: string[]) => ids.filter((id) => id === 'g1' || id === 't1'); + expect(settingsRows(await r.read(ORG_ADMIN_A))).toEqual(['t1']); + expect(settingsRows(await r.read(PLATFORM_ADMIN))).toEqual(['g1', 't1']); + }); + + it('a platform admin reads every row, the deployment-level row included', async () => { + const r = await boot({ posture: 'isolated' }); + expect(await r.read(PLATFORM_ADMIN)).toEqual(['a1', 'b1', 'd1']); + }); + + it('CONTROL: before the change the wall hid the deployment-level row from the platform admin too', async () => { + const r = await boot({ posture: 'isolated', sets: asBefore(), ledger: LEDGER_BEFORE }); + expect(await r.read(PLATFORM_ADMIN)).toEqual(['a1']); + expect(await r.read(ORG_ADMIN_A)).toEqual(['a1']); + }); +}); + +describe('[ADR-0131 D7 / ADR-0105 D3] under `single` the policy is stripped and the admin reads as before', () => { + it('the policy is a platform tenant policy by provenance, so collection strips it when no wall is enforced', () => { + const shipped = defaultPermissionSets.flatMap((ps) => ps.rowLevelSecurity ?? []).filter((p) => p.name === POLICY); + expect(shipped.length).toBeGreaterThan(0); + for (const policy of shipped) expect(isPlatformTenantPolicy(policy)).toBe(true); + }); + + it('both organization-admin variants read every row, exactly what the same read served before the change', async () => { + const now = await boot({ posture: 'single' }); + const before = await boot({ posture: 'single', sets: asBefore(), ledger: LEDGER_BEFORE }); + const expected = await before.read(ORG_ADMIN_A_NO_BYPASS); + expect(expected).toEqual(['a1', 'd1']); + expect(await now.read(ORG_ADMIN_A_NO_BYPASS)).toEqual(expected); + expect(await now.read(ORG_ADMIN_A)).toEqual(expected); + }); +}); diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 072f3c27f04..68eb3e93579 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -367,8 +367,10 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren // // `PLATFORM_CAPABILITIES` declares the capability `scope: 'org'`. That is a // statement about the runtime: the capability lifts the parent-record gate -// only, so under a wall-enforcing tenancy posture the tenant wall still bounds -// a holder to its own organization's ledger rows. A real `isolated` boot, two +// only, so under a wall-enforcing tenancy posture the ledger's organization row +// scope (`sys_audit_log_org` on the attribution field `tenant_id`, ADR-0131 D7: +// the ledger has no organization column) still bounds a holder to its own +// organization's ledger rows. A real `isolated` boot, two // organizations each created by its owner, each writing and deleting one // record; the owner of the first holds the capability. @@ -422,7 +424,7 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga const tenancy = await stack.kernel.getServiceAsync('tenancy'); const orgOf = async (id: string) => [...new Set((await ql.find(LEDGER, { where: { object_name: OBJ, record_id: id }, context: { ...SYS } })) - .map((r: Row) => r.organization_id))]; + .map((r: Row) => r.tenant_id))]; return { posture: tenancy?.posture, active: tenancy?.isolationActive, a: await orgOf(gone.a), b: await orgOf(gone.b) }; }, armed: (o) => o.posture === 'isolated' && o.active === true && org.a !== org.b && @@ -446,7 +448,7 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga expect(await servedAbout('b', gone.a)).toBe(0); }); - it('platform administrator: is served both, through its own wall bypass', async () => { + it('platform administrator: is served both, through its superuser read bypass', async () => { expect(await servedAbout('admin', gone.a)).toBe(2); expect(await servedAbout('admin', gone.b)).toBe(2); }); diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index 379aa6fa79c..a7a2692444b 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -95,9 +95,8 @@ const manifest: SettingsManifest = { * So the registry gets a declaration under that name and nothing more. The ROW * is still asserted at the engine seam, where the real object's absence does not * matter; what this buys is that `getSchema('sys_audit_log')` answers, which is - * the only thing the mount probe reads. It deliberately declares NO - * `organization_id`, so `makeFieldProbe`'s answer — and therefore every row - * asserted in this file — is byte-for-byte what it was before this existed. + * the only thing the mount probe reads. Like the shipped ledger (ADR-0131 D7) + * it declares NO `organization_id`, and the sink stamps none. */ const LEDGER_MOUNT_STANDIN = { name: 'sys_audit_log', @@ -379,8 +378,12 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', // Attribution: both channels, per ADR-0014 D2. expect(rows[0].user_id).toBe('usr_admin'); expect(rows[0].actor).toBe('usr_admin'); - // Tenant context — without it RLS hides the row from non-platform readers. - expect(rows[0].tenant_id).toBe('org_1'); + // [ADR-0131 D7] A global-scope change is about no organization, so its row + // carries no `tenant_id` although the writing session has `org_1` active: + // the ledger's organization row scope keeps it from that organization's + // readers. A tenant-scope change keeps the organization (pinned below). + expect(rows[0].tenant_id).toBeNull(); + expect('organization_id' in rows[0]).toBe(false); // Written as the platform, on an append-only, all-`readonly` table. expect(boot.ledgerOpts()[0]?.context).toMatchObject({ isSystem: true }); @@ -410,6 +413,9 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', expect(rows).toHaveLength(1); expect(rows[0].object_name).toBe(CONFIG_CHANGE_OBJECT_NAME); expect(rows[0].object_name).toBe('sys_setting'); + // CONTROL for the global case above: a tenant-scope change IS about the + // writing organization, so its row keeps it in the attribution field. + expect(rows[0].tenant_id).toBe('org_1'); // …and the two stores agree with the ledger: the row is a `sys_setting` // row, and the global rung's store took nothing. expect(boot.scopedSettingRows()).toHaveLength(1); diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index f0cae0a1e68..e52c6c71562 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -131,13 +131,12 @@ export const CONFIG_CHANGE_ACTION = 'config_change'; * of that row, and publishing it would widen this module's exported surface for * nothing. * - * ⛔ The two pre-existing spellings below — the `organization_id` field probe - * and the insert itself — are deliberately left INLINE rather than folded onto - * this constant. They are counted sites in `content/docs/permissions/tenant-audit-census.mdx` - * ("object name spelled inline" vs "named through a const"), so folding them - * moves a corpus-scale ratchet and a hand-written prose count in a docs tree - * this change has no business in. The consolidation is worth doing; it is not - * worth doing here. + * ⛔ The pre-existing spelling below — the insert itself — is deliberately left + * INLINE rather than folded onto this constant. It is a counted site in + * `content/docs/permissions/tenant-audit-census.mdx` ("object name spelled + * inline" vs "named through a const"), so folding it moves a corpus-scale + * ratchet and a hand-written prose count in a docs tree this change has no + * business in. The consolidation is worth doing; it is not worth doing here. */ const AUDIT_LEDGER_OBJECT_NAME = 'sys_audit_log'; @@ -169,48 +168,6 @@ type ConfigChangeLogger = SettingsDiagnosticsLogger & { debug?: (message: string) => void; }; -/** - * Whether the registered `sys_audit_log` schema declares `field`. - * - * `organization_id` is auto-injected by the SchemaRegistry ONLY in multi-tenant - * mode, so it is present on some deployments and absent on others. - * Unconditionally stamping it made every audit INSERT fail on a single-tenant - * stack ("table sys_audit_log has no column named organization_id"); never - * stamping it makes the SecurityPlugin's RLS predicate - * (`organization_id = current_user.organization_id`) hide every row from - * non-platform-admin readers on a multi-tenant one — which would leave the - * `config_changes` view exactly as empty as the defect this card fixes, one - * layer further down. `plugin-audit`'s own writer resolves it the same way, off - * the same lazily-read schema. - * - * Best-effort: an engine that exposes no `getSchema` simply skips the stamp, - * which is the pre-#8145 behaviour of every other explicit `sys_audit_log` - * writer in the repo. - */ -function makeFieldProbe(engine: IDataEngine): (field: string) => boolean { - let fields: Set | null | undefined; - return (field: string): boolean => { - if (fields === undefined) { - fields = null; - try { - // `getSchema` is not on `IDataEngine`; it is an ObjectQL member every - // real engine carries. Guarded rather than declared, so a lean engine - // double stays assignable. - const schema: any = (engine as any).getSchema?.('sys_audit_log'); - const declared = schema?.fields; - if (declared && typeof declared === 'object' && !Array.isArray(declared)) { - fields = new Set(Object.keys(declared)); - } else if (Array.isArray(declared)) { - fields = new Set(declared.map((f: any) => f?.name).filter(Boolean)); - } - } catch { - /* best-effort — absence just means we skip the stamp */ - } - } - return fields != null && fields.has(field); - }; -} - /** * [#18368] Is the platform audit ledger MOUNTED on this deployment? * @@ -221,9 +178,8 @@ function makeFieldProbe(engine: IDataEngine): (field: string) => boolean { * - `false` — the engine answered and it is not. This host declined to have a * ledger; skip, and say so on `debug` only. * - `undefined` — the engine could not be ASKED. `getSchema` is an ObjectQL - * member, not an `IDataEngine` one (the same guarded reach - * {@link makeFieldProbe} documents), so a lean engine double may - * not carry it, and a host engine may throw out of it. ⛔ Never + * member, not an `IDataEngine` one (a guarded reach, never a + * declared one), so a lean engine double may not carry it, and a host engine may throw out of it. ⛔ Never * read as "absent": an unanswerable probe must leave the write * attempted, which is this file's pre-#18368 behaviour, so a fault * on such an engine stays reported exactly as it was. @@ -270,7 +226,6 @@ export function buildConfigChangeAuditSink( logger?: ConfigChangeLogger, ): SettingsAuditSink { const eng: any = engine; - const declares = makeFieldProbe(engine); let failureReported = false; return { @@ -323,7 +278,15 @@ export function buildConfigChangeAuditSink( scope: entry.scope, digest: entry.valueDigest, }), - tenant_id: entry.tenantId ?? null, + // [ADR-0131 D7] The organization this row is ABOUT — the ledger's + // attribution field and its only organization column (it has no + // `organization_id`). A GLOBAL-scope change is a deployment-level + // action about no organization, so its row carries none, whatever + // organization the writing session has active; organization readers + // are scoped on this field, so stamping it would show one + // organization a deployment-wide change. Tenant- and user-scope + // changes keep the caller's organization. + tenant_id: entry.scope === 'global' ? null : entry.tenantId ?? null, metadata: safeStringify({ event: isReset ? 'settings.reset' : 'settings.set', namespace: entry.namespace, @@ -333,7 +296,6 @@ export function buildConfigChangeAuditSink( ...(entry.requestId ? { requestId: entry.requestId } : {}), }), }; - if (declares('organization_id')) row.organization_id = entry.tenantId ?? null; await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); } catch (err: any) { diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 1739ed1fb8c..9460192e9b4 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -184,10 +184,9 @@ export interface SettingsAuditSink { userId?: string; /** * [#8145] Tenant context of the caller, when known. Recorded on the ledger - * row's `tenant_id` (and, where the deployment declares the column, - * `organization_id`) — without it the SecurityPlugin's RLS predicate hides - * every `config_change` row from non-platform-admin readers, leaving the - * `config_changes` view as empty as the defect this fixes. + * row's `tenant_id`, the column the ledger's organization row scope reads, + * for a tenant- or user-scope change. A GLOBAL-scope change is about no + * organization, so its row carries none (ADR-0131 D7). */ tenantId?: string; actor?: string; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-audit-log-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-audit-log-organization-column-retired.ts new file mode 100644 index 00000000000..05718f4fdf6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-audit-log-organization-column-retired.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6 item 2) — the compliance ledger loses its injected +// organization column, and the organization a row is ABOUT stays in the +// existing attribution field tenant_id. A platform-object COLUMN retirement, +// not a spec-key retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. Existing +// rows keep the orphaned column until the v18 operator ceremony (ADR-0131 D14, +// D10 fate 1), which this entry does not perform. +export const entry: SemanticMigration = { + id: 'sys-audit-log-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_audit_log.organization_id — the injected organization column left the compliance ledger ' + + '(packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares ' + + 'systemFields.tenant false); the organization a row is about stays in the attribution field ' + + 'tenant_id, and an organization reader is scoped on it by a platform row policy', + replacement: + '`sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored ' + + 'filter, list-view column, report grouping, formula or seed key that names `organization_id` on ' + + '`sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. ' + + 'Under an organization wall an organization reader is scoped to the rows about its active ' + + 'organization by the platform row policy `sys_audit_log_org`, and a platform administrator ' + + 'reads every row', + reason: + 'ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization ' + + 'a row is about becomes a plain attribution field under a name the tenant-field resolver does not ' + + 'claim, never the tenancy anchor, and the object is governed by object permission, not by the ' + + 'wall. Writer census at commit 3ae59661dc of this repository\'s main branch: the record mirror, ' + + 'the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in ' + + 'service-settings, stamp tenant_id and stamped the injected column with the same value; the ' + + 'platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two ' + + 'administrative user writers in plugin-auth stamp neither. So the attribution field already ' + + 'carries every organization the column did. Under a walled posture the tenant wall compared the ' + + 'column to the caller organization, which hid every row about no organization from every reader, ' + + 'platform administrators included. The read scope moves to the security layer, where the ' + + 'engine computes it once: the platform row policy tenant_id equal to the caller organization, ' + + 'shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is ' + + 'enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or ' + + 'modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an ' + + 'object with no tenant column and hand each organization administrator every organization\'s ' + + 'rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is ' + + 'additive, so the physical column stays and the boot drift report names it orphaned; once its ' + + 'values are confirmed equal to tenant_id, the operator drops it with os migrate apply ' + + '--allow-destructive, and any row where they differ is reported rather than dropped.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_audit_log`: the field resolver (lint and the ' + + 'data door) answers it as an unknown field. A row about a deployment-level action is written ' + + 'with `tenant_id` empty and no refusal. Under an organization wall an organization administrator ' + + 'lists the rows whose `tenant_id` is its active organization and no other, and a platform ' + + 'administrator lists every row, the rows with no `tenant_id` included. Under `single` the policy ' + + 'is stripped and the organization administrator lists every row, as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ad8586707fb..aaff4aafc8e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6227,6 +6227,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' + 'authored content silently.', }, + { + id: 'sys-audit-log-organization-column-retired', + order: 88, + text: + 'It also takes the injected organization column off the compliance ledger, `sys_audit_log` ' + + '(ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the ' + + 'organization a row is about stays in the attribution field `tenant_id`, which every writer ' + + 'already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a ' + + 'platform administrator now reads the rows about no organization too; an organization reader is ' + + 'scoped to the rows about its active organization by the platform row policy ' + + '`sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ' + + 'ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant ' + + 'retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps ' + + 'the column as an orphan the boot drift report names, for the v18 ceremony to drop once its ' + + 'values are confirmed in `tenant_id`. The D3 record is the ' + + '`sys-audit-log-organization-column-retired` semantic entry.', + }, { id: 'sys-setting-global-rung-moved', order: 87, @@ -18992,6 +19009,58 @@ const step18: MigrationStep = { + '`sys_sso_provider` update door refuses an issuer change while accounts are still bound to ' + 'that provider.', }, + // #15207 (ADR-0131 D7, C6 item 2) — the compliance ledger loses its injected + // organization column, and the organization a row is ABOUT stays in the + // existing attribution field tenant_id. A platform-object COLUMN retirement, + // not a spec-key retirement: no authorable spec key moves, so nothing lands in + // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. Existing + // rows keep the orphaned column until the v18 operator ceremony (ADR-0131 D14, + // D10 fate 1), which this entry does not perform. + { + id: 'sys-audit-log-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_audit_log.organization_id — the injected organization column left the compliance ledger ' + + '(packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares ' + + 'systemFields.tenant false); the organization a row is about stays in the attribution field ' + + 'tenant_id, and an organization reader is scoped on it by a platform row policy', + replacement: + '`sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored ' + + 'filter, list-view column, report grouping, formula or seed key that names `organization_id` on ' + + '`sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. ' + + 'Under an organization wall an organization reader is scoped to the rows about its active ' + + 'organization by the platform row policy `sys_audit_log_org`, and a platform administrator ' + + 'reads every row', + reason: + 'ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization ' + + 'a row is about becomes a plain attribution field under a name the tenant-field resolver does not ' + + 'claim, never the tenancy anchor, and the object is governed by object permission, not by the ' + + 'wall. Writer census at commit 3ae59661dc of this repository\'s main branch: the record mirror, ' + + 'the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in ' + + 'service-settings, stamp tenant_id and stamped the injected column with the same value; the ' + + 'platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two ' + + 'administrative user writers in plugin-auth stamp neither. So the attribution field already ' + + 'carries every organization the column did. Under a walled posture the tenant wall compared the ' + + 'column to the caller organization, which hid every row about no organization from every reader, ' + + 'platform administrators included. The read scope moves to the security layer, where the ' + + 'engine computes it once: the platform row policy tenant_id equal to the caller organization, ' + + 'shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is ' + + 'enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or ' + + 'modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an ' + + 'object with no tenant column and hand each organization administrator every organization\'s ' + + 'rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is ' + + 'additive, so the physical column stays and the boot drift report names it orphaned; once its ' + + 'values are confirmed equal to tenant_id, the operator drops it with os migrate apply ' + + '--allow-destructive, and any row where they differ is reported rather than dropped.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_audit_log`: the field resolver (lint and the ' + + 'data door) answers it as an unknown field. A row about a deployment-level action is written ' + + 'with `tenant_id` empty and no refusal. Under an organization wall an organization administrator ' + + 'lists the rows whose `tenant_id` is its active organization and no other, and a platform ' + + 'administrator lists every row, the rows with no `tenant_id` included. Under `single` the policy ' + + 'is stripped and the organization administrator lists every row, as before.', + }, // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card // requires. A platform-object COLUMN retirement, not a spec-key retirement: no // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index 0f42e281ba8..eaa5690ce94 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -85,13 +85,15 @@ export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ // read seams and field-level security still apply. The activity stream's // gate does not honour it. Held by default by platform administrators // (`ADMIN_FULL_ACCESS_CAPABILITIES`); every other position only by explicit - // grant. `org`, measured rather than assumed: the ledger carries the - // registry-provisioned `organization_id`, its writers stamp the record's - // organization, and under a wall-enforcing tenancy posture the tenant wall - // still bounds a holder to its own organization's rows (only the - // parent-record gate is lifted). A platform administrator reaches every - // organization through its own wall bypass, not through this capability. - { name: 'view_all_audit_log', label: 'View All Audit Log', description: 'Read every compliance-ledger (sys_audit_log) row the ledger grant reaches in the caller’s organization, past the parent-record read gate: rows about deleted records, ended sessions and records the holder cannot open. Field-level security still narrows each row’s before/after snapshots.', scope: 'org' }, + // grant. `org`, measured rather than assumed: the ledger carries no + // organization column (ADR-0131 D7); its writers stamp the organization a + // row is about in the attribution field `tenant_id`, and under a + // wall-enforcing tenancy posture the platform row policy on that field + // (`sys_audit_log_org`, plugin-security's default sets) still bounds a holder + // to its own organization's rows (only the parent-record gate is lifted). A + // platform administrator reaches every row, deployment-level rows included, + // through its superuser read bypass, not through this capability. + { name: 'view_all_audit_log', label: 'View All Audit Log', description: 'Read every compliance-ledger (sys_audit_log) row the holder’s ledger grant and row scope reach, past the parent-record read gate: rows about deleted records, ended sessions and records the holder cannot open. Under an organization wall an organization reader’s row scope is the rows about its organization (tenant_id); a platform administrator reads every row, deployment-level rows included. Field-level security still narrows each row’s before/after snapshots.', scope: 'org' }, ]; /** Set of built-in capability names, for fast membership checks (lint, gating). */ diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 57358b100af..715150efb1c 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -25,12 +25,12 @@ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { "registered": 84, - "inReach": 50, - "outOfReach": 34 + "inReach": 49, + "outOfReach": 35 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 9, + "systemFields.tenant: false": 10, "tenancy.enabled: false": 2 }, "unexplained": [], @@ -113,9 +113,11 @@ { "name": "sys_audit_log", "file": "packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_automation_run",