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
11 changes: 11 additions & 0 deletions .changeset/runtime-domain-gates-tenancy-posture-loud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/runtime': minor
---

The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall"

Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability **probe**: every step of its fallback chain absorbs every rejection and answers `undefined`. So a `tenancy` service that was registered and **failed to build** arrived at both gates as the same value a deployment that never registered one produces, and both read that as "there is no wall". Measured on the pre-fix tree against a real kernel whose `tenancy` is registered through a throwing factory: `POST /keys` answered **201** and minted an organization-less key, echoing the raw secret once, where a walled posture refuses one; and an organization administrator's install-wide activation write answered **200** and wrote the row, where ADR-0126 §5 requires the platform operator.

The identity step already read this fact through the classified lookup, so one deployment held two readings of its own wall question at once — 503 at the identity step, admitted at the door bodies these gates guard. The gates now read the same classification, taken from the registry's own brand and never from message text: a service that was **never registered** stays quiet and behaves exactly as before (an org-less key is still minted, and a single-organization deployment's own admin can still flip an install-wide switch — with no tenancy service, install-level and org-level are one scope under ADR-0093 D4/D5), while a service that is **registered and unable to answer** raises `AuthzStoreUnavailableError` — 503 `SERVICE_UNAVAILABLE` — instead of degrading to "no posture". Nothing is minted and nothing is permitted on a posture that was never read. The activation gate is one body behind **two** routes, so three routes change: `POST /keys`, `POST /actions/_activation/:object/:action` and `POST /automation/:name/toggle`. Every gate reads the posture in the request's own environment scope, as the identity step does, so a `tenancy` registered `ServiceLifecycle.SCOPED` is resolved rather than reported as an outage.

