diff --git a/.changeset/24822-wall-judged-organization-stored.md b/.changeset/24822-wall-judged-organization-stored.md new file mode 100644 index 00000000000..9a16d8929da --- /dev/null +++ b/.changeset/24822-wall-judged-organization-stored.md @@ -0,0 +1,36 @@ +--- +'@objectstack/objectql': minor +'@objectstack/plugin-security': patch +--- + +fix(objectql,plugin-security): an insert's explicit `organization_id` that the Layer 0 wall judges is stored as sent, not replaced by the active organization + +Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`. + +**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an insert's `organization_id`. The Layer 0 write wall admitted C. Then the engine's static-`readonly` strip took it, because the platform's injected tenant column is `readonly: true`, and the driver stamped A. The insert answered 2xx with the row in A, single and array alike. The only signals were a `droppedFields` entry and a WARN. Under `isolated`, a caller that named its own active organization saw the same strip and report, for a value that ended up stored anyway. + +**What changed.** + +- **`@objectstack/objectql`.** + - `PostHookWriteImageCheck` (reached as `OperationContext.postHookWriteImageCheck`) gains an optional member, `judgesOrganization?: boolean`. An enforcement layer sets it when the seam's `evaluate` holds every inserted row's `organization_id` to the tenant wall. + - `ObjectQL.insert` reads that member to decide. When it is set and the object's `organization_id` is the platform's injected tenant column, a non-system caller's explicit `organization_id` stays out of the pre-hook withhold and out of the static-`readonly` strip. The injected column is told apart by the registry's provenance reader; an authored column keeps the strip. The value is then stored as sent where the wall admits it, and refused by the wall where it does not. + - When the member is absent or `false`, both passes run exactly as before. That covers a seam that carries only a business `check`, a stack without an enforcement layer, and `validate()`, which runs no middleware. +- **`@objectstack/plugin-security`.** + - Step 3.7 sets the member only where it composes the wall's own parts into an insert's stored-image seam. + - It does not set the member on a business-`check`-only seam, on a crossing platform operator's seam, or on any update. + - The wall's admit/refuse set is unchanged: an organization outside the caller's scope is still refused `403 PERMISSION_DENIED`. + +**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and nothing accepted before is refused now. + +- **The optional member.** `PostHookWriteImageCheck.judgesOrganization` (`OperationContext.postHookWriteImageCheck`) is new published surface on `@objectstack/objectql`. +- **The value is stored as sent.** + - Under `group`, an insert naming an organization of the caller's membership set is stored there. + - Under `isolated`, an insert naming the active organization is stored as before. +- **No longer reported.** Such an organization is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN. +- **`strictReadonlyWrites` stores it.** An in-process `engine.insert(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag. +- **`beforeInsert` hooks see it.** On a walled insert, `beforeInsert` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them. +- **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall. +- **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored. +- **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value. +- **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`). +- **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf. diff --git a/packages/objectql/src/engine-insert-wall-judged-organization.test.ts b/packages/objectql/src/engine-insert-wall-judged-organization.test.ts new file mode 100644 index 00000000000..35483465896 --- /dev/null +++ b/packages/objectql/src/engine-insert-wall-judged-organization.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24822] `engine.insert` leaves a non-system caller's explicit +// `organization_id` out of its static-`readonly` passes (the pre-hook withhold +// and the post-hook strip) ONLY when the stored-image seam declares that it +// judges the insert's organization: `PostHookWriteImageCheck.judgesOrganization`. +// +// The engine side of the seam, pinned with a stand-in installer. The installer +// in production is `@objectstack/plugin-security`'s step 3.7. Its side, and the +// two packages together on a real stack, are pinned in +// `plugin-security/src/insert-seam-judges-organization.test.ts` and +// `organizations/src/create-explicit-organization-wall.test.ts`. +// +// What this file holds the engine to: +// - the member SET: the value is stored as sent, shown to `beforeInsert`, +// handed to the seam, not reported, and not refused under +// `strictReadonlyWrites`. Every OTHER read-only key is still taken, and so +// is an AUTHORED read-only `organization_id`: only the platform's injected +// column is spared; +// - the member ABSENT or `false` on an installed seam, and no seam at all: +// the strip takes the value exactly as before (reported, withheld from the +// hook, refused under strict). Seam presence is not the signal: a business +// `check` installs a seam with no wall behind it; +// - `validate()` runs no middleware, so it holds no seam, and its preview is +// unchanged. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return row?.[k] === v; + }); + }; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound, applied AFTER the filter and by PRESENCE + // (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const row of [...s.values()]) { + if (!matches(row, ast?.where)) continue; + s.set(row.id, { ...row, ...data, id: row.id }); + count += 1; + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const OBJECT = 'qa_ledger'; +const ACTIVE = 'org_a'; +const NAMED = 'org_c'; +const SESSION = { userId: 'usr_member', tenantId: ACTIVE }; +const AUTHORED = 'qa_authored'; + +/** How the stand-in installer behaves on this engine's next insert. */ +type SeamMode = 'judges' | 'judges-false' | 'no-member' | 'none'; + +describe('[#24822] engine.insert spares organization_id only where the seam declares the organization judged', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let seamMode: SeamMode; + /** `organization_id` as `beforeInsert` saw it: `'absent'` when the key was not on the row. */ + let hookSaw: unknown[]; + /** `organization_id` on every row the seam judged. */ + let seamJudged: unknown[]; + let warns: string[]; + + beforeEach(async () => { + warns = []; + hookSaw = []; + seamJudged = []; + seamMode = 'judges'; + const logger: any = { + warn: (m: string) => warns.push(String(m)), + debug() {}, info() {}, error() {}, trace() {}, fatal() {}, + child() { return logger; }, + }; + engine = new ObjectQL({ logger }); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ name: OBJECT, fields: { name: { type: 'text' } } } as any); + // [#24822] F4: an app object whose AUTHOR declares its own read-only + // organization_id. The member alone does not spare it. + engine.registry.registerObject({ + name: AUTHORED, + fields: { name: { type: 'text' }, organization_id: { type: 'text', readonly: true } }, + } as any); + + engine.registerHook('beforeInsert', async (ctx: any) => { + const data = (ctx.input?.data ?? {}) as Record; + hookSaw.push(Object.prototype.hasOwnProperty.call(data, 'organization_id') ? data.organization_id : 'absent'); + }, { object: OBJECT }); + + // The stand-in installer: what an enforcement layer puts on an insert. + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'insert' && seamMode !== 'none') { + opCtx.postHookWriteImageCheck = { + evaluate: (rows: readonly Record[]) => { + for (const row of rows) seamJudged.push(row.organization_id); + }, + ...(seamMode === 'judges' ? { judgesOrganization: true } : {}), + ...(seamMode === 'judges-false' ? { judgesOrganization: false } : {}), + }; + } + await next(); + }); + }); + + const insertReporting = async (data: unknown, extra: Record = {}) => { + const dropped: Array<{ fields: string[]; reason: string }> = []; + await engine.insert(OBJECT, data as any, { + context: SESSION, + onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }), + ...extra, + } as any); + return dropped; + }; + const stored = () => [...storeFor(OBJECT).values()].map((r) => r.organization_id); + const readonlyWarns = () => warns.filter((w) => w.includes("Field 'organization_id'")); + + it('PRECONDITION: the platform injects organization_id as a read-only column on this object', () => { + const field = (engine.getSchema(OBJECT) as any)?.fields?.organization_id; + expect(field?.readonly, 'the strip has the column to take').toBe(true); + }); + + it('SET: the named organization is stored as sent, shown to beforeInsert, handed to the seam, and not reported', async () => { + const dropped = await insertReporting({ name: 'one', organization_id: NAMED }); + + expect(stored()).toEqual([NAMED]); + expect(dropped).toEqual([]); + expect(readonlyWarns()).toEqual([]); + expect(hookSaw).toEqual([NAMED]); + expect(seamJudged, 'the seam judges the value the driver stores').toEqual([NAMED]); + }); + + it('SET: an array insert keeps each row\'s named organization', async () => { + const dropped = await insertReporting([ + { name: 'one', organization_id: NAMED }, + { name: 'two', organization_id: ACTIVE }, + ]); + + expect(stored()).toEqual([NAMED, ACTIVE]); + expect(dropped).toEqual([]); + expect(seamJudged).toEqual([NAMED, ACTIVE]); + }); + + it('SET spares organization_id ONLY: another read-only key the caller sends is still taken and reported', async () => { + const dropped = await insertReporting({ name: 'one', organization_id: NAMED, created_by: 'usr_forged' }); + + expect(stored()).toEqual([NAMED]); + expect(dropped).toEqual([{ fields: ['created_by'], reason: 'readonly' }]); + expect([...storeFor(OBJECT).values()][0]?.created_by, 'the forged audit value is not stored').not.toBe('usr_forged'); + }); + + it('SET, but an AUTHORED read-only organization_id: the author\'s declaration keeps the strip, and strict refuses it', async () => { + const dropped: Array<{ fields: string[]; reason: string }> = []; + await engine.insert(AUTHORED, { name: 'one', organization_id: NAMED } as any, { + context: SESSION, + onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }), + } as any); + + expect([...storeFor(AUTHORED).values()].map((r) => r.organization_id), 'the caller\'s value is not stored').toEqual([undefined]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + + let thrown: { code?: unknown; fields?: unknown } | undefined; + try { + await engine.insert(AUTHORED, { name: 'two', organization_id: NAMED } as any, { context: SESSION, strictReadonlyWrites: true } as any); + } catch (e) { + thrown = e as typeof thrown; + } + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(thrown?.fields).toEqual(['organization_id']); + }); + + it('SET: strictReadonlyWrites does not refuse it, because strict refuses only what the strip takes', async () => { + await insertReporting({ name: 'one', organization_id: NAMED }, { strictReadonlyWrites: true }); + + expect(stored()).toEqual([NAMED]); + }); + + for (const mode of ['no-member', 'judges-false', 'none'] as const) { + describe(`NOT SET (${mode === 'none' ? 'no seam at all' : mode === 'no-member' ? 'a seam that does not declare it' : 'the member false'}): the strip takes the value exactly as before`, () => { + beforeEach(() => { seamMode = mode; }); + + it('taken, withheld from beforeInsert, reported, and WARNed', async () => { + const dropped = await insertReporting({ name: 'one', organization_id: NAMED }); + + expect(stored(), 'the caller\'s organization is not stored').toEqual([undefined]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + expect(readonlyWarns()).toHaveLength(1); + expect(hookSaw, 'withheld from the hook, as #22306 requires').toEqual(['absent']); + expect(seamJudged).toEqual(mode === 'none' ? [] : [undefined]); + }); + + it('refused under strictReadonlyWrites, and nothing is stored', async () => { + let thrown: { code?: unknown; fields?: unknown; operation?: unknown } | undefined; + try { + await insertReporting({ name: 'one', organization_id: NAMED }, { strictReadonlyWrites: true }); + } catch (e) { + thrown = e as typeof thrown; + } + expect(thrown, 'expected the strict write to be refused').toBeDefined(); + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(thrown?.fields).toEqual(['organization_id']); + expect(thrown?.operation).toBe('insert'); + expect(stored()).toEqual([]); + }); + }); + } + + it('a system context stores what it sends with or without the member: the strips never run for it', async () => { + seamMode = 'none'; + await engine.insert(OBJECT, { name: 'one', organization_id: NAMED } as any, { context: { isSystem: true } } as any); + seamMode = 'judges'; + await engine.insert(OBJECT, { name: 'two', organization_id: NAMED } as any, { context: { isSystem: true } } as any); + + expect(stored()).toEqual([NAMED, NAMED]); + }); + + it('validate() runs no middleware, so it holds no seam: its preview still reports the strip it always reported', async () => { + const preview = await engine.validate(OBJECT, { name: 'one', organization_id: NAMED }, { mode: 'insert', context: SESSION as any }); + + expect(preview.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['organization_id'], reason: 'readonly' }]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2527f2cc998..df234700538 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -29,7 +29,7 @@ import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/ // [#22646] The ADR-0112 catalog — `rejectCredentialAggregation`'s refusal // envelope carries `INVALID_FIELD` from it rather than a bare `Error`. import { StandardErrorCode } from '@objectstack/spec/api'; -import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data'; import type { FileRefusedValue } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. @@ -2388,6 +2388,47 @@ function callerSuppliedRow(row: unknown, sent: ReadonlySet | undefined): return copy; } +/** + * [#24822] The fields ONE insert's static-`readonly` passes may judge: the + * {@link staticReadonlyInsertSubject} view, without `organization_id` when the + * Layer 0 wall judges that column on this insert's stored rows AND the column + * is the platform's injected tenant column. + * + * The wall says so itself, on the seam it installs + * (`PostHookWriteImageCheck.judgesOrganization`). The caller passes that + * member's answer, for a non-system caller only. Nothing here derives it: + * where no wall judged the column, the strip takes a caller's value exactly + * as before. + * + * Why the column is left on the row: the wall has already refused, before + * this engine ran, every supplied value it does not admit. It judges the + * stored row again after the hooks. So a value that reaches the strip is one + * the wall admitted, and taking it stored the active organization in its + * place. That is a silent replacement of an admitted input, which ADR-0105 D5 + * and the organizations runtime's stamp rule out. + * + * ONE function for both passes that read the subject, the pre-hook withhold + * and the post-hook strip, so the hook view and the stored row cannot + * disagree about the column. + */ +function insertStaticReadonlySubject( + schema: unknown, + wallJudgesOrganization: boolean, +): ReturnType { + const subject = staticReadonlyInsertSubject(schema as any); + if (!subject || !wallJudgesOrganization) return subject; + if (!Object.prototype.hasOwnProperty.call(subject.fields, SystemFieldName.ORGANIZATION_ID)) return subject; + // An AUTHORED `organization_id` declared `readonly: true` is the author's + // own declaration, and declaration is enforcement: it keeps the strip. Only + // the platform's injected tenant column is spared, the one + // `TENANT_SCOPE_FIELD_DEF` declares read-only on the platform's behalf. Which + // one this is, is the registry's own provenance reader, never a second copy. + if (resolveInjectedColumnProvenance(schema, SystemFieldName.ORGANIZATION_ID) === 'author') return subject; + const fields = { ...subject.fields }; + delete fields[SystemFieldName.ORGANIZATION_ID]; + return Object.keys(fields).length > 0 ? { name: subject.name, fields } : null; +} + /** * [#22306] The insert side of the #16344 invariant — a hook is never handed a * value that will not be stored. Takes out of ONE caller row every value the @@ -2450,6 +2491,10 @@ function callerSuppliedRow(row: unknown, sent: ReadonlySet | undefined): * Pure: neither the caller's row nor `supplied` is touched. The row keeps its * reference when nothing was taken, else it is a shallow copy without the keys. * + * [#24822] `wallJudgesOrganization` spares `organization_id` here exactly as + * the post-hook strip spares it ({@link insertStaticReadonlySubject}). A value + * the wall judges will be stored, so the hook is shown it. + * * @returns the row the hook view is built from, and the withheld keys with the * caller's values (`undefined` when nothing was withheld) — handed back after * the hooks by {@link handBackWithheldInsertReadonly}. @@ -2459,8 +2504,9 @@ function withholdInsertReadonlyFromHooks( row: Record, supplied: Readonly>, preserveAudit: boolean, + wallJudgesOrganization: boolean, ): { row: Record; withheld: Record | undefined } { - let subject = staticReadonlyInsertSubject(schema as any); + let subject = insertStaticReadonlySubject(schema, wallJudgesOrganization); if (subject && preserveAudit) { const fields = { ...subject.fields }; for (const name of AUDIT_PROVENANCE_FIELDS) delete fields[name]; @@ -3698,6 +3744,29 @@ export interface OperationContext { export interface PostHookWriteImageCheck { evaluate(rows: readonly Record[]): void | Promise; honoured?: boolean; + /** + * [#24822] Declared by the installer, and only by it: on an `insert`, + * `evaluate` holds every row's `organization_id` to the Layer 0 tenant wall. + * It admits the value or refuses the write by throwing. + * `@objectstack/plugin-security` sets it at step 3.7, only where it + * composes the wall's own parts into the insert's seam. A seam that carries + * a business `check` alone does not judge the organization, and neither + * does a platform operator's seam that crosses the wall, so neither sets it. + * + * {@link ObjectQL.insert} reads this member, and nothing else, to decide one + * thing: whether a non-system caller's explicit `organization_id` stays on + * the row for that judgement or is taken by the static-`readonly` strip + * (the platform's tenant column declares `readonly: true`). Set, the value + * is stored as sent where the wall admits it and refused where it does not. + * It is never silently replaced by the active organization (ADR-0105 D5). + * Only the platform's injected column is spared. An AUTHORED + * `organization_id` declared `readonly: true` keeps the strip. + * Absent or `false`, the strip runs exactly as before. The engine never + * sets it, and never derives it from the posture or the context: that + * would be a mirror of the wall, and a mirror is wider than the wall + * wherever the wall is not mounted. + */ + judgesOrganization?: boolean; } /** @@ -15830,6 +15899,14 @@ export class ObjectQL implements IObjectQLEngine { // write. `opCtx.data` keeps the caller's payload; only the hook view is // built from the withheld rows. ⛔ `isSystem` withholds nothing — the // strips do not run for it, so every value it sends is stored. + // + // [#24822] Read ONCE, here, and by both static-`readonly` passes: whether + // the Layer 0 wall judges this insert's `organization_id` on the stored + // rows. The wall declares it on the seam it installed, and the engine + // reads that member and nothing else (`insertStaticReadonlySubject`). + // The middleware chain has run by now, so the seam is in place. + const wallJudgesOrganization = + !opCtx.context?.isSystem && opCtx.postHookWriteImageCheck?.judgesOrganization === true; const insertWithheld: Array | undefined> = []; const hookViewSource: unknown[] = (isBatch ? (opCtx.data as unknown[]) : [opCtx.data]).slice(); if (!opCtx.context?.isSystem) { @@ -15841,6 +15918,7 @@ export class ObjectQL implements IObjectQLEngine { if (!row || typeof row !== 'object' || Array.isArray(row)) continue; const hidden = withholdInsertReadonlyFromHooks( hideSchema, row as Record, suppliedPerRow[i] ?? {}, preserveAuditForHide, + wallJudgesOrganization, ); insertWithheld[i] = hidden.withheld; hookViewSource[i] = hidden.row; @@ -16258,7 +16336,14 @@ export class ObjectQL implements IObjectQLEngine { // to anything", so a user-writable bucket is judged here exactly as it // is on update); `null` — no such field on this object — is the cheap // exit every ordinary insert takes. - const readonlySubject = staticReadonlyInsertSubject(schemaForValidation as any); + // + // [#24822] ...without `organization_id` where the Layer 0 wall judges it + // on this insert's stored rows (`wallJudgesOrganization`, read once + // above for the withhold too). That value is stored as sent or refused + // by the wall, never taken here and replaced by the active organization. + // So it is not reported or refused under `strictReadonlyWrites` either: + // strict refuses exactly what this strip takes. + const readonlySubject = insertStaticReadonlySubject(schemaForValidation, wallJudgesOrganization); if (readonlySubject) { const preserveAuditIgnored: string[] = []; for (let i = 0; i < rows.length; i++) { diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 83dc53d56fb..85ec7f8bbba 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -348,6 +348,20 @@ const READERS: Record = { 'tells an authored organization_id from the injection while re-planning registered schemas ' + '(ADR-0131 D7); it reads definitions, never a row', }, + // [#24822] The insert's static-`readonly` subject, without the tenant column + // where the Layer 0 wall's seam declares it judges the insert's organization. + 'engine.ts#insertStaticReadonlySubject :: resolveInjectedColumnProvenance()': { + disposition: 'not-a-read', + why: + "asks whether the insert's organization_id is the platform's injected column or the author's; " + + 'it reads a registered definition, never storage', + }, + 'engine.ts#insertStaticReadonlySubject :: organization_id': { + disposition: 'not-a-read', + why: + "leaves the column out of an insert's static-readonly subject where the wall's seam judges it; " + + 'it reads a schema view and the caller row in hand, never storage', + }, 'tenancy/system-write-organization.ts# :: organization_id': { disposition: 'not-a-read', why: 'the declaration of the default tenant column name', diff --git a/packages/plugins/organizations/src/create-explicit-organization-wall.test.ts b/packages/plugins/organizations/src/create-explicit-organization-wall.test.ts index a6e35e0b93d..fed0d494cdd 100644 --- a/packages/plugins/organizations/src/create-explicit-organization-wall.test.ts +++ b/packages/plugins/organizations/src/create-explicit-organization-wall.test.ts @@ -39,9 +39,14 @@ * `sys_user_permission_set`, where the defect was measured: nothing but the * wall stands between a supplied value and the stored row. * - `qa_ledger` carries the platform-injected `organization_id`, the shape of - * every app object. The engine strips that column from a non-system payload - * as `readonly` AFTER the wall has judged it, so here an admitted value is - * re-derived, and a refused one never reaches the strip. + * every app object. The column is `readonly`, and [#24822] the engine's + * static-`readonly` strip leaves a non-system caller's explicit value on the + * row only where the wall judges it: the wall's seam declares + * `judgesOrganization`. There an admitted value is stored as sent and a + * refused one never reaches the strip. Everywhere else, the strip takes the + * value exactly as before. Those paths are a `single` boot, a stack without + * the security plugin, a platform operator crossing the wall, and a seam + * that carries a business `check` alone. The last block pins both halves. * * ## [#21682] The stamp is not a caller write * @@ -70,6 +75,9 @@ const SISTER_ORG = 'org_south'; const DECLARED = 'qa_assignment'; const INJECTED = 'qa_ledger'; +const PRIVATE = 'qa_vault'; +const UNWALLED = 'qa_catalog'; +const AUTHORED = 'qa_authored'; const OBJECTS = [ { @@ -89,6 +97,42 @@ const OBJECTS = [ name: { name: 'name', type: 'text' }, }, }, + // [#24822] A PRIVATE object, also on the injected column: its posture lets a + // platform operator CROSS the wall (ADR-0095 D3), so the wall judges no + // organization for that caller there. + { + name: PRIVATE, + label: 'Vault', + access: { default: 'private' }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + }, + }, + // [#24822] A tenancy-enabled app object whose AUTHOR declares its own + // read-only `organization_id`. The wall still judges it, and the author's + // declaration keeps the static-`readonly` strip. + { + name: AUTHORED, + label: 'Authored', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text', readonly: true }, + }, + }, + // [#24822] Tenancy switched off, and an AUTHORED read-only `organization_id`: + // Layer 0 composes nothing on it, so no wall judges the column. + { + name: UNWALLED, + label: 'Catalog', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text', readonly: true }, + }, + }, ]; /** Plain CRUD and no row-level policy — the wall is the only thing judging the organization. */ @@ -98,6 +142,18 @@ const MEMBER: PermissionSet = { objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, } as unknown as PermissionSet; +/** + * [#24822] The same member under a business insert `check` on the injected-column + * object. Step 3.6 then installs the stored-image seam on its own, with no wall + * behind it wherever the posture composes none. + */ +const MEMBER_WITH_CHECK: PermissionSet = { + ...MEMBER, + rowLevelSecurity: [ + { name: 'ledger_not_blocked', object: INJECTED, operation: 'insert', check: "record.name != 'blocked'" }, + ], +} as unknown as PermissionSet; + /** A platform operator: the superuser bit AND a platform-exclusive capability (ADR-0095 D3). */ const PLATFORM_ADMIN: PermissionSet = { name: 'admin_full_access', @@ -127,19 +183,39 @@ afterEach(async () => { } }); +/** + * [#24822] What an insert's stored-image seam looked like once every middleware + * had run: whether one was installed, and whether it declared that it judges + * the organization (`PostHookWriteImageCheck.judgesOrganization`). + */ +interface SeamSighting { installed: boolean; judgesOrganization: unknown } + interface Booted { engine: ObjectQL; /** `organization_id` as the engine handed it to the `beforeInsert` chain, per insert. */ seenByHooks: unknown[]; /** A table's rows read straight off the driver, past every scope. */ table: (name: string) => Promise>>; + /** [#24822] One sighting per insert that reached the engine. */ + seams: SeamSighting[]; + /** [#24822] Every line the engine logged at WARN. */ + warns: string[]; } /** * `organizations: false` boots without this package, the way `serve` boots the - * `single` posture — the control for the [#21682] block. + * `single` posture — the control for the [#21682] block. [#24822] + * `security: false` boots without the Layer 0 wall, and `member` replaces the + * member's permission set. */ -async function boot(posture: Posture = 'isolated', { organizations: mountOrganizations = true } = {}): Promise { +async function boot( + posture: Posture = 'isolated', + { + organizations: mountOrganizations = true, + security: mountSecurity = true, + member = MEMBER, + }: { organizations?: boolean; security?: boolean; member?: PermissionSet } = {}, +): Promise { const engine = new ObjectQL(); engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true); await engine.init(); @@ -166,7 +242,7 @@ async function boot(posture: Posture = 'isolated', { organizations: mountOrganiz objectql: engine, metadata: { get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [MEMBER, PLATFORM_ADMIN], + list: async () => [member, PLATFORM_ADMIN], }, tenancy: { posture }, }; @@ -185,24 +261,41 @@ async function boot(posture: Posture = 'isolated', { organizations: mountOrganiz hook: vi.fn(), }; const organizations = mountOrganizations ? new OrganizationsPlugin({ ensureDefaultOrganization: false }) : null; - const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const security = mountSecurity ? new SecurityPlugin({ fallbackPermissionSet: 'member_default' }) : null; // Kernel order: every init, then every start — organizations first, as `serve` mounts it. await organizations?.init(ctx as never); - await security.init(ctx as never); + await security?.init(ctx as never); await organizations?.start(ctx as never); - await security.start(ctx as never); + await security?.start(ctx as never); if (organizations) { expect(services['org-scoping'], 'the real runtime is the org-scoping service').toBe(organizations); } else { expect('org-scoping' in services, 'the control boots without the runtime').toBe(false); } // The expected refusals log at WARN through the engine's own logger. - vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + const warns: string[] = []; + vi.spyOn((engine as unknown as { logger: { warn: (m: unknown) => void } }).logger, 'warn') + .mockImplementation((m: unknown) => { warns.push(String(m)); }); + + // [#24822] Registered LAST, so it runs innermost: every other middleware has + // installed what it installs on the operation by the time this reads it. + const seams: SeamSighting[] = []; + engine.registerMiddleware((async (opCtx: { operation: string; postHookWriteImageCheck?: { judgesOrganization?: unknown } }, next: () => Promise) => { + if (opCtx.operation === 'insert') { + seams.push({ + installed: opCtx.postHookWriteImageCheck !== undefined, + judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization, + }); + } + await next(); + }) as never); for (const object of [DECLARED, INJECTED]) { await engine.insert(object, { id: 'r1', name: 'seeded', organization_id: OWN_ORG }, { context: SYS_CTX } as never); } seenByHooks.length = 0; + seams.length = 0; + warns.length = 0; const table = async (name: string) => { const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(name); @@ -211,7 +304,7 @@ async function boot(posture: Posture = 'isolated', { organizations: mountOrganiz return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); }; - return { engine, seenByHooks, table }; + return { engine, seenByHooks, table, seams, warns }; } interface Outcome { ok: boolean; code?: string; status?: number; message?: string } @@ -360,6 +453,9 @@ const createReporting = async (b: Booted, object: string, data: unknown, context return { outcome, dropped }; }; +/** [#24822] The engine's read-only WARN lines that name `organization_id`. */ +const organizationWarnings = (b: Booted) => b.warns.filter((line) => line.includes("Field 'organization_id'")); + describe('[#21682] droppedFields names only keys the caller sent, so the organization stamp is not one', () => { for (const [who, context] of CALLERS) { it(`${who}: a walled create naming no organization reports no dropped field, and is stored in the active organization`, async () => { @@ -374,13 +470,20 @@ describe('[#21682] droppedFields names only keys the caller sent, so the organiz }); } - it('a caller that sends the readonly key itself still sees it reported, the same key the stamp fills', async () => { + // [#24822] This cell used to pin the report of the key: the strip took the + // caller's own organization and the stamp put the same value back. The wall + // judges that value on a walled insert, so it is now stored as sent, and + // nothing was dropped to report. Its `single` twin below, where no wall + // judges the column, still reports it. + it('[#24822] isolated: a caller that sends its own active organization has it stored as sent, and nothing is reported', async () => { const b = await boot(); const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: OWN_ORG }, MEMBER_CTX); expect(outcome.ok, outcome.message).toBe(true); - expect(dropped).toEqual([{ object: INJECTED, fields: ['organization_id'], reason: 'readonly' }]); + expect(dropped, 'a value stored as sent is not a dropped field').toEqual([]); + expect(organizationWarnings(b), 'and no read-only WARN names it').toEqual([]); + expect(b.seenByHooks, 'the beforeInsert chain sees the value the caller sent').toEqual([OWN_ORG]); expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); }); @@ -426,3 +529,350 @@ describe('[#21682] droppedFields names only keys the caller sent, so the organiz }); }); }); + +/** + * [#24822] An explicit `organization_id` on the platform-injected column is + * stored as sent where the Layer 0 wall judges it, and taken as before + * everywhere else. + * + * The wall has always judged a supplied value (the payload half of step 3.7), + * and since #20013 it judges the stored row too. But the engine's + * static-`readonly` strip then took the admitted value (the column declares + * `readonly: true`), and the driver stamped the active organization in its + * place: a 2xx, and a row in another organization than the one the caller + * named and the wall admitted. Now the wall's seam declares that it judges + * the insert's organization (`judgesOrganization`), and only then does the + * engine leave the value for that verdict. + * + * The negative half is the larger one. Every path where no wall judged the + * column keeps the strip, the WARN and the `droppedFields` report it had, + * cell for cell: a `single` boot with and without a business `check` seam, + * `group` and `isolated` without the security plugin, a tenancy-disabled + * object with an authored read-only column, a platform operator crossing the + * wall, and a system context, which the strip never judged. Honouring the + * value on any of them would store a caller's organization with no wall + * verdict at all. + */ +describe('[#24822] an explicit organization_id the wall judges is stored as sent; nowhere else is it honoured', () => { + const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; + const GROUP_ADMIN_CTX = { ...ADMIN_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; + /** A platform operator with NO active organization (ADR-0123 D2 / #24821). */ + const ADMIN_NO_ORG_CTX = { userId: 'usr_admin', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; + const READONLY_DROP = (object: string) => [{ object, fields: ['organization_id'], reason: 'readonly' }]; + const WALL_JUDGES = [{ installed: true, judgesOrganization: true }]; + + const createStrict = (b: Booted, object: string, data: unknown, context: object) => + attempt(() => b.engine.insert(object, data as never, { context, strictReadonlyWrites: true } as never)); + + /** The strict refusal on its own envelope: the code, the field and the verb. */ + const expectStrictRefusal = async (b: Booted, object: string, data: unknown, context: object) => { + let thrown: { code?: unknown; fields?: unknown; operation?: unknown } | undefined; + try { + await b.engine.insert(object, data as never, { context, strictReadonlyWrites: true } as never); + } catch (e) { + thrown = e as typeof thrown; + } + expect(thrown, 'expected the strict write to be refused').toBeDefined(); + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(thrown?.fields).toEqual(['organization_id']); + expect(thrown?.operation).toBe('insert'); + }; + + describe('`group`: the wall is the membership set', () => { + it('a member\'s create naming a sister organization is stored THERE, with nothing reported', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, GROUP_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped, 'a value stored as sent is not a dropped field').toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(b.seenByHooks, 'the beforeInsert chain sees the value that will be stored').toEqual([SISTER_ORG]); + expect(b.seams, 'the wall declared that it judges the organization').toEqual(WALL_JUDGES); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: SISTER_ORG }]); + }); + + it('the array insert answers the same, row by row, beside a row the stamp fills', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, INJECTED, [ + { id: 'r2', name: 'named', organization_id: SISTER_ORG }, + { id: 'r3', name: 'left to the stamp' }, + ], GROUP_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(b.seams).toEqual(WALL_JUDGES); + expect(await b.table(INJECTED)).toEqual([ + ...SEEDED, + { id: 'r2', organization_id: SISTER_ORG }, + { id: 'r3', organization_id: OWN_ORG }, + ]); + }); + + it('a create naming an organization outside the membership set is still refused, single and array, and nothing is stored', async () => { + const b = await boot('group'); + + const single = await attempt(() => create(b, INJECTED, { id: 'r2', name: 'new', organization_id: FOREIGN_ORG }, GROUP_CTX)); + const bulk = await attempt(() => create(b, INJECTED, [{ id: 'r3', name: 'new', organization_id: FOREIGN_ORG }], GROUP_CTX)); + + expectWallRefusal(single, 'insert', INJECTED); + expectWallRefusal(bulk, 'insert', INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED); + }); + + it('a create naming no organization is stamped with the active one and reports nothing', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new' }, GROUP_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + }); + + it('an in-process strictReadonlyWrites create naming a sister organization is stored, not refused: strict refuses only what the strip takes', async () => { + const b = await boot('group'); + + const outcome = await createStrict(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, GROUP_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: SISTER_ORG }]); + }); + }); + + describe('an AUTHORED read-only organization_id on a walled object (F4)', () => { + // [#24822] F4: the wall judges this column too (the object stays walled on + // an authored `organization_id`), and the seam declares it. But the + // author declared the column read-only, and declaration is enforcement: + // only the platform's injected column is spared. + it('an AUTHORED read-only organization_id on a walled object keeps the strip: taken, reported, the active organization stored, and refused under strict', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, AUTHORED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, GROUP_CTX); + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(AUTHORED), 'the author\'s read-only column is not written by the caller').toEqual([{ id: 'r2', organization_id: OWN_ORG }]); + expect(dropped).toEqual(READONLY_DROP(AUTHORED)); + expect(organizationWarnings(b)).toHaveLength(1); + + await expectStrictRefusal(b, AUTHORED, { id: 'r3', name: 'strict', organization_id: SISTER_ORG }, GROUP_CTX); + expect(await b.table(AUTHORED)).toEqual([{ id: 'r2', organization_id: OWN_ORG }]); + expect(b.seams, 'the wall still judges an authored column and declares it').toEqual([...WALL_JUDGES, ...WALL_JUDGES]); + }); + }); + + // [#24822 · ADR-0123 D2] F1: a member with a membership set and NO active + // organization. Before this change the strip took the named organization, the + // stored row named none, and the D2 arm refused it. The arm's scope is a row + // that names no organization, and this row names one the wall admits. + describe('`group`, a member with NO active organization', () => { + const NO_ACTIVE_ORG_CTX = { userId: 'usr_member', positions: [], permissions: [], posture: 'MEMBER', accessible_org_ids: [OWN_ORG, SISTER_ORG] }; + + it('a create naming a member organization is stored THERE, single and array, with nothing reported', async () => { + const b = await boot('group'); + + const single = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, NO_ACTIVE_ORG_CTX); + const bulk = await createReporting(b, INJECTED, [{ id: 'r3', name: 'new', organization_id: SISTER_ORG }], NO_ACTIVE_ORG_CTX); + + expect(single.outcome.ok, single.outcome.message).toBe(true); + expect(bulk.outcome.ok, bulk.outcome.message).toBe(true); + expect(await b.table(INJECTED)).toEqual([ + ...SEEDED, + { id: 'r2', organization_id: SISTER_ORG }, + { id: 'r3', organization_id: SISTER_ORG }, + ]); + expect([...single.dropped, ...bulk.dropped]).toEqual([]); + expect(b.seams).toEqual([...WALL_JUDGES, ...WALL_JUDGES]); + }); + + it('a create naming an organization outside the membership set is still refused by the wall, and nothing is stored', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => create(b, INJECTED, { id: 'r2', name: 'new', organization_id: FOREIGN_ORG }, NO_ACTIVE_ORG_CTX)); + + expectWallRefusal(outcome, 'insert', INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED); + }); + + it('a create naming no organization still gets the ADR-0123 D2 refusal: it has nowhere to land', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => create(b, INJECTED, { id: 'r2', name: 'new' }, NO_ACTIVE_ORG_CTX)); + + expect(outcome.ok, 'expected the no-active-organization refusal').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain('has no active organization'); + expect(await b.table(INJECTED)).toEqual(SEEDED); + }); + }); + + describe('`isolated`: the wall is the active organization', () => { + it('a create naming the active organization is stored as sent, with nothing reported', async () => { + const b = await boot('isolated'); + + const { outcome, dropped } = await createReporting(b, INJECTED, [{ id: 'r2', name: 'new', organization_id: OWN_ORG }], MEMBER_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(b.seams).toEqual(WALL_JUDGES); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + }); + + // [#24822] F3: Middleware A fills an empty value only on a single-row + // payload, so an ARRAY row's explicit `null` or `''` reaches the engine. + // It is no longer taken and reported (or refused under strict); the + // driver's stamp still fills it with the active organization. + it('an array insert with an explicit empty organization is stamped with the active one, and nothing is reported or refused', async () => { + const b = await boot('isolated'); + + const { outcome, dropped } = await createReporting(b, INJECTED, [ + { id: 'r2', name: 'null', organization_id: null }, + { id: 'r3', name: 'empty', organization_id: '' }, + ], MEMBER_CTX); + const strict = await createStrict(b, INJECTED, [{ id: 'r4', name: 'strict', organization_id: '' }], MEMBER_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(strict.ok, strict.message).toBe(true); + expect(await b.table(INJECTED)).toEqual([ + ...SEEDED, + { id: 'r2', organization_id: OWN_ORG }, + { id: 'r3', organization_id: OWN_ORG }, + { id: 'r4', organization_id: OWN_ORG }, + ]); + expect(dropped).toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + }); + + it('a create naming any other organization is still refused, single and array, and nothing is stored', async () => { + const b = await boot('isolated'); + + const single = await attempt(() => create(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, MEMBER_CTX)); + const bulk = await attempt(() => create(b, INJECTED, [{ id: 'r3', name: 'new', organization_id: SISTER_ORG }], MEMBER_CTX)); + + expectWallRefusal(single, 'insert', INJECTED); + expectWallRefusal(bulk, 'insert', INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED); + }); + }); + + describe('where no wall judges the column, the strip takes the value exactly as before', () => { + it('`single` without this package: taken, reported, and stored in the context\'s organization; no seam', async () => { + const b = await boot('single', { organizations: false }); + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, MEMBER_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual(READONLY_DROP(INJECTED)); + expect(organizationWarnings(b)).toHaveLength(1); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined }]); + }); + + it('`single` with a business insert `check`: its seam is installed and does NOT declare the organization judged, so the value is taken', async () => { + const b = await boot('single', { organizations: false, member: MEMBER_WITH_CHECK }); + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, MEMBER_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(INJECTED), 'the caller\'s organization is not stored').toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + expect(dropped).toEqual(READONLY_DROP(INJECTED)); + expect(organizationWarnings(b)).toHaveLength(1); + expect(b.seams, 'the business check installs a seam with no wall behind it').toEqual([ + { installed: true, judgesOrganization: undefined }, + ]); + }); + + it('`single` without a wall: an in-process strictReadonlyWrites create naming an organization is still refused, and nothing is stored', async () => { + const b = await boot('single', { organizations: false }); + + await expectStrictRefusal(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, MEMBER_CTX); + expect(await b.table(INJECTED)).toEqual(SEEDED); + }); + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\` without the security plugin: no wall, so the value is taken and reported, and the stamp's organization is stored`, async () => { + const b = await boot(posture, { security: false }); + const context = posture === 'group' ? GROUP_CTX : MEMBER_CTX; + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, context); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual(READONLY_DROP(INJECTED)); + expect(organizationWarnings(b)).toHaveLength(1); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined }]); + }); + + it(`\`${posture}\` without the security plugin: a create naming no organization is stamped, and the stamp is still not reported (#21682)`, async () => { + const b = await boot(posture, { security: false }); + const context = posture === 'group' ? GROUP_CTX : MEMBER_CTX; + + const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new' }, context); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped, 'the stamp is the platform\'s write, here where no wall spares the column').toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]); + }); + + it(`\`${posture}\`: a platform operator crossing the wall on a private object has the value taken and reported; no seam`, async () => { + const b = await boot(posture); + const context = posture === 'group' ? GROUP_ADMIN_CTX : ADMIN_CTX; + + const { outcome, dropped } = await createReporting(b, PRIVATE, { id: 'r2', name: 'new', organization_id: FOREIGN_ORG }, context); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual(READONLY_DROP(PRIVATE)); + expect(await b.table(PRIVATE)).toEqual([{ id: 'r2', organization_id: OWN_ORG }]); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined }]); + }); + } + + it('a platform operator with NO active organization crossing on a private object: its seam does not declare the organization judged, so a named organization is taken and the insert is still refused', async () => { + const b = await boot('isolated'); + + const outcome = await attempt(() => create(b, PRIVATE, { id: 'r2', name: 'new', organization_id: FOREIGN_ORG }, ADMIN_NO_ORG_CTX)); + + expect(outcome.ok, 'expected the no-active-organization refusal').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain('has no active organization'); + expect(await b.table(PRIVATE)).toEqual([]); + expect(b.seams, 'the operator\'s seam is installed (#24821) and judges no organization').toEqual([ + { installed: true, judgesOrganization: undefined }, + ]); + }); + + it('a tenancy-disabled object with an authored read-only organization_id: no wall, so the value is taken and NULL is stored', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, UNWALLED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, GROUP_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual(READONLY_DROP(UNWALLED)); + expect(await b.table(UNWALLED)).toEqual([{ id: 'r2', organization_id: null }]); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined }]); + }); + + it('a system context stores what it sends on the injected column, as before; it meets neither the stamp, the wall nor the strip', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await createReporting(b, INJECTED, [ + { id: 'r2', name: 'replayed', organization_id: SISTER_ORG }, + { id: 'r3', name: 'replayed', organization_id: FOREIGN_ORG }, + ], SYS_CTX); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined }]); + expect(await b.table(INJECTED)).toEqual([ + ...SEEDED, + { id: 'r2', organization_id: SISTER_ORG }, + { id: 'r3', organization_id: FOREIGN_ORG }, + ]); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts b/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts new file mode 100644 index 00000000000..eca6316ae1a --- /dev/null +++ b/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts @@ -0,0 +1,471 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24822] Step 3.7 declares, on an insert's stored-image seam, that the seam + * judges the insert's organization (`judgesOrganization`), and it declares + * that nowhere else. + * + * ## Why the member exists + * + * The engine's static-`readonly` strip took a caller's explicit + * `organization_id` (the platform's tenant column declares `readonly: true`) + * after the Layer 0 wall had admitted it, and the driver stamped the active + * organization in its place: a 2xx, with the row in another organization than + * the one the caller named and the wall admitted. The engine cannot tell on + * its own where the wall judged the column. The seam it holds + * (`OperationContext.postHookWriteImageCheck`) is also installed by a business + * `check` alone, and by a platform operator who crosses the wall. So the wall + * says so, on the seam, and the engine reads that member and nothing else. + * + * ## What this file pins + * + * Real `ObjectQL` (source), real `SqlDriver`, real `SecurityPlugin`; an + * innermost probe middleware reads the seam once every other middleware ran. + * + * - SET: a walled insert, under `isolated` and `group`, alone or composed with + * a business `check`. The value is then stored as sent. With no + * organizations runtime in this composition, an absent value is the + * driver's stamp, as before. + * - UNSET: the seam a business `check` installs under `single`, where no wall + * exists; a platform operator's seam that crosses the wall with no active + * organization (#24821); and an update's seam, whose organization this + * member is not about. + * - NO SEAM: a platform operator crossing on a private object with an active + * organization, and a system context. + * - DELEGATED (ADR-0090 D10): a crossing operator acting for a walled + * delegator. The wall's parts are the delegator's alone, so the member is + * SET, and the explicit organization is held to the delegator's wall: stored + * as sent where it admits it, refused where it does not. + */ + +import { describe, it, expect, afterEach, vi } 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 { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; + +const OWN_ORG = 'org_a'; +const SISTER_ORG = 'org_c'; + +const FIELDS = { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, +}; + +const OBJECTS = [ + { name: 'qa_account', label: 'Account', fields: FIELDS }, + { name: 'qa_checked', label: 'Checked', fields: FIELDS }, + { name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS }, +]; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { + name: 'checked_not_forbidden', + object: 'qa_checked', + operation: 'all', + using: "record.name != 'forbidden'", + check: "record.name != 'forbidden'", + }, + ], +} as unknown as PermissionSet; + +const PLATFORM_ADMIN: PermissionSet = { + name: 'admin_full_access', + label: 'Platform Administrator', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + }, + systemPermissions: ['manage_platform_settings', 'manage_metadata'], +} as unknown as PermissionSet; + +const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG }; +const MEMBER_CTX = { userId: 'usr_a', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ADMIN_CTX = { userId: 'usr_admin', tenantId: OWN_ORG, positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; +const ADMIN_NO_ORG_CTX = { userId: 'usr_admin', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; + +type Posture = 'isolated' | 'group' | 'single'; + +interface Sighting { operation: string; installed: boolean; judgesOrganization: unknown } + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.insert-seam-judges-organization', + name: 'The insert seam declares the organization judged', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER, PLATFORM_ADMIN], + }, + }; + if (posture !== 'single') { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + services.tenancy = { posture }; + } + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + // Registered LAST, so it runs innermost and reads the seam as installed. + const sightings: Sighting[] = []; + engine.registerMiddleware((async ( + opCtx: { operation: string; postHookWriteImageCheck?: { judgesOrganization?: unknown } }, + next: () => Promise, + ) => { + if (opCtx.operation === 'insert' || opCtx.operation === 'update') { + sightings.push({ + operation: opCtx.operation, + installed: opCtx.postHookWriteImageCheck !== undefined, + judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization, + }); + } + await next(); + }) as never); + + await engine.insert('qa_account', { id: 'r1', name: 'one', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + sightings.length = 0; + + const table = async (name: string) => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(name); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex(name).select('id', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + return { engine, sightings, table }; +} + +interface Outcome { ok: boolean; code?: string; status?: number; message?: string } +const attempt = async (run: () => Promise): Promise => { + try { + await run(); + return { ok: true }; + } catch (e) { + const err = e as { code?: string; statusCode?: number; status?: number; message?: string }; + return { ok: false, code: err.code, status: err.statusCode ?? err.status, message: String(err.message ?? e) }; + } +}; + +const JUDGED = { operation: 'insert', installed: true, judgesOrganization: true }; + +describe('[#24822] step 3.7 declares the insert\'s organization judged only where the wall\'s parts are composed in', () => { + it('`isolated` member insert on a walled object: SET, and the active organization it names is stored as sent', async () => { + const b = await boot('isolated'); + + const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r2', name: 'two', organization_id: OWN_ORG }, { context: MEMBER_CTX } as never)); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([JUDGED]); + expect(await b.table('qa_account')).toEqual([ + { id: 'r1', organization_id: OWN_ORG }, + { id: 'r2', organization_id: OWN_ORG }, + ]); + }); + + it('`group` member insert naming a sister organization: SET, and the row is stored there, single and array', async () => { + const b = await boot('group'); + + const single = await attempt(() => b.engine.insert('qa_account', { id: 'r2', name: 'two', organization_id: SISTER_ORG }, { context: GROUP_CTX } as never)); + const bulk = await attempt(() => b.engine.insert('qa_account', [{ id: 'r3', name: 'three', organization_id: SISTER_ORG }], { context: GROUP_CTX } as never)); + + expect(single.ok, single.message).toBe(true); + expect(bulk.ok, bulk.message).toBe(true); + expect(b.sightings).toEqual([JUDGED, JUDGED]); + expect(await b.table('qa_account')).toEqual([ + { id: 'r1', organization_id: OWN_ORG }, + { id: 'r2', organization_id: SISTER_ORG }, + { id: 'r3', organization_id: SISTER_ORG }, + ]); + }); + + it('a walled insert under a business `check`: the composed seam carries the member, and the check still runs', async () => { + const b = await boot('isolated'); + + const admitted = await attempt(() => b.engine.insert('qa_checked', { id: 'r2', name: 'fine', organization_id: OWN_ORG }, { context: MEMBER_CTX } as never)); + const refused = await attempt(() => b.engine.insert('qa_checked', { id: 'r3', name: 'forbidden', organization_id: OWN_ORG }, { context: MEMBER_CTX } as never)); + + expect(admitted.ok, admitted.message).toBe(true); + expect(refused.ok, 'the business check still refuses its own row').toBe(false); + expect(refused.code).toBe('PERMISSION_DENIED'); + expect(refused.status).toBe(403); + expect(b.sightings).toEqual([JUDGED, JUDGED]); + expect(await b.table('qa_checked')).toEqual([{ id: 'r2', organization_id: OWN_ORG }]); + }); + + it('`single` with a business `check`: the seam is installed and the member is UNSET, because no wall exists to judge the organization', async () => { + const b = await boot('single'); + + const outcome = await attempt(() => b.engine.insert('qa_checked', { id: 'r2', name: 'fine', organization_id: SISTER_ORG }, { context: MEMBER_CTX } as never)); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([{ operation: 'insert', installed: true, judgesOrganization: undefined }]); + expect(await b.table('qa_checked'), 'taken by the strip, and the context\'s organization stamped').toEqual([ + { id: 'r2', organization_id: OWN_ORG }, + ]); + }); + + it('a platform operator crossing on a private object with NO active organization: its seam is installed with the member UNSET, and the insert is refused', async () => { + const b = await boot('isolated'); + + const outcome = await attempt(() => b.engine.insert('qa_vault', { id: 'r2', name: 'two', organization_id: SISTER_ORG }, { context: ADMIN_NO_ORG_CTX } as never)); + + expect(b.sightings).toEqual([{ operation: 'insert', installed: true, judgesOrganization: undefined }]); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain('has no active organization'); + expect(await b.table('qa_vault')).toEqual([]); + }); + + it('a platform operator crossing on a private object with an active organization: no seam at all, and the value is taken', async () => { + const b = await boot('isolated'); + + const outcome = await attempt(() => b.engine.insert('qa_vault', { id: 'r2', name: 'two', organization_id: SISTER_ORG }, { context: ADMIN_CTX } as never)); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([{ operation: 'insert', installed: false, judgesOrganization: undefined }]); + expect(await b.table('qa_vault')).toEqual([{ id: 'r2', organization_id: OWN_ORG }]); + }); + + it('an update\'s seam does not carry the member: it declares an INSERT\'s organization only', async () => { + const b = await boot('isolated'); + + const outcome = await attempt(() => b.engine.update('qa_account', { id: 'r1', name: 'renamed' } as never, { context: MEMBER_CTX } as never)); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([{ operation: 'update', installed: true, judgesOrganization: undefined }]); + }); + + it('a system context installs no seam', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r2', name: 'two', organization_id: SISTER_ORG }, { context: SYS_CTX } as never)); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([{ operation: 'insert', installed: false, judgesOrganization: undefined }]); + }); +}); + +/** + * [#24822 · ADR-0090 D10] An ON-BEHALF-OF insert whose CALLER crosses the + * wall (a platform operator on a private object: its own Layer 0 is `null`) + * and whose DELEGATOR is walled. Step 3.7's wall parts are then the + * delegator's alone, so the seam judges the insert's organization against the + * delegator's wall and declares it: an organization that wall admits is stored + * as sent, and one it refuses is refused. + * + * The delegator is a real user whose context the plugin rebuilds from stored + * rows (`resolveDelegatorContext`): two memberships, so under `group` its wall + * is that membership set; under `isolated` it is the live request's active + * organization. + */ +describe('[#24822 · ADR-0090 D10] a delegated insert by a crossing operator is held to the delegator\'s wall', () => { + const ORG_A = 'org_a'; + const ORG_C = 'org_c'; + const ORG_D = 'org_d'; + const DELEGATOR = 'usr_delegator'; + /** A private object is outside the `'*'` grant, so the delegator holds it by name. */ + const DELEGATOR_SET: PermissionSet = { + name: 'qa_vault_writer', + label: 'Vault writer', + objects: { qa_vault: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as unknown as PermissionSet; + const SETS = [MEMBER, PLATFORM_ADMIN, DELEGATOR_SET]; + const OPERATOR_FOR_DELEGATOR = { + userId: 'usr_operator', + tenantId: ORG_A, + positions: [], + permissions: ['admin_full_access'], + posture: 'PLATFORM_ADMIN', + onBehalfOf: { userId: DELEGATOR }, + }; + + async function bootDelegated(posture: 'isolated' | 'group') { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.insert-seam-judges-organization-delegated', + name: 'A delegated insert is held to the delegator\'s wall', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + { name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS }, + ], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + for (const id of [ORG_A, ORG_C, ORG_D]) { + await engine.insert('sys_organization', { id, name: id, slug: id }, { context: SYS_CTX } as never); + } + await engine.insert('sys_user', { id: DELEGATOR, name: DELEGATOR, email: 'delegator@example.test' }, { context: SYS_CTX } as never); + for (const org of [ORG_A, ORG_C]) { + await engine.insert('sys_member', { id: `mem_${org}`, user_id: DELEGATOR, organization_id: org, role: 'member' }, { context: SYS_CTX } as never); + } + // The delegator holds the member set through a stored grant in each of its + // organizations, as a user does (the rows `resolveDelegatorContext` reads). + const driverOf = (o: string) => (engine as unknown as { getDriver(x: string): { create(x: string, r: Record): Promise } }).getDriver(o); + await driverOf('sys_permission_set').create('sys_permission_set', { + id: 'ps_qa_vault_writer', name: 'qa_vault_writer', label: 'Vault writer', organization_id: null, managed_by: 'admin', active: true, + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }); + for (const org of [ORG_A, ORG_C]) { + await engine.insert('sys_user_permission_set', { id: `ups_${org}`, user_id: DELEGATOR, permission_set: 'qa_vault_writer', organization_id: org }, { context: SYS_CTX } as never); + } + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? SETS.find((set) => (set as { name?: string }).name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => SETS, + }, + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + tenancy: { posture }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const sightings: Sighting[] = []; + engine.registerMiddleware((async ( + opCtx: { operation: string; postHookWriteImageCheck?: { judgesOrganization?: unknown } }, + next: () => Promise, + ) => { + if (opCtx.operation === 'insert') { + sightings.push({ + operation: opCtx.operation, + installed: opCtx.postHookWriteImageCheck !== undefined, + judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization, + }); + } + await next(); + }) as never); + + const insertReporting = async (data: unknown) => { + const dropped: unknown[] = []; + const outcome = await attempt(() => engine.insert('qa_vault', data as never, { + context: OPERATOR_FOR_DELEGATOR, + onFieldsDropped: (e: unknown) => dropped.push(e), + } as never)); + return { outcome, dropped }; + }; + const vault = async () => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver('qa_vault'); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex('qa_vault').select('id', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + return { sightings, insertReporting, vault }; + } + + const DELEGATOR_WALL_JUDGES = [{ operation: 'insert', installed: true, judgesOrganization: true }]; + const expectWallRefusal = (outcome: Outcome) => { + expect(outcome.ok, 'expected the delegator\'s wall to refuse').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain("the insert would place 'qa_vault' in another tenant"); + }; + + it('`group`: an organization the delegator holds is stored as sent, nothing reported, and the seam declares the delegator\'s judgement', async () => { + const b = await bootDelegated('group'); + + const { outcome, dropped } = await b.insertReporting({ id: 'r1', name: 'one', organization_id: ORG_C }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.vault(), 'stored where the caller named, not in the active organization').toEqual([{ id: 'r1', organization_id: ORG_C }]); + expect(dropped).toEqual([]); + expect(b.sightings).toEqual(DELEGATOR_WALL_JUDGES); + }); + + it('`group`: an organization the delegator does not hold is refused by the delegator\'s wall, and nothing is stored', async () => { + const b = await bootDelegated('group'); + + const { outcome } = await b.insertReporting({ id: 'r1', name: 'one', organization_id: ORG_D }); + + expectWallRefusal(outcome); + expect(await b.vault()).toEqual([]); + }); + + it('`group`: naming none is stamped with the active organization, as before', async () => { + const b = await bootDelegated('group'); + + const { outcome, dropped } = await b.insertReporting({ id: 'r1', name: 'one' }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(await b.vault()).toEqual([{ id: 'r1', organization_id: ORG_A }]); + }); + + it('`isolated`: the active organization is stored as sent and not reported; another one is refused', async () => { + const admitted = await bootDelegated('isolated'); + const sent = await admitted.insertReporting({ id: 'r1', name: 'one', organization_id: ORG_A }); + expect(sent.outcome.ok, sent.outcome.message).toBe(true); + expect(sent.dropped).toEqual([]); + expect(await admitted.vault()).toEqual([{ id: 'r1', organization_id: ORG_A }]); + expect(admitted.sightings).toEqual(DELEGATOR_WALL_JUDGES); + + const refused = await bootDelegated('isolated'); + const other = await refused.insertReporting({ id: 'r1', name: 'one', organization_id: ORG_C }); + expectWallRefusal(other.outcome); + expect(await refused.vault()).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 88d716e422b..11c1a8d455f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -260,6 +260,15 @@ interface WriteImageCheckSeam { evaluate(rows: readonly Record[]): void; /** Set by the engine immediately before `evaluate` runs. */ honoured?: boolean; + /** + * [#24822] Set by step 3.7, and only by it: on an insert, `evaluate` holds + * every row's `organization_id` to the Layer 0 tenant wall's own parts. The + * engine then leaves a caller's explicit value on the row for that verdict + * instead of stripping it as `readonly`. Never set on a seam that carries the + * business `check` alone, or on a crossing platform operator's seam: neither + * judges the organization. + */ + judgesOrganization?: boolean; } /** @@ -4317,6 +4326,24 @@ export class SecurityPlugin implements Plugin { if (emptiesOrganization(row)) denyOrganizationRemoval("the stored row's"); } }, + // ── [#24822 · ADR-0105 D5] The insert's organization is JUDGED here ── + // + // The loop above holds a stored row's `organization_id` to the wall + // whenever the wall has parts, and the payload half above already + // refused every supplied value outside them. So on an insert the + // engine may leave a caller's explicit value on the row for this + // verdict instead of stripping it as `readonly` and storing the + // active organization in its place. That replacement was silent: + // a 2xx, with the row in another organization than the one the + // wall admitted. The member says so, and the engine reads nothing + // else. Set ONLY where the wall's own parts are composed in + // (`tenantParts` non-empty). The crossing operator's seam + // (`operatorInsertLacksActiveOrganization` with no parts) judges no + // organization: its loop admits every row. The business `check` + // seam of step 3.6 never reaches this composition. Neither sets it, + // so the strip there runs as before. An update's organization is + // not this member's. + ...(opCtx.operation === 'insert' && tenantParts.length > 0 ? { judgesOrganization: true } : {}), }; opCtx.postHookWriteImageCheck = writeImageCheckSeam; tenantWallSeamInstalled = true;