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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, [number, number]> = {};
for (const file of readdirSync(SRC, { recursive: true }) as string[]) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue;
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<OperationPrincipalsHost, 'logger' | 'ql' | 'refuseGuestWithoutOrganization' | 'resolvePermissionSetsForContext' | 'tenancyPosture'>,
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<OperationPrincipalsHost, 'guestOrganizationRefusalWarned' | 'logger' | 'ql' | 'tenancyPosture'>,
context: any,
operation: string,
object: string,
): Promise<void> {
// 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);
}
Loading
Loading