`resolveService` keeps its probe contract for every other name and every other domain: the classified read is a second, opted-into member — `DomainHandlerDeps.resolveServiceOrLoud` — that a gate calls one site at a time, so no gate outside the three routes above changes behaviour. **Minor** rather than patch: this grows the exported `DomainHandlerDeps` interface with a required member, which is a published-surface addition — the same shape the three `DomainHandlerDeps` growths in 17.0.0 shipped as minor changes.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:157`, `:211` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:177`, `:268` |
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` |
Expand Down
46 changes: 46 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,52 @@ export interface DomainHandlerDeps {
*/
resolveService<K extends keyof ServiceSlotContracts>(context: HttpProtocolContext, name: K, environmentId?: string): Promise<ServiceSlotContract<K> | undefined>;
resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any;
/**
* [#15900 · #13906 decision 1 option A] The CLASSIFIED sibling of
* `resolveService`, for a domain gate whose input is an authorization FACT
* rather than an optional capability.
*
* Same chain, same registries, same order — only what a REJECTION means
* differs:
*
* - branded "never registered" (`isServiceNotRegisteredError`, #13905) →
* `undefined`, quiet. The supported composition, whose behaviour is
* exactly what it was;
* - every other rejection (a factory that threw, a scoped registration
* resolved without a scope id, a circular service dependency) →
* re-raised, for the gate to answer as an OUTAGE rather than as an
* absent fact.
*
* ⚠️ `resolveService` above stays the contract for everything else, and
* that is a boundary rather than an oversight: it is a capability PROBE
* whose collapsed `undefined` is the right shape for "is this optional
* service installed", and rerouting a NAME through this method for every
* domain at once would change every gate that reads it in one stroke —
* option C on #15900, explicitly NOT ruled, because nobody has enumerated
* those gates. A gate opts IN, one call site at a time, and says at the
* call site why its input is not a capability question.
*
* ⛔ Do not reach for this because it reads as the stricter one. The
* classification is only meaningful where "the fact could not be read" and
* "the fact is absent" license DIFFERENT answers; where they license the
* same answer it buys an outage in place of a working deployment.
*
* ⚠️ PASS THE SCOPE YOU HOLD. `environmentId` is optional in the signature
* and load bearing in use: a service registered `ServiceLifecycle.SCOPED`
* and resolved without one rejects UNBRANDED, so this method re-raises it
* and the caller answers 503 — on a service that is perfectly healthy. Under
* `resolveService` that same omission was invisible, because the probe
* absorbed it; opting a call site in without the scope converts a silent
* fallback into a manufactured outage for every caller of that door. A
* rejection out of this method should describe the SERVICE, never the call
* site's own omission.
*
* Untyped by slot on purpose, exactly like `resolveService`'s second
* overload: its callers address `tenancy`, which has no written
* `ServiceSlotContracts` entry, and inventing one here would be a shape
* nothing verifies.
*/
resolveServiceOrLoud(context: HttpProtocolContext, name: string, environmentId?: string): Promise<any>;
/**
* Unscoped service lookup on the current kernel, typed by the slot.
*
Expand Down
65 changes: 61 additions & 4 deletions packages/runtime/src/domains/activation-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
// authorization RUNG off the execution context, which is where "platform
// operator, NOT a tenant user role" (ADR-0068 D2) still means that. The
// built-in identity NAME is deliberately no longer imported: see the doc block.
import { effectiveTenancyPosture } from '@objectstack/core';
import { effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core';
import { postureEnforcesWall } from '@objectstack/spec/security';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
Expand Down Expand Up @@ -147,6 +147,26 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = {
* validation, so a refused caller writes nothing and learns nothing about the
* contract. "Write first, refuse second" is the worst shape here — it is
* #10243 with an audit trail.
*
* ⚠️ It has THREE exits, not two: a refusal, `undefined` to proceed, and a
* THROW. See the posture read below for the class that throws and why a caller
* must not absorb it into "no gate to enforce".
*
* ⚠️ TWO DOORS, one gate body — so every exit above, the throw included,
* reaches BOTH of them and neither is "the" activation door:
*
* - `./actions.ts` — `POST /actions/_activation/:object/:action`, calling
* this function directly with {@link ACTION_ACTIVATION_SUBJECT};
* - `./automation.ts` — `POST /automation/:name/toggle`, through its
* `refuseUngrantedFlowActivationWrite` wrapper and
* {@link FLOW_ACTIVATION_SUBJECT}.
*
* Both `await` the call, so the throw exit is a rejected promise the domain
* handler propagates and the dispatcher's error exit renders — nothing is
* unhandled at either door. Written down because a change to this body is a
* change to two routes: a claim about "the gate" that was measured at one door
* is a claim about half the surface, and both doors are pinned together in
* `./tenancy-posture-outage-gates.test.ts` for that reason.
*/
export async function refuseUngrantedActivationWrite(
deps: DomainHandlerDeps,
Expand All @@ -156,11 +176,48 @@ export async function refuseUngrantedActivationWrite(
const ec: any = context?.executionContext;
if (ec?.isSystem) return undefined;

// [#15900, ruled 2026-09-06 — option A] The posture is an authorization
// INPUT here, so the two ways it can be missing are two different facts:
//
// - **never registered** ⇒ no posture, and no refusal. Unchanged, and load
// bearing: ADR-0093 D4/D5 makes a deployment with no tenancy service the
// same shape as `single`, where install-level and org-level are ONE
// scope and the org admin who already cleared `manage_metadata` is the
// right authority. Refusing here would lock every single-organization
// operator out of their own switch.
// - **registered and unable to answer** ⇒ how far this install-wide row
// reaches was never READ, so whether the operator is required was never
// DECIDED. Serving the write there is #13906 decision 1 option A's
// permissive direction at a second door — 「A posture that could not be
// READ is not a posture that is ABSENT.」 — so it is answered as an
// outage (503), never as a permit.
//
// Told apart by the REGISTRY's brand (#13905) inside `resolveServiceOrLoud`,
// never by message text; the branded class is already absorbed there, so
// anything reaching this `catch` is a `tenancy` that is wired and broke.
//
// ⛔ The plain `resolveService` probe is what this gate used to read, and it
// collapses the two: a factory that threw and a name nothing registered
// both arrived as the same absent posture, and this gate then returned
// `undefined` — no refusal — for both.
//
// ⚠️ THE SCOPE ID IS PART OF THE READ, not an optimisation. `tenancy` may be
// registered `ServiceLifecycle.SCOPED`, and a scoped registration resolved
// without a scope id rejects UNBRANDED (`Scope ID required for scoped
// service 'tenancy'`) — which the classified lookup correctly re-raises,
// and this gate would then answer as a 503 it MANUFACTURED itself on a
// perfectly healthy deployment, locking the platform operator out of the
// switch that is theirs. `context.environmentId` is the same scope the
// identity step and `./keys.ts` already resolve this exact name with, so
// all three read one deployment's posture through one scope: an outage
// answered here is the service's, never this call site's omission.
let posture;
try {
posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy'));
} catch {
posture = undefined;
posture = effectiveTenancyPosture(
await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId),
);
} catch (err) {
throw new AuthzStoreUnavailableError('tenancy', err);
}
if (!posture || !postureEnforcesWall(posture)) return undefined;

