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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/tenancy-organization-field-stamp-only.md
Original file line numberDiff line numberDiff line change
@@ -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`).
3 changes: 2 additions & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,7 +124,7 @@ const result = ApiMethod.parse(data);
| **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. |
| **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. |
Expand DownExpand Up@@ -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`. |


---
Expand Down
83 changes: 83 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
});
23 changes: 23 additions & 0 deletions packages/platform-objects/src/identity/sys-api-key.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
112 changes: 96 additions & 16 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' },
Expand Down
Loading
Loading