Skip to content

Commit 7901b2d

Browse files
feat(spec): stamp-only tenancy.organizationField — read-neutral organization declaration for audit stamping (#8778) (#8905)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt * chore(spec): liveness ledger entry, regenerated references/authorable-surface, changeset (#8778) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30d3752 commit 7901b2d

13 files changed

Lines changed: 385 additions & 28 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-audit": minor
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
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)
8+
9+
The platform had one answer to "what is this object WALLED by"
10+
(`tenancy.tenantField`) and no answer to "which column says who this row is
11+
ABOUT". For ordinary objects the two coincide; for credential tables they
12+
deliberately do not — `sys_api_key` records the organization a key
13+
authenticates into under `active_organization_id` precisely so the credential
14+
table is not org-walled (#8287). #8777's schema-resolved audit stamping could
15+
therefore reach every shipped object except the one that motivated it, and
16+
revocation rows on `sys_api_key` kept stamping the revoker's organization.
17+
18+
`TenancyConfigSchema` now accepts an optional `organizationField` — a
19+
READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778):
20+
21+
- The audit writer's `resolveRecordOrganizationField` consults it first, ahead
22+
of the ADR-0066 `enabled: false` opt-out — an author declaring it on an
23+
unwalled object is stating exactly that the audit trail should follow the
24+
record's own organization even though no wall does. It is honoured only when
25+
the object really has the field (the #5315 guard `tenantField` carries).
26+
- No read path reads it: `applyTenantScope`, `injectTenantOnInsert`,
27+
`computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all
28+
measured blind to it, and that read-neutrality is pinned by tests beside
29+
each. Declaring it never walls an object and never hides rows.
30+
- ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening
31+
move of a general field-roles mechanism. A consumer other than audit
32+
stamping needs its own ruling before reading it.
33+
34+
`sys_api_key` now declares
35+
`tenancy: { enabled: false, organizationField: 'active_organization_id' }`,
36+
so revoking another user's key from a different active organization lands the
37+
audit row behind the wall of the KEY's organization — where the tenant admin
38+
who can act on it reads it. The `enabled: false` is measured
39+
behavior-identical to the previous absent block for this object on every read
40+
path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's
41+
tenant field resolves null either way; Layer 0 is exempt either way; the
42+
memory/mongo boot guards count only an explicit `enabled: true`).

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ const result = ApiMethod.parse(data);
124124
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| … +42 more>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
125125
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
126126
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
127-
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
127+
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
128128
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
129129
| **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. |
130130
| **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.
314314
| :--- | :--- | :--- | :--- |
315315
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
316316
| **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). |
317+
| **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`. |
317318

318319

319320
---

‎packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,86 @@ describe('SqlDriver tenant scope (organization_id)', () => {
510510
});
511511
});
512512
});
513+
514+
/**
515+
* [#8778] `tenancy.organizationField` is STAMP-ONLY — the driver's tenant
516+
* scoping must be blind to it. The key exists for the audit writer alone
517+
* (which column says who a row is ABOUT); the wall keeps answering a different
518+
* question (what the object is WALLED by) from `enabled` / `tenantField` /
519+
* the `organization_id` column, exactly as before. These cases pin the two
520+
* read paths the ruling names in this package — `applyTenantScope` (reads)
521+
* and `injectTenantOnInsert` (writes) — against the declaration, in both the
522+
* walled and the unwalled (`sys_api_key`-shaped) postures.
523+
*/
524+
describe('tenancy.organizationField is read-neutral in the driver (#8778)', () => {
525+
let driver: SqlDriver;
526+
527+
beforeEach(async () => {
528+
driver = new SqlDriver({
529+
client: 'better-sqlite3',
530+
connection: { filename: ':memory:' },
531+
useNullAsDefault: true,
532+
});
533+
await driver.initObjects([
534+
{
535+
// A WALLED object that also declares the stamp-only key: scoping must
536+
// keep running on `organization_id`, never on `about_org_id`.
537+
name: 'ticket',
538+
tenancy: { enabled: true, organizationField: 'about_org_id' },
539+
fields: {
540+
organization_id: { type: 'string' },
541+
about_org_id: { type: 'string' },
542+
name: { type: 'string' },
543+
},
544+
},
545+
{
546+
// The shipped sys_api_key shape: unwalled (`enabled: false`), stamp
547+
// column under a deliberately different name, no `organization_id`.
548+
name: 'api_key_like',
549+
tenancy: { enabled: false, organizationField: 'active_organization_id' },
550+
fields: {
551+
active_organization_id: { type: 'string' },
552+
name: { type: 'string' },
553+
revoked: { type: 'boolean' },
554+
},
555+
},
556+
]);
557+
});
558+
559+
afterEach(async () => {
560+
await driver.disconnect();
561+
});
562+
563+
it('applyTenantScope keeps walling by organization_id, not the stamp column', async () => {
564+
// A row whose WALL column and STAMP column disagree is the discriminating
565+
// fixture: if the driver ever read `organizationField`, org_b would see it.
566+
await driver.create('ticket', { id: 't1', organization_id: 'org_a', about_org_id: 'org_b', name: 'T1' });
567+
const asA = await driver.find('ticket', {}, { tenantId: 'org_a' });
568+
const asB = await driver.find('ticket', {}, { tenantId: 'org_b' });
569+
expect(asA.map((r) => r.id)).toEqual(['t1']);
570+
expect(asB).toHaveLength(0);
571+
});
572+
573+
it('injectTenantOnInsert stamps organization_id and NEVER the declared stamp column', async () => {
574+
const created = await driver.create('ticket', { id: 't2', name: 'T2' }, { tenantId: 'org_a' });
575+
expect(created.organization_id).toBe('org_a');
576+
// The stamp-only column is the AUDIT WRITER's to fill from the record —
577+
// driver injection writing it would fabricate "who this row is about".
578+
expect(created.about_org_id ?? null).toBeNull();
579+
});
580+
581+
it('the unwalled credential-table shape stays unwalled: reads unscoped, inserts uninjected', async () => {
582+
// Pre-#8287-shaped row: no organization at all. Under any wall reading
583+
// `active_organization_id` or resurrecting a scope, this row vanishes for
584+
// its own owner — the defect #8287 removed and #8778 must not reintroduce.
585+
await driver.create('api_key_like', { id: 'k0', name: 'legacy', revoked: false });
586+
await driver.create('api_key_like', { id: 'k1', name: 'ci', active_organization_id: 'org_b', revoked: false });
587+
588+
const asA = await driver.find('api_key_like', {}, { tenantId: 'org_a' });
589+
expect(asA.map((r) => r.id).sort()).toEqual(['k0', 'k1']);
590+
591+
const created = await driver.create('api_key_like', { id: 'k2', name: 'new' }, { tenantId: 'org_a' });
592+
expect(created.active_organization_id ?? null).toBeNull();
593+
expect('organization_id' in created).toBe(false);
594+
});
595+
});