Expand Down
37 changes: 29 additions & 8 deletions packages/runtime/src/domains/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* organization is re-checked here, against `sys_member`, at mint time.
*/

import { isGrantActive, effectiveTenancyPosture } from '@objectstack/core';
import { isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core';
import { postureEnforcesWall } from '@objectstack/spec/security';

import { generateApiKey } from '../security/api-key.js';
Expand Down Expand Up @@ -104,17 +104,38 @@ export async function handleKeysRequest(

// The EFFECTIVE posture, from the kernel's `tenancy` service — what is
// ENFORCED, not what `OS_TENANCY_POSTURE` requested (ADR-0093 D4/D5: a
// requested-but-unenforceable wall resolves to `single`). An absent service
// means we cannot tell, and the honest answer to that at MINT time is to
// mint: refusing would block key creation on a deployment that may have no
// wall at all.
// requested-but-unenforceable wall resolves to `single`).
//
// [#15900, ruled 2026-09-06 — option A] "No service" and "the service could
// not be built" are TWO facts, and only one of them licenses a mint:
//
// - **never registered** ⇒ no posture, and the honest answer at MINT time
// is to mint. That is the deliberate choice this comment has always
// recorded, and it is unchanged: refusing would block key creation on a
// deployment that may have no wall at all, and a no-tenancy composition
// is supported. ⚠️ It applies to THIS class only — it is not a statement
// about a posture that could not be read.
// - **registered and unable to answer** ⇒ the posture is an authorization
// INPUT that was never READ, so whether this key needs an organization
// was never DECIDED. Minting there hands back a long-lived credential on
// a question nobody answered — the permissive direction #13906 decision 1
// option A ruled against at the identity seam, whose words govern here
// too: 「A posture that could not be READ is not a posture that is
// ABSENT.」 Answered as an outage (503), never as a mint.
//
// The split is taken from the REGISTRY's own brand (#13905), never from
// message text, and the two classes arrive here already told apart —
// `resolveServiceOrLoud` absorbs the branded "never registered" and answers
// `undefined`, so anything that REJECTS is a `tenancy` that is wired and
// broken. ⛔ Hence no `isServiceNotRegisteredError` re-test below: a second
// copy of the classification is a second thing to drift.
let tenancyPosture;
try {
tenancyPosture = effectiveTenancyPosture(
await deps.resolveService(context, 'tenancy' as any, context.environmentId),
await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId),
);
} catch {
tenancyPosture = undefined;
} catch (err) {
throw new AuthzStoreUnavailableError('tenancy', err);
}
const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false;

Expand Down
Loading
Loading