Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/15207-audit-log-attribution-field.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered sys-audit-log-organization-column-retired -->

**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.
16 changes: 3 additions & 13 deletions packages/objectql/src/federated-injected-column-readers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,16 @@ const READERS: Record<string, Row> = {
},
'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',
why:
"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()': {
Expand Down Expand Up @@ -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',
]);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>> = {
org_reg: { retention_overrides: { sys_audit_log: { maxAge: '365d' }, sys_job_run: { maxAge: '365d' } } },
};
return {
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
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<string, unknown>).flatMap(([key, value]) =>
key.startsWith('$') ? filteredColumns(value) : [key],
);
}

async function lifecycleEngine(object: Record<string, unknown>) {
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<string> => {
const registered = engine.registry.getObject(table) as { fields?: Record<string, unknown> } | 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<string, unknown>) { return { id: 'r_1', ...data }; },
async update(_t: string, id: string, data: Record<string, unknown>) { 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<string, unknown>) { return row; },
async syncSchema() {},
};
const archive = {
...driver,
name: 'archive',
async find() { return []; },
async deleteMany() { return 0; },
};

engine.registerDriver(driver as unknown as Parameters<ObjectQL['registerDriver']>[0], true);
engine.registerDriver(archive as unknown as Parameters<ObjectQL['registerDriver']>[0], false);
await engine.init();
engine.registry.registerObject(object as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
engine.registry.registerObject(ORG_OBJECT as unknown as Parameters<ObjectQL['registry']['registerObject']>[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'));
});
});
Loading
Loading