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); } /**