‎packages/platform-objects/src/identity/sys-api-key.object.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ export const SysApiKey = ObjectSchema.create({
4444
reason: 'Identity table managed by better-auth — see ADR-0010.',
4545
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
4646
},
47+
// [#8778, #8707 remainder] Stamp-only organization declaration — NOT a wall.
48+
//
49+
// `organizationField` tells the audit writer which column carries the
50+
// organization a key row is ABOUT, so history/revocation rows land behind
51+
// the wall of the key's own organization instead of the revoker's active
52+
// one (#8707's repro). It is read by audit stamping ONLY; no tenant-scoping
53+
// path (`applyTenantScope` / `injectTenantOnInsert` /
54+
// `computeTenantLayer0Filter`) reads it — pinned by tests beside each.
55+
//
56+
// `enabled: false` states explicitly what this table's shape already
57+
// implies, and is measured behavior-identical to having no `tenancy` block
58+
// for THIS object on every read path: injection bails on
59+
// `managedBy: 'better-auth'` before tenancy is consulted
60+
// (`resolveInjectedSystemColumns`), the SQL driver's `computeTenantField`
61+
// resolves null either way (no `organization_id`, no `tenantField`), the
62+
// Layer 0 wall is exempt either way (no `organization_id` column), and the
63+
// memory/mongo boot guards count only an explicit `enabled: true`. ⛔ Never
64+
// "upgrade" this to `enabled: true` or move the column to
65+
// `tenancy.tenantField`: both wall the credential table on an equality that
66+
// excludes NULL, and every pre-#8287 key vanishes from its own owner's
67+
// "My Keys" list — the defect #8287 exists to have removed (see the
68+
// `active_organization_id` field comment below).
69+
tenancy: { enabled: false, organizationField: 'active_organization_id' },
4770
description: 'API keys for programmatic access',
4871
displayNameField: 'name',
4972
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,28 +1411,108 @@ describe('audit writers — the record\'s own organization stamps the row (#8707
14111411
expect(stampOf(created).audit?.organization_id).not.toBe('org-parent');
14121412
});
14131413

