From 0177b21a24872691eb7853eac63dfa0c6039678f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:26:24 +0000 Subject: [PATCH] refactor(plugin-security): move the rest of the tenant write wall and the context prologue out of security-plugin.ts The bodies of organizationWallRefusal (the ADR-0123 D2 scope pre-test) and writeWallVerdict (the wall's per-row verdict for the write preview) join composeWriteTenantWall in security-plugin-write-tenant-wall.ts, with the two module-level declarations only they read, callerHasOrganizationScope and carriesActiveOrganization. The bodies of resolveOperationPrincipals (the middleware's context prologue: ADR-0096 D5, ADR-0090 D10) and refuseGuestWithoutOrganization (ADR-0138 D4) move unedited to a new module, security-plugin-operation-principals.ts, with the two things only they read: the strict-mode refusal principalLessDenial and the @objectstack/core import of the guest refusal. Each method keeps its name, signature, docblock and position and hands its body one host literal of exactly the members it reads. isPrincipalLessContext, isGuestEnvelope and isTenantWallDenial stay and are exported and imported by value; principalLessDenial is imported back type-only for the docblock link that names it. The system-context census row 6c anchor and the per-file delegator-resolution pin follow the moved bodies. Move-only: no verdict changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa --- content/docs/permissions/system-context.mdx | 2 +- .../delegated-organization-membership.test.ts | 4 +- .../security-plugin-operation-principals.ts | 207 ++++++++++++++ .../src/security-plugin-write-tenant-wall.ts | 218 ++++++++++++--- .../plugin-security/src/security-plugin.ts | 263 +++++------------- 5 files changed, 451 insertions(+), 243 deletions(-) create mode 100644 packages/plugins/plugin-security/src/security-plugin-operation-principals.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0875df20ed8..e1872dcc6a5 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -103,7 +103,7 @@ that silently does not happen. | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | | 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | -| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` | +| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` | diff --git a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts index e5da9abc336..e72e922f7bc 100644 --- a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts +++ b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts @@ -685,7 +685,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t const calls = (text: string, name: string) => (text.match(new RegExp(`\\b${name}\\(`, 'g')) ?? []).length - (text.match(new RegExp(`function ${name}\\(`, 'g')) ?? []).length; - it('per source file, the asks equal the resolutions — and the two files that resolve one are both counted', () => { + it('per source file, the asks equal the resolutions — and the three files that resolve one are all counted', () => { const counted: Record = {}; for (const file of readdirSync(SRC, { recursive: true }) as string[]) { if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue; @@ -694,7 +694,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t if (resolutions === 0) continue; counted[file] = [resolutions, calls(text, 'delegatedOrganizationRefusal')]; } - expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin.ts']); + expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin-operation-principals.ts', 'security-plugin.ts']); for (const [file, [resolutions, asks]] of Object.entries(counted)) expect(asks, file).toBe(resolutions); }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin-operation-principals.ts b/packages/plugins/plugin-security/src/security-plugin-operation-principals.ts new file mode 100644 index 00000000000..a08c1037c91 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-operation-principals.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 6] The CONTEXT prologue of `SecurityPlugin`'s engine + * middleware, moved out of `security-plugin.ts`: + * + * - `resolveOperationPrincipals`, the prologue itself. A context the operation + * may not run under is refused before any gate: the ADR-0096 D5 strict mode, + * and on a walled posture an on-behalf-of context whose delegator's own + * membership does not back the organization the operation runs in + * (ADR-0090 D10, #25063). Every other context is resolved to the permission + * sets every gate composes with: the principal's, and on a delegated context + * the delegator's too. + * - `refuseGuestWithoutOrganization`, the located refusal the prologue raises + * for a guest acting in no organization, ahead of any set resolution + * (ADR-0138 D4). + * + * A MOVE, not a redesign. Each function below is the body of the + * `SecurityPlugin` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same refusals with the same codes and spellings, + * in the same order, the same fail-closed resolution, the same warning once per + * reason. What only these bodies read moved with them, also unedited: the + * ADR-0096 D5 refusal `principalLessDenial`, docblock included, and the + * `@objectstack/core` import the guest refusal reads, comment included. The + * refusal now carries `export`; see below. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 5 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps both private methods — the published + * `dist/*.d.ts` lists each one's name, docblock and position — and each body is + * one literal of exactly the {@link OperationPrincipalsHost} members that + * method reads, plus a `.call`. A method is handed over as a closure that calls + * it on the plugin at call time, so a test double or spy set on the plugin is + * still the one the body reaches: the prologue still asks the plugin's + * `refuseGuestWithoutOrganization` and `resolvePermissionSetsForContext`, and + * the middleware, `checkControlledByParentWrite` and the write preview still + * reach the prologue through the plugin. Both methods were async and both + * functions still are; each method now returns its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `guestOrganizationRefusalWarned` is declared `readonly` and goes by value, so + * the refusal records each reason it warned for in the plugin's own set. `ql`, + * `logger` and `tenancyPosture` are not declared `readonly` (the plugin assigns + * each after construction), so they go as GETTERS, and each read happens when + * the body makes it. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * `isPrincipalLessContext`, the ADR-0096 D5 test, stays there: the + * object-admission probes and the row scope read it too, and ADR-0138 anchors + * it in that file. `isGuestEnvelope` stays too, because the middleware's + * owner-write step and the set resolution read it. Both are exported and + * imported here by value. Each is read only inside a function body, at call + * time, never while either module evaluates, so the two-way import is safe + * under ESM, and the published bundle holds one module. The test's docblock + * there names the refusal (`principalLessDenial`), so `security-plugin.ts` + * imports it from here type-only, which keeps that link resolving and emits + * nothing. The refusal is not exported from `security-plugin.ts` instead, + * because its return type then moves `PermissionDeniedError`'s declaration to + * the top of the published `.d.ts` (measured on this cut). The delegator's + * resolution and its organization check are imported from `explain-engine.js`, + * and `PermissionDeniedError` from `errors.js`, as `security-plugin.ts` imports + * them. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import { isGuestEnvelope, isPrincipalLessContext } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { delegatedOrganizationRefusal, resolveDelegatorContext } from './explain-engine.js'; +import { PermissionDeniedError } from './errors.js'; +// [ADR-0138 D4] The guest entry's organization step, and the located refusal +// the CRUD gate raises where it refuses. +import { GuestOrganizationUnresolvedError, resolveGuestOrganization } from '@objectstack/core'; + +/** + * [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws + * for a principal-less, non-system context ({@link isPrincipalLessContext}): + * `PermissionDeniedError`, `403 PERMISSION_DENIED`, the engine's one deny + * vocabulary. ⛔ Not the doors' anonymous pair (`401 UNAUTHENTICATED`): every + * HTTP door answers that one first, so what reaches this line is in-process + * plumbing, for which "authenticate" is a false remedy. The text names the two + * that are true. + */ +export function principalLessDenial(operation: string, object: string): PermissionDeniedError { + return new PermissionDeniedError( + `[Security] Access denied: operation '${operation}' on object '${object}' was called with a ` + + `context that carries no principal (no user, no position, no permission set) and is not a ` + + `system context. Pass the caller's execution context, or, for platform plumbing whose own ` + + `door already authorized the caller, the explicit system opt-in (isSystem: true).`, + { operation, object }, + ); +} + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. + * Each is typed AS the class member (an indexed access on the class), so a + * change to a member's signature on the class is a change here too, and naming + * a member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface OperationPrincipalsHost { + readonly guestOrganizationRefusalWarned: SecurityPlugin['guestOrganizationRefusalWarned']; + readonly logger: SecurityPlugin['logger']; + readonly ql: SecurityPlugin['ql']; + readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + refuseGuestWithoutOrganization: SecurityPlugin['refuseGuestWithoutOrganization']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; +} + +/** The body of `SecurityPlugin#resolveOperationPrincipals`; the method's docblock there is its contract. */ +export async function resolveOperationPrincipals( + this: Pick, + operation: string, + object: string, + context: any, +): Promise<{ permissionSets: PermissionSet[]; delegatorSets: PermissionSet[] | null; delegatorContext: any }> { + if (isPrincipalLessContext(context)) { + throw principalLessDenial(operation, object); + } + await this.refuseGuestWithoutOrganization(context, operation, object); + + let permissionSets: PermissionSet[] = []; + try { + permissionSets = await this.resolvePermissionSetsForContext(context); + } catch (e) { + this.logger.error?.( + `[security] permission resolution failed for operation '${operation}' on ` + + `object '${object}' (user ${context?.userId ?? 'unknown'}) — ` + + `denying request (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + throw new PermissionDeniedError( + `[Security] Access denied: permission subsystem unavailable for ` + + `operation '${operation}' on object '${object}'`, + ); + } + + let delegatorSets: PermissionSet[] | null = null; + let delegatorContext: any = null; + if (permissionSets.length > 0 && context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') { + throw new PermissionDeniedError( + `[Security] Access denied: on-behalf-of principal names delegator ` + + `'${del.userId}', who does not exist — refusing to act (ADR-0090 D10 fail-closed)`, + { operation, object }, + ); + } + if (del.kind === 'resolved') { + const liveOrganization = delegatedOrganizationRefusal(context, del.context, this.tenancyPosture); + if (liveOrganization !== undefined) { + this.logger.warn?.( + `[Security] on-behalf-of ${operation} '${object}' REFUSED — the delegator is not a member of the ` + + `organization the request runs in (ADR-0090 D10, fail-closed)`, + ); + throw new PermissionDeniedError( + `[Security] Access denied: on-behalf-of principal acts in organization '${liveOrganization}', which ` + + `delegator '${String(context.onBehalfOf.userId)}' is not a member of — refusing to act (ADR-0090 D10 fail-closed)`, + { operation, object }, + `[ADR-0090 D10] tenancy posture '${this.tenancyPosture}' walls organizations, and a delegated operation is ` + + `bounded by the delegator's scope: it runs in the live principal's active organization, which the ` + + `delegator's own membership must back. Build the on-behalf-of context in an organization the delegator ` + + `is a member of. System contexts and the single posture are unaffected.`, + ); + } + delegatorContext = del.context; + delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); + } + } + return { permissionSets, delegatorSets, delegatorContext }; +} + +/** The body of `SecurityPlugin#refuseGuestWithoutOrganization`; the method's docblock there is its contract. */ +export async function refuseGuestWithoutOrganization( + this: Pick, + context: any, + operation: string, + object: string, +): Promise { + // A system context never reaches here: both callers of + // {@link resolveOperationPrincipals} return before it for one (the + // middleware's system short-circuit, and `checkControlledByParentWrite`'s + // first exit), so this rule reads no elevation of its own. + if (!isGuestEnvelope(context)) return; + const tenantId = context?.tenantId; + if (typeof tenantId === 'string' && tenantId !== '') return; + const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture); + if (decision.kind !== 'refuse') return; + if (!this.guestOrganizationRefusalWarned.has(decision.reason)) { + this.guestOrganizationRefusalWarned.add(decision.reason); + this.logger.warn?.( + `[security] an anonymous request reached '${operation}' on '${object}' and was refused: this deployment ` + + `resolves no organization for the guest (${decision.reason}, tenancy posture '${this.tenancyPosture}'` + + `${decision.organizationCount !== undefined ? `, ${decision.organizationCount} organization(s)` : ''}). ` + + `ADR-0138 D4 derives the guest's organization only on a single-organization deployment, and no site ` + + `binding (D5) exists yet to name one per request, so every anonymous data operation here answers ` + + `501 NOT_IMPLEMENTED. Serve the endpoint to signed-in callers (authRequired: true), or run it on a ` + + `single-organization deployment. Reported once per reason.`, + { operation, object, reason: decision.reason }, + ); + } + throw new GuestOrganizationUnresolvedError(decision.reason); +} diff --git a/packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts b/packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts index 04c93c468e1..2740d7f661b 100644 --- a/packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts +++ b/packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts @@ -1,65 +1,79 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#25223 cut 5] Step 3.7's Layer 0 tenant write wall of `SecurityPlugin`, - * composed once for one write, moved out of `security-plugin.ts` whole: the - * ADR-0123 D2 scope pre-test that refuses a write with no active organization, - * the parts every row is held to (ADR-0095 D1, and the delegator's own wall - * under ADR-0090 D10), and the judgements that read them — the payload half, - * and the stored-image seam with its landing refusals (#24784, #24821). + * [#25223 cuts 5 and 6] Step 3.7's Layer 0 tenant write wall of + * `SecurityPlugin`, moved out of `security-plugin.ts`: * - * A MOVE, not a redesign. The function below is the body of the + * - `composeWriteTenantWall` (cut 5), the wall composed once for one write: + * the ADR-0123 D2 scope pre-test that refuses a write with no active + * organization, the parts every row is held to (ADR-0095 D1, and the + * delegator's own wall under ADR-0090 D10), and the judgements that read + * them — the payload half, and the stored-image seam with its landing + * refusals (#24784, #24821). + * - `organizationWallRefusal` (cut 6), that scope pre-test itself: which + * principal the no-active-organization wall refuses. `canWriteObject` asks + * it too, so the two doors share one spelling of the wall. The two + * module-level declarations only these bodies read moved with it: + * `callerHasOrganizationScope`, and `carriesActiveOrganization`, which the + * cut-5 body reads too. + * - `writeWallVerdict` (cut 6), the wall's verdict on each row of a write + * PREVIEW (#25062), which the engine asks through `registerWriteWallVerdict`. + * + * A MOVE, not a redesign. Each function below is the body of the * `SecurityPlugin` method of the same name as it stood, re-indented one level * and otherwise unedited: the same pre-test, ordered ahead of the forge guard, - * the same refusals with the same spellings, the same seam conditions. Nothing - * here logs or throws, as before: the middleware logs and throws each refusal - * (`raiseWriteWallRefusal`), and the write preview (`writeWallVerdict`) answers - * it as a row's verdict. Both stay in the class and still call the method. + * the same refusals with the same spellings, the same seam conditions, the + * same preview answers. The two module-level declarations are as they stood, + * docblocks included; `carriesActiveOrganization` is again declared without + * `export`, as before cut 5. Nothing here logs or throws on a refusal, as + * before: the middleware logs and throws each one (`raiseWriteWallRefusal`), + * and the write preview answers it as a row's verdict. * - * ## How the moved body reaches the class + * ## How the moved bodies reach the class * - * The shape cuts 1 to 4 used (`security-plugin-object-inputs.ts`, whose header - * carries the reasoning). The class keeps the method — the published - * `dist/*.d.ts` lists its name, docblock and position — and its body is one - * literal of exactly the {@link WriteTenantWallHost} members it reads, plus a - * `.call`. A method is handed over as a closure that calls it on the plugin at - * call time, so a test double or spy set on the plugin is still the one the - * body reaches: the pre-test is still `organizationWallRefusal`, the one - * verdict `canWriteObject` asks too, and the caller's Layer 0 is still the - * read side's, through `computeWriteTenantWall` and - * `computeWriteTenantCheckFilter`. The method was async and the function still - * is; the method now returns the function's promise. + * The shape cuts 1 to 5 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps every method — the published + * `dist/*.d.ts` lists each one's name, docblock and position, and + * `writeWallVerdict`'s full signature, because it is public — and each body is + * one literal of exactly the {@link WriteTenantWallHost} members that method + * reads, plus a `.call`. A method is handed over as a closure that calls it on + * the plugin at call time, so a test double or spy set on the plugin is still + * the one the body reaches: the wall's pre-test is still the plugin's + * `organizationWallRefusal`, the caller's Layer 0 is still the read side's, + * through `computeWriteTenantWall` and `computeWriteTenantCheckFilter`, and the + * preview still reaches the prologue (`resolveOperationPrincipals`) and the + * wall (`composeWriteTenantWall`) through the plugin. Each method was async and + * each function still is; each method now returns its function's promise. * - * The two fields the body reads go as GETTERS, by the rule cut 1 set: + * The two fields the bodies read go as GETTERS, by the rule cut 1 set: * `tenancyPosture` is not declared `readonly` (`start()` resolves it after * construction), and `orgScopingEnabled` is the class's own getter over it. * Each read happens when the body makes it, as before. That includes the - * reads inside the judgements the body returns: the developer messages of - * `denyOrganizationRemoval` and of `judgeLanding`'s two refusals read - * `tenancyPosture` when the middleware or the preview calls them, through the - * host, so they read the plugin's value at that time. + * reads inside the judgements `composeWriteTenantWall` returns: the developer + * messages of `denyOrganizationRemoval` and of `judgeLanding`'s two refusals + * read `tenancyPosture` when the middleware or the preview calls them, through + * the host, so they read the plugin's value at that time. * * ## What stays in `security-plugin.ts`, and how it is read * - * `WriteTenantWall` and `WriteWallRefusal` keep their one declaration there, - * where the method's signature and `raiseWriteWallRefusal` read them; this - * module names both through the class's own signature (below). - * `carriesActiveOrganization` stays there too, because - * `callerHasOrganizationScope` reads it, and is exported and imported here by - * value. It is read only inside the function body, at call time, never while - * either module evaluates, so the two-way import is safe under ESM, and the - * published bundle holds one module. `matchesFilterCondition` is imported from - * `@objectstack/formula`, `postureUsesUnionScope` from - * `@objectstack/spec/security` and `PermissionDeniedError` from `errors.js`, as - * `security-plugin.ts` imported them; `security-plugin.ts` no longer reads - * `matchesFilterCondition`. + * `WriteTenantWall`, `WriteWallRefusal` and `WriteWallRowVerdict` keep their + * one declaration there, where the method signatures, `raiseWriteWallRefusal` + * and `start()` read them; this module names all three through the class's own + * signatures (below). `isTenantWallDenial` stays there too, because + * `tenant-layer.ts` names it as living in that file, and is exported and + * imported here by value. It is read only inside a function body, at call + * time, never while either module evaluates, so the two-way import is safe + * under ESM, and the published bundle holds one module. `matchesFilterCondition` + * is imported from `@objectstack/formula`, `postureUsesUnionScope` and + * `TenancyPosture` from `@objectstack/spec/security` and `PermissionDeniedError` + * from `errors.js`, as `security-plugin.ts` imported them. * * Nothing re-exports this module, so the published surface does not move. */ import type { SecurityPlugin } from './security-plugin.js'; -import { carriesActiveOrganization } from './security-plugin.js'; -import type { PermissionSet } from '@objectstack/spec/security'; +import { isTenantWallDenial } from './security-plugin.js'; +import type { PermissionSet, TenancyPosture } from '@objectstack/spec/security'; import { postureUsesUnionScope } from '@objectstack/spec/security'; import { matchesFilterCondition } from '@objectstack/formula'; import { PermissionDeniedError } from './errors.js'; @@ -79,8 +93,60 @@ type WriteTenantWall = Awaited; /** - * The `SecurityPlugin` members the function below reads — every one of them, - * and no other; the function's `this` is the `Pick` of the members it reads. + * The preview's verdict on one row, as the class declares it: the + * `WriteWallRowVerdict` type stays in `security-plugin.ts`, and the moved body + * names it exactly as it did. + */ +type WriteWallRowVerdict = NonNullable>>[number]; + +/** + * [ADR-0123 D2] Does this execution context carry ANY organization scope the + * tenant wall could enforce with? + * + * The answer is posture-shaped, and that is the whole reason this is a function + * rather than an `if (!ctx.tenantId)` at the call site: under `isolated` the + * scope is the ACTIVE organization (`tenantId`), under `group` it is the + * caller's MEMBERSHIP SET (`accessible_org_ids`) and the active organization no + * longer bounds anything (ADR-0105 D2). Asking only about `tenantId` would + * refuse every `group` caller who is legitimately scoped by membership; asking + * only about the set would let an `isolated` caller through on a set the + * `isolated` wall never reads. + * + * This is a cheap PRE-TEST, never the verdict. It exists so the common paths — + * the `single` posture, and any caller who does have an organization — skip the + * layered RLS compile entirely. The verdict is always + * {@link isTenantWallDenial} over the Layer 0 filter, which is the one place + * that knows about platform-admin exemptions, non-tenant objects and phantom + * anchors. + * + * It answers the SCOPE question only. Under a union-scope posture a caller + * scoped by membership can still lack the organization a NEW row lands in — + * the active one ({@link carriesActiveOrganization}) — and step 3.7 judges + * that separately, on the stored image of an insert. + */ +function callerHasOrganizationScope(context: any, posture: TenancyPosture): boolean { + if (postureUsesUnionScope(posture)) { + const orgIds = context?.accessible_org_ids; + return Array.isArray(orgIds) && orgIds.length > 0; + } + return carriesActiveOrganization(context); +} + +/** + * [ADR-0105 D5 · ADR-0123 D2] Does this execution context carry an ACTIVE + * organization (`tenantId`)? In every posture that is where a new row lands: + * the engine stamps an insert's organization from it. Under `isolated` it is + * also the whole read scope; under `group` the membership set is the read + * scope and the active organization stays the write target (ADR-0105 D2). + */ +function carriesActiveOrganization(context: any): boolean { + const tenantId = context?.tenantId; + return tenantId != null && tenantId !== ''; +} + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. * Each is typed AS the class member (an indexed access on the class), so a * change to a member's signature on the class is a change here too, and naming * a member the class does not have is a compile error. Every field here is a @@ -89,9 +155,11 @@ type WriteWallRefusal = NonNullable; export interface WriteTenantWallHost { readonly orgScopingEnabled: SecurityPlugin['orgScopingEnabled']; readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + composeWriteTenantWall: SecurityPlugin['composeWriteTenantWall']; computeWriteTenantCheckFilter: SecurityPlugin['computeWriteTenantCheckFilter']; computeWriteTenantWall: SecurityPlugin['computeWriteTenantWall']; organizationWallRefusal: SecurityPlugin['organizationWallRefusal']; + resolveOperationPrincipals: SecurityPlugin['resolveOperationPrincipals']; } /** The body of `SecurityPlugin#composeWriteTenantWall`; the method's docblock there is its contract. */ @@ -435,3 +503,65 @@ export async function composeWriteTenantWall( : null, }; } + +/** The body of `SecurityPlugin#organizationWallRefusal`; the method's docblock there is its contract. */ +export async function organizationWallRefusal( + this: Pick, + permissionSets: PermissionSet[], + object: string, + operation: string, + context: any, + delegatorSets: PermissionSet[] | null, + delegatorContext: any, +): Promise<'caller' | 'delegator' | null> { + if (!this.orgScopingEnabled || callerHasOrganizationScope(context, this.tenancyPosture)) return null; + const callerWall = await this.computeWriteTenantCheckFilter(permissionSets, object, operation, context); + // [ADR-0090 D10] The delegator is walled on its own context, exactly as + // the forge guard walls it — an on-behalf-of write may not land a row + // the delegator itself could not place. + const delegatorWall = + delegatorSets && !callerHasOrganizationScope(delegatorContext, this.tenancyPosture) + ? await this.computeWriteTenantCheckFilter(delegatorSets, object, operation, delegatorContext) + : null; + return isTenantWallDenial(callerWall) + ? 'caller' + : isTenantWallDenial(delegatorWall) + ? 'delegator' + : null; +} + +/** The body of `SecurityPlugin#writeWallVerdict`; the method's docblock there is its contract. */ +export async function writeWallVerdict( + this: Pick, + object: string, + operation: 'insert' | 'update', + context: any, + rows: readonly Record[], +): Promise { + if (!context || typeof context !== 'object' || context.isSystem || !context.userId) return null; + if (operation !== 'insert' && operation !== 'update') return null; + let principals: Awaited>; + try { + principals = await this.resolveOperationPrincipals(operation, object, context); + } catch { + return null; + } + const isRow = (row: unknown): row is Record => + !!row && typeof row === 'object' && !Array.isArray(row); + const wall = await this.composeWriteTenantWall( + object, + operation, + context, + rows.filter(isRow), + true, + principals, + { positions: context.positions ?? [], permissionSets: context.permissions ?? [] }, + ); + return rows.map((row): WriteWallRowVerdict => { + if (!isRow(row)) return { admitted: true, judgesOrganization: false }; + const refused = wall.preTest ?? wall.judgePayload([row]) ?? wall.storedImage?.judgeLanding([row]) ?? null; + return refused + ? { admitted: false, field: refused.field, code: refused.error.code, message: refused.error.message } + : { admitted: true, judgesOrganization: wall.storedImage?.judgesOrganization === true }; + }); +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 00d6dfd381c..4c79c0d8d07 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -144,10 +144,28 @@ import { controlledByParentChainRefusal, type CbpReadFilterHost, } from './security-plugin-cbp-read-filter.js'; -// [#25223 cut 5] Step 3.7's Layer 0 tenant write wall, composed once per write -// (ADR-0123 D2, ADR-0095 D1); the method of the same name below hands the body -// the members it reads (`WriteTenantWallHost`). -import { composeWriteTenantWall, type WriteTenantWallHost } from './security-plugin-write-tenant-wall.js'; +// [#25223 cuts 5 and 6] Step 3.7's Layer 0 tenant write wall, composed once per +// write (ADR-0123 D2, ADR-0095 D1), its no-active-organization pre-test and its +// verdict for the write preview; the methods of the same names below hand each +// body the members it reads (`WriteTenantWallHost`). +import { + composeWriteTenantWall, + organizationWallRefusal, + writeWallVerdict, + type WriteTenantWallHost, +} from './security-plugin-write-tenant-wall.js'; +// [#25223 cut 6] The context prologue of the engine middleware (ADR-0096 D5, +// ADR-0090 D10) and its guest organization refusal (ADR-0138 D4); the methods +// of the same names below hand each body the members it reads +// (`OperationPrincipalsHost`). `principalLessDenial`, the strict-mode refusal +// only the prologue throws, moved with it; it is imported type-only so that +// `isPrincipalLessContext`'s docblock still links to it. +import { + refuseGuestWithoutOrganization, + resolveOperationPrincipals, + type OperationPrincipalsHost, + type principalLessDenial, +} from './security-plugin-operation-principals.js'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -167,7 +185,6 @@ import { import { normalizeTenancyPosture, postureEnforcesWall, - postureUsesUnionScope, type ObjectAccessScope, type TenancyPosture, } from '@objectstack/spec/security'; @@ -198,9 +215,6 @@ import { PermissionDeniedError, MaskedValueWriteError, } from './errors.js'; -// [ADR-0138 D4] The guest entry's organization step, and the located refusal -// the CRUD gate raises where it refuses. -import { GuestOrganizationUnresolvedError, resolveGuestOrganization } from '@objectstack/core'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; @@ -231,7 +245,7 @@ import { registerBuiltinCapabilities } from './builtin-capabilities.js'; * the organization refusal (D4) and the owner-anchor stamp's guest branch (D1) * all ask it here. */ -function isGuestEnvelope(context: unknown): boolean { +export function isGuestEnvelope(context: unknown): boolean { const c = context as { principalKind?: unknown; userId?: unknown } | null | undefined; return c?.principalKind === 'guest' && !c?.userId; } @@ -514,31 +528,12 @@ const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze( * strict mode the empty list has NO exception left. ⛔ No door, probe or layer * carries a spelling of "no sets means no restriction". */ -function isPrincipalLessContext(context: any): boolean { +export function isPrincipalLessContext(context: any): boolean { const positions = context?.positions ?? []; const explicitPermissionSets = context?.permissions ?? []; return positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId; } -/** - * [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws - * for a principal-less, non-system context ({@link isPrincipalLessContext}): - * `PermissionDeniedError`, `403 PERMISSION_DENIED`, the engine's one deny - * vocabulary. ⛔ Not the doors' anonymous pair (`401 UNAUTHENTICATED`): every - * HTTP door answers that one first, so what reaches this line is in-process - * plumbing, for which "authenticate" is a false remedy. The text names the two - * that are true. - */ -function principalLessDenial(operation: string, object: string): PermissionDeniedError { - return new PermissionDeniedError( - `[Security] Access denied: operation '${operation}' on object '${object}' was called with a ` + - `context that carries no principal (no user, no position, no permission set) and is not a ` + - `system context. Pass the caller's execution context, or, for platform plumbing whose own ` + - `door already authorized the caller, the explicit system opt-in (isSystem: true).`, - { operation, object }, - ); -} - /** * [#5492] Knobs on the layered RLS computation. Both are COMPOSITION * instructions rather than policy switches: the caller has already consulted an @@ -738,51 +733,6 @@ interface CbpRelation { */ const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; -/** - * [ADR-0123 D2] Does this execution context carry ANY organization scope the - * tenant wall could enforce with? - * - * The answer is posture-shaped, and that is the whole reason this is a function - * rather than an `if (!ctx.tenantId)` at the call site: under `isolated` the - * scope is the ACTIVE organization (`tenantId`), under `group` it is the - * caller's MEMBERSHIP SET (`accessible_org_ids`) and the active organization no - * longer bounds anything (ADR-0105 D2). Asking only about `tenantId` would - * refuse every `group` caller who is legitimately scoped by membership; asking - * only about the set would let an `isolated` caller through on a set the - * `isolated` wall never reads. - * - * This is a cheap PRE-TEST, never the verdict. It exists so the common paths — - * the `single` posture, and any caller who does have an organization — skip the - * layered RLS compile entirely. The verdict is always - * {@link isTenantWallDenial} over the Layer 0 filter, which is the one place - * that knows about platform-admin exemptions, non-tenant objects and phantom - * anchors. - * - * It answers the SCOPE question only. Under a union-scope posture a caller - * scoped by membership can still lack the organization a NEW row lands in — - * the active one ({@link carriesActiveOrganization}) — and step 3.7 judges - * that separately, on the stored image of an insert. - */ -function callerHasOrganizationScope(context: any, posture: TenancyPosture): boolean { - if (postureUsesUnionScope(posture)) { - const orgIds = context?.accessible_org_ids; - return Array.isArray(orgIds) && orgIds.length > 0; - } - return carriesActiveOrganization(context); -} - -/** - * [ADR-0105 D5 · ADR-0123 D2] Does this execution context carry an ACTIVE - * organization (`tenantId`)? In every posture that is where a new row lands: - * the engine stamps an insert's organization from it. Under `isolated` it is - * also the whole read scope; under `group` the membership set is the read - * scope and the active organization stays the write target (ADR-0105 D2). - */ -export function carriesActiveOrganization(context: any): boolean { - const tenantId = context?.tenantId; - return tenantId != null && tenantId !== ''; -} - /** * [#21771] Ruling A's read question, judged on the answer of a caller-context * by-id read: is the row ABSENT to this caller? @@ -913,7 +863,7 @@ export function copyOfCallerPredicate(where: unknown): unknown { * `organization_id $in […]`) or `null`, which no reference check can confuse * with it. */ -function isTenantWallDenial(filter: Record | null | undefined): boolean { +export function isTenantWallDenial(filter: Record | null | undefined): boolean { return filter === RLS_DENY_FILTER; } @@ -8468,7 +8418,7 @@ export class SecurityPlugin implements Plugin { * {@link canWriteObject} asks this same method, so the two doors share one * spelling of the wall. It is handed no row, stored or written. */ - private async organizationWallRefusal( + private organizationWallRefusal( permissionSets: PermissionSet[], object: string, operation: string, @@ -8476,20 +8426,15 @@ export class SecurityPlugin implements Plugin { delegatorSets: PermissionSet[] | null, delegatorContext: any, ): Promise<'caller' | 'delegator' | null> { - if (!this.orgScopingEnabled || callerHasOrganizationScope(context, this.tenancyPosture)) return null; - const callerWall = await this.computeWriteTenantCheckFilter(permissionSets, object, operation, context); - // [ADR-0090 D10] The delegator is walled on its own context, exactly as - // the forge guard walls it — an on-behalf-of write may not land a row - // the delegator itself could not place. - const delegatorWall = - delegatorSets && !callerHasOrganizationScope(delegatorContext, this.tenancyPosture) - ? await this.computeWriteTenantCheckFilter(delegatorSets, object, operation, delegatorContext) - : null; - return isTenantWallDenial(callerWall) - ? 'caller' - : isTenantWallDenial(delegatorWall) - ? 'delegator' - : null; + // [#25223 cut 6] The body moved to `security-plugin-write-tenant-wall.ts` (see `WriteTenantWallHost`). + const readOrgScopingEnabled = () => this.orgScopingEnabled; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + computeWriteTenantCheckFilter: (...a) => this.computeWriteTenantCheckFilter(...a), + get orgScopingEnabled() { return readOrgScopingEnabled(); }, + get tenancyPosture() { return readTenancyPosture(); }, + }; + return organizationWallRefusal.call(host, permissionSets, object, operation, context, delegatorSets, delegatorContext); } /** @@ -8577,38 +8522,18 @@ export class SecurityPlugin implements Plugin { * organization a `beforeInsert` / `beforeUpdate` hook writes is judged by * the write's stored-image half alone — the preview's named limit. */ - async writeWallVerdict( + writeWallVerdict( object: string, operation: 'insert' | 'update', context: any, rows: readonly Record[], ): Promise { - if (!context || typeof context !== 'object' || context.isSystem || !context.userId) return null; - if (operation !== 'insert' && operation !== 'update') return null; - let principals: Awaited>; - try { - principals = await this.resolveOperationPrincipals(operation, object, context); - } catch { - return null; - } - const isRow = (row: unknown): row is Record => - !!row && typeof row === 'object' && !Array.isArray(row); - const wall = await this.composeWriteTenantWall( - object, - operation, - context, - rows.filter(isRow), - true, - principals, - { positions: context.positions ?? [], permissionSets: context.permissions ?? [] }, - ); - return rows.map((row): WriteWallRowVerdict => { - if (!isRow(row)) return { admitted: true, judgesOrganization: false }; - const refused = wall.preTest ?? wall.judgePayload([row]) ?? wall.storedImage?.judgeLanding([row]) ?? null; - return refused - ? { admitted: false, field: refused.field, code: refused.error.code, message: refused.error.message } - : { admitted: true, judgesOrganization: wall.storedImage?.judgesOrganization === true }; - }); + // [#25223 cut 6] The body moved to `security-plugin-write-tenant-wall.ts` (see `WriteTenantWallHost`). + const host: Pick = { + composeWriteTenantWall: (...a) => this.composeWriteTenantWall(...a), + resolveOperationPrincipals: (...a) => this.resolveOperationPrincipals(...a), + }; + return writeWallVerdict.call(host, object, operation, context, rows); } /** @@ -8950,65 +8875,23 @@ export class SecurityPlugin implements Plugin { * refusal where it is made. A context with no active organization is not * this rule's: its answers stay with ADR-0123 D2. */ - private async resolveOperationPrincipals( + private resolveOperationPrincipals( operation: string, object: string, context: any, ): Promise<{ permissionSets: PermissionSet[]; delegatorSets: PermissionSet[] | null; delegatorContext: any }> { - if (isPrincipalLessContext(context)) { - throw principalLessDenial(operation, object); - } - await this.refuseGuestWithoutOrganization(context, operation, object); - - let permissionSets: PermissionSet[] = []; - try { - permissionSets = await this.resolvePermissionSetsForContext(context); - } catch (e) { - this.logger.error?.( - `[security] permission resolution failed for operation '${operation}' on ` + - `object '${object}' (user ${context?.userId ?? 'unknown'}) — ` + - `denying request (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - throw new PermissionDeniedError( - `[Security] Access denied: permission subsystem unavailable for ` + - `operation '${operation}' on object '${object}'`, - ); - } - - let delegatorSets: PermissionSet[] | null = null; - let delegatorContext: any = null; - if (permissionSets.length > 0 && context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind === 'missing') { - throw new PermissionDeniedError( - `[Security] Access denied: on-behalf-of principal names delegator ` + - `'${del.userId}', who does not exist — refusing to act (ADR-0090 D10 fail-closed)`, - { operation, object }, - ); - } - if (del.kind === 'resolved') { - const liveOrganization = delegatedOrganizationRefusal(context, del.context, this.tenancyPosture); - if (liveOrganization !== undefined) { - this.logger.warn?.( - `[Security] on-behalf-of ${operation} '${object}' REFUSED — the delegator is not a member of the ` + - `organization the request runs in (ADR-0090 D10, fail-closed)`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: on-behalf-of principal acts in organization '${liveOrganization}', which ` + - `delegator '${String(context.onBehalfOf.userId)}' is not a member of — refusing to act (ADR-0090 D10 fail-closed)`, - { operation, object }, - `[ADR-0090 D10] tenancy posture '${this.tenancyPosture}' walls organizations, and a delegated operation is ` + - `bounded by the delegator's scope: it runs in the live principal's active organization, which the ` + - `delegator's own membership must back. Build the on-behalf-of context in an organization the delegator ` + - `is a member of. System contexts and the single posture are unaffected.`, - ); - } - delegatorContext = del.context; - delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); - } - } - return { permissionSets, delegatorSets, delegatorContext }; + // [#25223 cut 6] The body moved to `security-plugin-operation-principals.ts` (see `OperationPrincipalsHost`). + const readLogger = () => this.logger; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + get logger() { return readLogger(); }, + get ql() { return readQl(); }, + refuseGuestWithoutOrganization: (...a) => this.refuseGuestWithoutOrganization(...a), + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + get tenancyPosture() { return readTenancyPosture(); }, + }; + return resolveOperationPrincipals.call(host, operation, object, context); } /** @@ -9035,30 +8918,18 @@ export class SecurityPlugin implements Plugin { * organization object) leaves the guest to the gate as it is — denied while * it holds no set (D3 item 2). */ - private async refuseGuestWithoutOrganization(context: any, operation: string, object: string): Promise { - // A system context never reaches here: both callers of - // {@link resolveOperationPrincipals} return before it for one (the - // middleware's system short-circuit, and `checkControlledByParentWrite`'s - // first exit), so this rule reads no elevation of its own. - if (!isGuestEnvelope(context)) return; - const tenantId = context?.tenantId; - if (typeof tenantId === 'string' && tenantId !== '') return; - const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture); - if (decision.kind !== 'refuse') return; - if (!this.guestOrganizationRefusalWarned.has(decision.reason)) { - this.guestOrganizationRefusalWarned.add(decision.reason); - this.logger.warn?.( - `[security] an anonymous request reached '${operation}' on '${object}' and was refused: this deployment ` + - `resolves no organization for the guest (${decision.reason}, tenancy posture '${this.tenancyPosture}'` + - `${decision.organizationCount !== undefined ? `, ${decision.organizationCount} organization(s)` : ''}). ` + - `ADR-0138 D4 derives the guest's organization only on a single-organization deployment, and no site ` + - `binding (D5) exists yet to name one per request, so every anonymous data operation here answers ` + - `501 NOT_IMPLEMENTED. Serve the endpoint to signed-in callers (authRequired: true), or run it on a ` + - `single-organization deployment. Reported once per reason.`, - { operation, object, reason: decision.reason }, - ); - } - throw new GuestOrganizationUnresolvedError(decision.reason); + private refuseGuestWithoutOrganization(context: any, operation: string, object: string): Promise { + // [#25223 cut 6] The body moved to `security-plugin-operation-principals.ts` (see `OperationPrincipalsHost`). + const readLogger = () => this.logger; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + guestOrganizationRefusalWarned: this.guestOrganizationRefusalWarned, + get logger() { return readLogger(); }, + get ql() { return readQl(); }, + get tenancyPosture() { return readTenancyPosture(); }, + }; + return refuseGuestWithoutOrganization.call(host, context, operation, object); } /**