From 7d7c26b58b2c9bdb342fa117c04e99e7b6435f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 15:23:21 +0000 Subject: [PATCH 1/2] feat(spec): add stamp-only tenancy.organizationField and route sys_api_key audit stamps through it (#8778) Option A per the maintainer ruling on #8778: a read-neutral, stamp-only organization declaration. The audit writer's resolveRecordOrganizationField consults it first (with the #5315 field-presence guard); sys_api_key declares { enabled: false, organizationField: 'active_organization_id' } and stays unwalled. Read-neutrality pinned beside each named read path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --- .../src/sql-driver-tenant-scope.test.ts | 83 +++++++++++++ .../src/identity/sys-api-key.object.ts | 23 ++++ .../plugin-audit/src/audit-writers.test.ts | 112 +++++++++++++++--- .../plugins/plugin-audit/src/audit-writers.ts | 33 ++++-- .../plugin-security/src/tenant-layer.test.ts | 30 ++++- .../src/data/injected-system-columns.test.ts | 26 ++++ packages/spec/src/data/object.test.ts | 14 +++ packages/spec/src/data/object.zod.ts | 36 ++++++ 8 files changed, 332 insertions(+), 25 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts index 7b999fdd9b..40dd1d70b9 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -510,3 +510,86 @@ describe('SqlDriver tenant scope (organization_id)', () => { }); }); }); + +/** + * [#8778] `tenancy.organizationField` is STAMP-ONLY — the driver's tenant + * scoping must be blind to it. The key exists for the audit writer alone + * (which column says who a row is ABOUT); the wall keeps answering a different + * question (what the object is WALLED by) from `enabled` / `tenantField` / + * the `organization_id` column, exactly as before. These cases pin the two + * read paths the ruling names in this package — `applyTenantScope` (reads) + * and `injectTenantOnInsert` (writes) — against the declaration, in both the + * walled and the unwalled (`sys_api_key`-shaped) postures. + */ +describe('tenancy.organizationField is read-neutral in the driver (#8778)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + // A WALLED object that also declares the stamp-only key: scoping must + // keep running on `organization_id`, never on `about_org_id`. + name: 'ticket', + tenancy: { enabled: true, organizationField: 'about_org_id' }, + fields: { + organization_id: { type: 'string' }, + about_org_id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + { + // The shipped sys_api_key shape: unwalled (`enabled: false`), stamp + // column under a deliberately different name, no `organization_id`. + name: 'api_key_like', + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + fields: { + active_organization_id: { type: 'string' }, + name: { type: 'string' }, + revoked: { type: 'boolean' }, + }, + }, + ]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('applyTenantScope keeps walling by organization_id, not the stamp column', async () => { + // A row whose WALL column and STAMP column disagree is the discriminating + // fixture: if the driver ever read `organizationField`, org_b would see it. + await driver.create('ticket', { id: 't1', organization_id: 'org_a', about_org_id: 'org_b', name: 'T1' }); + const asA = await driver.find('ticket', {}, { tenantId: 'org_a' }); + const asB = await driver.find('ticket', {}, { tenantId: 'org_b' }); + expect(asA.map((r) => r.id)).toEqual(['t1']); + expect(asB).toHaveLength(0); + }); + + it('injectTenantOnInsert stamps organization_id and NEVER the declared stamp column', async () => { + const created = await driver.create('ticket', { id: 't2', name: 'T2' }, { tenantId: 'org_a' }); + expect(created.organization_id).toBe('org_a'); + // The stamp-only column is the AUDIT WRITER's to fill from the record — + // driver injection writing it would fabricate "who this row is about". + expect(created.about_org_id ?? null).toBeNull(); + }); + + it('the unwalled credential-table shape stays unwalled: reads unscoped, inserts uninjected', async () => { + // Pre-#8287-shaped row: no organization at all. Under any wall reading + // `active_organization_id` or resurrecting a scope, this row vanishes for + // its own owner — the defect #8287 removed and #8778 must not reintroduce. + await driver.create('api_key_like', { id: 'k0', name: 'legacy', revoked: false }); + await driver.create('api_key_like', { id: 'k1', name: 'ci', active_organization_id: 'org_b', revoked: false }); + + const asA = await driver.find('api_key_like', {}, { tenantId: 'org_a' }); + expect(asA.map((r) => r.id).sort()).toEqual(['k0', 'k1']); + + const created = await driver.create('api_key_like', { id: 'k2', name: 'new' }, { tenantId: 'org_a' }); + expect(created.active_organization_id ?? null).toBeNull(); + expect('organization_id' in created).toBe(false); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index fa64630168..a4cb07629b 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -44,6 +44,29 @@ export const SysApiKey = ObjectSchema.create({ reason: 'Identity table managed by better-auth — see ADR-0010.', docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, + // [#8778, #8707 remainder] Stamp-only organization declaration — NOT a wall. + // + // `organizationField` tells the audit writer which column carries the + // organization a key row is ABOUT, so history/revocation rows land behind + // the wall of the key's own organization instead of the revoker's active + // one (#8707's repro). It is read by audit stamping ONLY; no tenant-scoping + // path (`applyTenantScope` / `injectTenantOnInsert` / + // `computeTenantLayer0Filter`) reads it — pinned by tests beside each. + // + // `enabled: false` states explicitly what this table's shape already + // implies, and is measured behavior-identical to having no `tenancy` block + // for THIS object on every read path: injection bails on + // `managedBy: 'better-auth'` before tenancy is consulted + // (`resolveInjectedSystemColumns`), the SQL driver's `computeTenantField` + // resolves null either way (no `organization_id`, no `tenantField`), the + // Layer 0 wall is exempt either way (no `organization_id` column), and the + // memory/mongo boot guards count only an explicit `enabled: true`. ⛔ Never + // "upgrade" this to `enabled: true` or move the column to + // `tenancy.tenantField`: both wall the credential table on an equality that + // excludes NULL, and every pre-#8287 key vanishes from its own owner's + // "My Keys" list — the defect #8287 exists to have removed (see the + // `active_organization_id` field comment below). + tenancy: { enabled: false, organizationField: 'active_organization_id' }, description: 'API keys for programmatic access', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 66c4f6c2ec..d6a609ad39 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -1411,28 +1411,108 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 expect(stampOf(created).audit?.organization_id).not.toBe('org-parent'); }); - it('⛔ KNOWN GAP — `sys_api_key.active_organization_id` is still unreachable', async () => { + // ── `tenancy.organizationField` — the stamp-only declaration (#8778) ──── + // + // The former ⛔ KNOWN GAP case lived here: it pinned that + // `sys_api_key.active_organization_id` was UNREACHABLE and stamped the + // ACTOR's org, and was written to go red the day a read-neutral, stamp-only + // declaration landed in `packages/spec`. That day is #8778 (maintainer-ruled + // option A): the cases below are its rewrite, expecting `org-key`. + + it('stamps from a declared `tenancy.organizationField` — the #8707 repro, closed (#8778)', async () => { + const { engine, fire, created } = makeEngine( + { + ...MULTI_TENANT, + // As it really ships since #8287: no `organization_id` (better-auth + // managed tables get no injected system columns), and the org the key + // authenticates into under a deliberately different name. + sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'], + }, + // The shipped declaration shape (sys-api-key.object.ts): the credential + // table stays unwalled (`enabled: false` — `active_organization_id` is + // NOT a tenant-scope column and must never become one), while the + // stamp-only key routes the audit trail to the key's own organization. + // The declaration WINS over the ADR-0066 opt-out limb: an author who + // declares it on an unwalled object is stating exactly that the trail + // follows the record even though no wall does. + { sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } }, + ); + installAuditWriters(engine as any, 'test.audit'); + + // The card's repro: revoking a key whose organization differs from the + // revoker's active one. The row now lands behind the wall of the KEY's + // organization — where the tenant admin who can act on it reads it — not + // the revoker's. + await fire('afterUpdate', { + object: 'sys_api_key', + input: { id: 'key-1' }, + previous: { id: 'key-1', name: 'ci', active_organization_id: 'org-key', revoked: false }, + result: { id: 'key-1', name: 'ci', active_organization_id: 'org-key', revoked: true }, + session: { tenantId: 'org-actor', userId: 'user-1' }, + }); + + expect(stampOf(created).audit?.organization_id).toBe('org-key'); + }); + + it('honours `organizationField` only when the field exists (#5315 guard), falling through intact', async () => { + // A declared stamp column the object does not have must fall through to + // the rest of the precedence — the same guard `tenantField` carries — and + // for an `enabled: false` object the fall-through is the ADR-0066 limb: + // actor's org, exactly the pre-declaration behaviour. + const { engine, fire, created } = makeEngine( + { + ...MULTI_TENANT, + sys_api_key: ['id', 'name', 'user_id', 'revoked'], + }, + { sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } }, + ); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterUpdate', { + object: 'sys_api_key', + input: { id: 'key-1' }, + previous: { id: 'key-1', name: 'ci', revoked: false }, + result: { id: 'key-1', name: 'ci', revoked: true }, + session: { tenantId: 'org-actor', userId: 'user-1' }, + }); + + expect(stampOf(created).audit?.organization_id).toBe('org-actor'); + }); + + it('`organizationField` outranks `tenantField` — "who is this row about" beats "what walls it"', async () => { + // On an object declaring both, the stamp-only key is the more specific + // answer to the stamping question. (No shipped object declares both; this + // pins the precedence so the day one does is not a coin flip.) + const { engine, fire, created } = makeEngine( + { ...MULTI_TENANT, crm_lead: ['id', 'name', 'workspace_id', 'about_org_id'] }, + { + crm_lead: { + tenancy: { enabled: true, tenantField: 'workspace_id', organizationField: 'about_org_id' }, + }, + }, + ); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-1' }, + result: { id: 'lead-1', name: 'Acme', workspace_id: 'ws-1', about_org_id: 'org-about' }, + session: { tenantId: 'org-actor', userId: 'user-1' }, + }); + + expect(stampOf(created).audit?.organization_id).toBe('org-about'); + }); + + it('control: without the declaration the credential table still stamps the actor\'s org', async () => { + // The pre-#8778 shape (no `tenancy` block at all). This is what the old + // KNOWN GAP case pinned; kept as the control proving the new stamp comes + // from the DECLARATION, not from a hidden heuristic over the column name. const { engine, fire, created } = makeEngine({ ...MULTI_TENANT, - // As it really ships since #8287: no `organization_id` (better-auth - // managed tables get no injected system columns), and the org the key - // authenticates into under a deliberately different name. sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'], }); installAuditWriters(engine as any, 'test.audit'); - // The card's repro: revoking a key whose organization differs from the - // revoker's active one. The precedence above is now correct, but the column - // is not resolvable — `active_organization_id` is NOT this object's - // tenant-scope column and must not be declared as one (`tenancy.tenantField` - // feeds `applyTenantScope` / `injectTenantOnInsert`, so declaring it would - // wall the credential table on an equality that excludes NULL and make - // pre-#8287 keys vanish from their own owner's list — the defect #8287 - // exists to have removed). - // - // ⚠️ This case pins the REMAINING HALF of #8707, not a decision. It must go - // red — and be rewritten to expect `org-key` — on the day a read-neutral, - // stamp-only organization declaration lands in `packages/spec`. await fire('afterUpdate', { object: 'sys_api_key', input: { id: 'key-1' }, diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index a993b27320..6ac38ca86d 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -272,6 +272,19 @@ export function createFieldPresenceProbe( * the parts that could drift are one definition, and only the ordering is * restated. * + * 0. **Declared `tenancy.organizationField`, when the object really has that + * field.** The read-neutral, STAMP-ONLY declaration #8778's ruling added + * for exactly this consumer (option A; #8707's remaining half). It + * answers "which column says who this row is ABOUT" — a different + * question from "what is this object walled by", which is why it wins + * over every limb below, the ADR-0066 opt-out included: an author who + * declares it on an unwalled object (`sys_api_key`, `enabled: false` by + * necessity — the credential table must never be org-walled, #8287) is + * stating precisely that the trail should follow the record's own + * organization even though no wall does. Honoured only when the field is + * really present, same #5315 guard as limb 2. ⛔ Stamp-only cuts both + * ways: this resolver is the key's ONLY consumer by scope pin — a read + * path that starts consulting it needs its own ruling. * 1. **`tenancy.enabled === false` → `null`.** ADR-0066 platform-global * objects (`sys_sso_provider` is the shipped example) keep an optional org * FK while explicitly NOT being tenant-scoped. Stamping an audit row from @@ -305,14 +318,13 @@ export function createFieldPresenceProbe( * the same conclusion through a heuristic is the same mistake with no gate on * it. * - * Consequently `sys_api_key.active_organization_id` is still NOT reachable - * here, and that is reported rather than papered over — see the PR for #8707. - * Its column is not the object's tenant-scope column and must not become one: + * `sys_api_key.active_organization_id` is reachable through limb 0 since + * #8778 (it was the object that motivated the key). Its column is still not — + * and must never become — the object's tenant-scope column: * `tenancy.tenantField` feeds `applyTenantScope` / `injectTenantOnInsert`, so - * declaring it would wall the credential table on an equality that excludes - * NULL — every pre-#8287 key would vanish from its own owner's list, which is - * the defect #8287 exists to have removed. A read-neutral, stamp-only - * declaration is a `packages/spec` contract addition and belongs to that seat. + * declaring it there would wall the credential table on an equality that + * excludes NULL — every pre-#8287 key would vanish from its own owner's + * list, which is the defect #8287 exists to have removed. * * @param objectDef the registered object definition (`engine.getSchema(name)`) * @param hasField the memoized field-presence probe for the SAME object — this @@ -325,8 +337,13 @@ export function resolveRecordOrganizationField( hasField: (field: string) => boolean, ): string | null { if (!objectDef || typeof objectDef !== 'object') return null; + const tenancy = (objectDef as { tenancy?: { organizationField?: unknown; tenantField?: unknown } }).tenancy; + // Limb 0 — the explicit stamp-only declaration (#8778) wins over everything, + // the ADR-0066 opt-out below included: see the precedence doc above. + const stampField = tenancy?.organizationField; + if (typeof stampField === 'string' && stampField.length > 0 && hasField(stampField)) return stampField; if (isTenancyDisabled(objectDef)) return null; - const declared = (objectDef as { tenancy?: { tenantField?: unknown } }).tenancy?.tenantField; + const declared = tenancy?.tenantField; if (typeof declared === 'string' && declared.length > 0 && hasField(declared)) return declared; if (hasField(SystemFieldName.ORGANIZATION_ID)) return SystemFieldName.ORGANIZATION_ID; return null; diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts index e1cd4149e7..98ebb03efc 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -214,13 +214,41 @@ describe('sys_api_key is not org-walled (#8287)', () => { expect(apiKeyFields.has('organization_id')).toBe(false); }); + /** + * [#8778] The stamp-only declaration must not move this object's Layer 0 + * inputs. `security-plugin.ts` derives them from exactly two reads — the + * registered field set (`objectHasOrgIdField`) and + * `tenancy.enabled === false || systemFields.tenant === false` + * (`tenancyDisabled`) — and `tenancy.organizationField` feeds neither. + * Derived here against the REAL shipped object, same doctrine as the rest + * of this suite: a hand-written boolean and the object can drift, and this + * is the pair that must not. + */ + const apiKeyTenancyDisabled = + (SysApiKey as { tenancy?: { enabled?: boolean } }).tenancy?.enabled === false || + (SysApiKey as { systemFields?: { tenant?: boolean } }).systemFields?.tenant === false; + + it('declares the stamp-only organizationField without acquiring the walling column (#8778)', () => { + // The declaration exists (the audit writer's input)… + expect((SysApiKey as any).tenancy?.organizationField).toBe('active_organization_id'); + // …and it did not smuggle a wall in: the field set still has no + // `organization_id`, and the block states `enabled: false` explicitly. + expect(apiKeyFields.has('organization_id')).toBe(false); + expect((SysApiKey as any).tenancy?.enabled).toBe(false); + }); + for (const tenancyPosture of ['single', 'group', 'isolated'] as const) { it(`${tenancyPosture}: Layer 0 contributes nothing, so a key row stays visible to its owner`, () => { const filter = computeTenantLayer0Filter({ ...base, tenancyPosture, - // Exactly what security-plugin.ts computes from the registered fields. + // Exactly what security-plugin.ts computes from the registered fields + // and the tenancy block — the REAL declaration, post-#8778, so this + // case is also the read-neutrality pin for `organizationField`: if the + // stamp-only key (or the `enabled: false` that must accompany it) ever + // started feeding the wall, this filter would stop being null. objectHasOrgIdField: apiKeyFields.has('organization_id'), + tenancyDisabled: apiKeyTenancyDisabled, }); expect(filter).toBeNull(); }); diff --git a/packages/spec/src/data/injected-system-columns.test.ts b/packages/spec/src/data/injected-system-columns.test.ts index 210dd2f03c..16dbfd0de7 100644 --- a/packages/spec/src/data/injected-system-columns.test.ts +++ b/packages/spec/src/data/injected-system-columns.test.ts @@ -80,6 +80,32 @@ describe('resolveInjectedSystemColumns (#5378)', () => { } }); + it('is blind to the stamp-only `tenancy.organizationField` (#8778 read-neutrality)', () => { + // The #8778 ruling's scope pin: `organizationField` is consulted by audit + // stamping ONLY. The injection plan must reach the same verdicts with and + // without it — on a plain tenant object, and on the shipped sys_api_key + // shape (better-auth managed + `enabled: false`), where the plan's + // better-auth bail must keep running BEFORE tenancy is read at all. + const withKey = resolveInjectedSystemColumns({ + ...business, + tenancy: { enabled: true, organizationField: 'about_org_id' }, + }); + expect(withKey).toMatchObject( + // Same verdicts as the bare business object — the key changed nothing. + { tenant: true, audit: true, owner: true, owningBusinessUnit: true }, + ); + expect(withKey.names.has('about_org_id')).toBe(false); + + const apiKeyShape = resolveInjectedSystemColumns({ + name: 'sys_api_key', + managedBy: 'better-auth', + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + fields: {}, + }); + expect(apiKeyShape).toMatchObject({ tenant: false, audit: false, owner: false, owningBusinessUnit: false }); + expect([...apiKeyShape.names]).toEqual(['id']); + }); + it('withholds the audit family for systemFields.audit: false', () => { const plan = resolveInjectedSystemColumns({ ...business, systemFields: { audit: false } }); expect(plan.audit).toBe(false); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 99763df6b1..3d1ea05b06 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1553,6 +1553,20 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () .toEqual({ enabled: false, tenantField: 'workspace_id' }); }); + it('accepts the stamp-only `organizationField`, with no default materialized (#8778)', () => { + // The shipped shape: sys_api_key stays unwalled (`enabled: false`) while + // audit rows stamp the organization of the key they describe. The key is + // read by audit stamping ONLY — read-neutrality is pinned beside each + // read path (driver tenant scope, Layer 0, injection plan), not here. + expect( + TenancyConfigSchema.parse({ enabled: false, organizationField: 'active_organization_id' }), + ).toEqual({ enabled: false, organizationField: 'active_organization_id' }); + + // Undeclared stays undeclared — same #5315 doctrine as `tenantField`. + const result = TenancyConfigSchema.parse({ enabled: true }); + expect('organizationField' in result).toBe(false); + }); + it('rejects the retired `strategy` with a tombstone pointing at the two real modes', () => { const result = TenancyConfigSchema.safeParse({ enabled: true, strategy: 'isolated' }); expect(result.success).toBe(false); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7da1d05fb7..e07693b68e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -502,6 +502,20 @@ const TENANCY_MODES_EXPLAINER = * (`organization` is the product's noun). Undeclared now stays `undefined` and * the driver's fallback is the single source of truth. * + * `organizationField` (#8707 / #8778, maintainer-ruled option A) is the + * STAMP-ONLY sibling: it answers "which column says who this row is ABOUT", + * where `tenantField` answers "what is this object WALLED by". For ordinary + * objects the two coincide and `organizationField` is never needed; for + * credential tables they deliberately do not — `sys_api_key` records the + * organization a key authenticates into under `active_organization_id` + * precisely so the credential table does NOT become org-walled (#8287). The + * key is consulted exclusively by audit stamping (plugin-audit's + * `resolveRecordOrganizationField`); no read path reads it, and that + * read-neutrality is pinned by tests beside each read path. ⛔ Scope-pinned by + * the #8778 ruling: this is ONE stamp-only declaration key, not the opening + * move of a general field-roles mechanism — a consumer other than audit + * stamping needs its own ruling before reading it. + * * @example Shared database, platform-default tenant column (organization_id) * { * enabled: true @@ -512,6 +526,13 @@ const TENANCY_MODES_EXPLAINER = * enabled: true, * tenantField: 'workspace_id' * } + * + * @example An unwalled credential table whose audit rows still stamp the + * organization of the record they describe (sys_api_key, #8778) + * { + * enabled: false, + * organizationField: 'active_organization_id' + * } */ export const TenancyConfigSchema = lazySchema(() => strictObject({ surface: '`tenancy`', @@ -527,6 +548,21 @@ export const TenancyConfigSchema = lazySchema(() => strictObject({ 'object really has that field — otherwise the same `organization_id` ' + 'fallback applies. No default is materialized here on purpose (#5315).', ), + organizationField: z.string().optional().describe( + 'STAMP-ONLY (#8778): column carrying the organization a row is ABOUT, ' + + 'consulted exclusively when audit rows are stamped. It does NOT ' + + 'tenant-scope anything — no read path (`applyTenantScope`, ' + + '`injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so ' + + 'declaring it never walls the object and never hides rows. Declare it ' + + 'only when the organization a row belongs to lives under a column that ' + + 'deliberately is NOT the tenant column: `sys_api_key` is the shipped ' + + 'example — a credential table that must stay unwalled (`enabled: false`) ' + + 'while history/revocation audit rows stamp the organization of the key ' + + 'they describe (`active_organization_id`). Ordinary tenant objects omit ' + + 'it; their stamp column is resolved from `tenantField` / ' + + '`organization_id` already. Honoured only when the object really has ' + + 'the field, like `tenantField`.', + ), })); /** From 2fc505d96a00b5b0c47a8b8d570bf9593500a0e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 16:11:10 +0000 Subject: [PATCH 2/2] chore(spec): liveness ledger entry, regenerated references/authorable-surface, changeset (#8778) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --- .../tenancy-organization-field-stamp-only.md | 42 +++++++++++++++++++ content/docs/references/data/object.mdx | 3 +- packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/object.json | 6 +++ packages/spec/liveness/state-counts.md | 4 +- 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 .changeset/tenancy-organization-field-stamp-only.md diff --git a/.changeset/tenancy-organization-field-stamp-only.md b/.changeset/tenancy-organization-field-stamp-only.md new file mode 100644 index 0000000000..72204020cf --- /dev/null +++ b/.changeset/tenancy-organization-field-stamp-only.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-audit": minor +"@objectstack/platform-objects": patch +--- + +feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder) + +The platform had one answer to "what is this object WALLED by" +(`tenancy.tenantField`) and no answer to "which column says who this row is +ABOUT". For ordinary objects the two coincide; for credential tables they +deliberately do not — `sys_api_key` records the organization a key +authenticates into under `active_organization_id` precisely so the credential +table is not org-walled (#8287). #8777's schema-resolved audit stamping could +therefore reach every shipped object except the one that motivated it, and +revocation rows on `sys_api_key` kept stamping the revoker's organization. + +`TenancyConfigSchema` now accepts an optional `organizationField` — a +READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778): + +- The audit writer's `resolveRecordOrganizationField` consults it first, ahead + of the ADR-0066 `enabled: false` opt-out — an author declaring it on an + unwalled object is stating exactly that the audit trail should follow the + record's own organization even though no wall does. It is honoured only when + the object really has the field (the #5315 guard `tenantField` carries). +- No read path reads it: `applyTenantScope`, `injectTenantOnInsert`, + `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all + measured blind to it, and that read-neutrality is pinned by tests beside + each. Declaring it never walls an object and never hides rows. +- ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening + move of a general field-roles mechanism. A consumer other than audit + stamping needs its own ruling before reading it. + +`sys_api_key` now declares +`tenancy: { enabled: false, organizationField: 'active_organization_id' }`, +so revoking another user's key from a different active organization lands the +audit row behind the wall of the KEY's organization — where the tenant admin +who can act on it reads it. The `enabled: false` is measured +behavior-identical to the previous absent block for this object on every read +path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's +tenant field resolves null either way; Layer 0 is exempt either way; the +memory/mongo boot guards count only an explicit `enabled: true`). diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5a51273350..4caf796e19 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -124,7 +124,7 @@ const result = ApiMethod.parse(data); | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. | | **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | -| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | +| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications | | **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). | | **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. | | **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. | @@ -314,6 +314,7 @@ Boolean-or-predicates override for a built-in CRUD affordance. | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | | **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose (#5315). | +| **organizationField** | `string` | optional | STAMP-ONLY (#8778): column carrying the organization a row is ABOUT, consulted exclusively when audit rows are stamped. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | --- diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index ba4da252d7..db07bb56c1 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -840,6 +840,7 @@ "data/StringOperator:$notContains", "data/StringOperator:$startsWith", "data/TenancyConfig:enabled", + "data/TenancyConfig:organizationField", "data/TenancyConfig:tenantField", "data/TursoConfig:authToken [RETIRED]", "data/TursoConfig:concurrency", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index e04c15716d..f132d29b0a 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -150,6 +150,12 @@ "status": "live", "evidence": "packages/drivers/driver-sql/src/sql-driver.ts", "note": "row-level tenant scoping (org-scoping plugin path) reads tenancy.tenantField — audit called it inert; corrected. strategy/crossTenantAccess were REMOVED after spec 15.0 (#2763): zero consumers; tenancy block is now .strict() with tombstone guidance." + }, + "organizationField": { + "status": "live", + "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", + "note": "STAMP-ONLY by the #8778 maintainer ruling (option A): consulted exclusively by resolveRecordOrganizationField when audit rows are stamped, so a credential table can stay unwalled while its trail follows the record's own organization (sys_api_key.active_organization_id, #8707/#8287). Deliberately read by NO tenant-scoping path; read-neutrality is pinned by tests beside applyTenantScope/injectTenantOnInsert (driver-sql), computeTenantLayer0Filter (plugin-security) and resolveInjectedSystemColumns (spec).", + "verifiedAt": "2026-08-15" } } }, diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 427de6ccb7..9861fb40a0 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -27,7 +27,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| -| `object` | 49 | 0 | 0 | 1 | 50 | +| `object` | 50 | 0 | 0 | 1 | 51 | | `field` | 67 | 0 | 0 | 1 | 68 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **774** | **6** | **55** | **8** | **843** | +| **total** | **775** | **6** | **55** | **8** | **844** |