1414-
it('⛔ KNOWN GAP — `sys_api_key.active_organization_id` is still unreachable', async () => {
1414+
// ── `tenancy.organizationField` — the stamp-only declaration (#8778) ────
1415+
//
1416+
// The former ⛔ KNOWN GAP case lived here: it pinned that
1417+
// `sys_api_key.active_organization_id` was UNREACHABLE and stamped the
1418+
// ACTOR's org, and was written to go red the day a read-neutral, stamp-only
1419+
// declaration landed in `packages/spec`. That day is #8778 (maintainer-ruled
1420+
// option A): the cases below are its rewrite, expecting `org-key`.
1421+
1422+
it('stamps from a declared `tenancy.organizationField` — the #8707 repro, closed (#8778)', async () => {
1423+
const { engine, fire, created } = makeEngine(
1424+
{
1425+
...MULTI_TENANT,
1426+
// As it really ships since #8287: no `organization_id` (better-auth
1427+
// managed tables get no injected system columns), and the org the key
1428+
// authenticates into under a deliberately different name.
1429+
sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'],
1430+
},
1431+
// The shipped declaration shape (sys-api-key.object.ts): the credential
1432+
// table stays unwalled (`enabled: false` — `active_organization_id` is
1433+
// NOT a tenant-scope column and must never become one), while the
1434+
// stamp-only key routes the audit trail to the key's own organization.
1435+
// The declaration WINS over the ADR-0066 opt-out limb: an author who
1436+
// declares it on an unwalled object is stating exactly that the trail
1437+
// follows the record even though no wall does.
1438+
{ sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } },
1439+
);
1440+
installAuditWriters(engine as any, 'test.audit');
1441+
1442+
// The card's repro: revoking a key whose organization differs from the
1443+
// revoker's active one. The row now lands behind the wall of the KEY's
1444+
// organization — where the tenant admin who can act on it reads it — not
1445+
// the revoker's.
1446+
await fire('afterUpdate', {
1447+
object: 'sys_api_key',
1448+
input: { id: 'key-1' },
1449+
previous: { id: 'key-1', name: 'ci', active_organization_id: 'org-key', revoked: false },
1450+
result: { id: 'key-1', name: 'ci', active_organization_id: 'org-key', revoked: true },
1451+
session: { tenantId: 'org-actor', userId: 'user-1' },
1452+
});
1453+
1454+
expect(stampOf(created).audit?.organization_id).toBe('org-key');
1455+
});
1456+
1457+
it('honours `organizationField` only when the field exists (#5315 guard), falling through intact', async () => {
1458+
// A declared stamp column the object does not have must fall through to
1459+
// the rest of the precedence — the same guard `tenantField` carries — and
1460+
// for an `enabled: false` object the fall-through is the ADR-0066 limb:
1461+
// actor's org, exactly the pre-declaration behaviour.
1462+
const { engine, fire, created } = makeEngine(
1463+
{
1464+
...MULTI_TENANT,
1465+
sys_api_key: ['id', 'name', 'user_id', 'revoked'],
1466+
},
1467+
{ sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } },
1468+
);
1469+
installAuditWriters(engine as any, 'test.audit');
1470+
1471+
await fire('afterUpdate', {
1472+
object: 'sys_api_key',
1473+
input: { id: 'key-1' },
1474+
previous: { id: 'key-1', name: 'ci', revoked: false },
1475+
result: { id: 'key-1', name: 'ci', revoked: true },
1476+
session: { tenantId: 'org-actor', userId: 'user-1' },
1477+
});
1478+
1479+
expect(stampOf(created).audit?.organization_id).toBe('org-actor');
1480+
});
1481+
1482+
it('`organizationField` outranks `tenantField` — "who is this row about" beats "what walls it"', async () => {
1483+
// On an object declaring both, the stamp-only key is the more specific
1484+
// answer to the stamping question. (No shipped object declares both; this
1485+
// pins the precedence so the day one does is not a coin flip.)
1486+
const { engine, fire, created } = makeEngine(
1487+
{ ...MULTI_TENANT, crm_lead: ['id', 'name', 'workspace_id', 'about_org_id'] },
1488+
{
1489+
crm_lead: {
1490+
tenancy: { enabled: true, tenantField: 'workspace_id', organizationField: 'about_org_id' },
1491+
},
1492+
},
1493+
);
1494+
installAuditWriters(engine as any, 'test.audit');
1495+
1496+
await fire('afterInsert', {
1497+
object: 'crm_lead',
1498+
input: { id: 'lead-1' },
1499+
result: { id: 'lead-1', name: 'Acme', workspace_id: 'ws-1', about_org_id: 'org-about' },
1500+
session: { tenantId: 'org-actor', userId: 'user-1' },
1501+
});
1502+
1503+
expect(stampOf(created).audit?.organization_id).toBe('org-about');
1504+
});
1505+
1506+
it('control: without the declaration the credential table still stamps the actor\'s org', async () => {
1507+
// The pre-#8778 shape (no `tenancy` block at all). This is what the old
1508+
// KNOWN GAP case pinned; kept as the control proving the new stamp comes
1509+
// from the DECLARATION, not from a hidden heuristic over the column name.
14151510
const { engine, fire, created } = makeEngine({
14161511
...MULTI_TENANT,
1417-
// As it really ships since #8287: no `organization_id` (better-auth
1418-
// managed tables get no injected system columns), and the org the key
1419-
// authenticates into under a deliberately different name.
14201512
sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'],
14211513
});
14221514
installAuditWriters(engine as any, 'test.audit');
14231515

1424-
// The card's repro: revoking a key whose organization differs from the
1425-
// revoker's active one. The precedence above is now correct, but the column
1426-
// is not resolvable — `active_organization_id` is NOT this object's
1427-
// tenant-scope column and must not be declared as one (`tenancy.tenantField`
1428-
// feeds `applyTenantScope` / `injectTenantOnInsert`, so declaring it would
1429-
// wall the credential table on an equality that excludes NULL and make
1430-
// pre-#8287 keys vanish from their own owner's list — the defect #8287
1431-
// exists to have removed).
1432-
//
1433-
// ⚠️ This case pins the REMAINING HALF of #8707, not a decision. It must go
1434-
// red — and be rewritten to expect `org-key` — on the day a read-neutral,
1435-
// stamp-only organization declaration lands in `packages/spec`.
14361516
await fire('afterUpdate', {
14371517
object: 'sys_api_key',
14381518
input: { id: 'key-1' },

0 commit comments

Comments
 (0)