diff --git a/.changeset/22302-security-service-principal-less-text.md b/.changeset/22302-security-service-principal-less-text.md new file mode 100644 index 00000000000..ce7675d497b --- /dev/null +++ b/.changeset/22302-security-service-principal-less-text.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): `ISecurityService` no longer says a context with no principal is admitted or keeps its scope + +Clause-②: no + +The contract text on `ISecurityService.getReadFilter`, `ISecurityService.canExport` and `ISecurityService.canReadObject` (its verdict's arm list and its fails-closed paragraph) said that a context carrying no principal at all keeps the scope the other layers compose for it, or is admitted, as the security middleware hands it through. Since ADR-0096 D5 strict mode `plugin-security` does neither. A non-system context with no position, no named permission set and no user id now gets `false` from `canReadObject` and `canExport`, the deny filter from `getReadFilter`, and `403 PERMISSION_DENIED` (`PermissionDeniedError`) from the engine middleware's principal-less refusal. The text now says so, at each of the three methods, and cites ADR-0096 D5. + +The TSDoc on `ExecutionContext.flowRunId` and on the `ExecutionContext` input type (`kernel/execution-context.zod.ts`), and on `BaseEngineOptionsSchema.context` (`data/data-engine.zod.ts`), no longer teaches `{ flowRunId }` alone as an admitted, unscoped context: it carries no principal and is refused like any other. + +Only comments change. No behaviour, export or type moves, and the system bypass and the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no permission set read as before. The corrected text ships in the published `.d.ts`. + +What changes for you: nothing at runtime. If your code read the old text and passes the engine a context that carries neither the caller's principal nor `isSystem: true`, it is refused; pass the caller's own context or, for platform code whose own door already authorized the caller, the explicit system opt-in `{ isSystem: true }`. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index bb096e98177..3c8193f5446 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -123,9 +123,16 @@ interface EngineQueryOptions { ``` `ExecutionContext` is the author state — every field optional (the parse result, -with defaults applied, is `ExecutionContextParsed`). Supply what you have, the engine reads what it needs. A system read passes -`{ isSystem: true }`; an automation run with no resolvable identity passes only -its run id (`{ flowRunId }`), a context that deliberately carries no principal. +with defaults applied, is `ExecutionContextParsed`). Supply what you have, the engine reads what it needs. + +What it needs is a principal or an explicit elevation. Pass the caller's own +context (a user id, a position or a named permission set), or, for platform code +whose own door already authorized the caller, the system opt-in +`{ isSystem: true }`. A context that carries neither is refused by the security +plugin with `403 PERMISSION_DENIED` (`PermissionDeniedError`) before the operation +runs, for every verb (ADR-0096 D5). `{ flowRunId }` alone is such a context: a run +id is provenance, not identity, and grants nothing, so a context that carries it +must also carry the caller's principal or `isSystem: true`. #### Removed in protocol 17: `cursor` and `distinct` diff --git a/content/docs/permissions/access-recipes.mdx b/content/docs/permissions/access-recipes.mdx index 398ae847a89..19ba0e6bec2 100644 --- a/content/docs/permissions/access-recipes.mdx +++ b/content/docs/permissions/access-recipes.mdx @@ -49,7 +49,7 @@ Two separate questions: - `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.** - `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system". - A schedule-triggered run has no user, so under `user` it runs unscoped — declare `system` to make that intentional. + A schedule-triggered run has no user, so under `user` its data operations are refused — declare `system` to make the elevation explicit and intended. ### Interface: gate the app, the nav, and the action diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 98160e061f8..60695f7288d 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -313,9 +313,10 @@ export interface ISecurityService { * **The deny baseline (ADR-0056 D2).** A non-system caller that carries a * principal (a position, a named permission set or a user id) and resolves * NO permission set gets the same DENY filter: an empty set list grants no - * row, as it grants no object ({@link canReadObject}). Only a context that - * carries no principal at all keeps the scope the other layers compose for - * it (ADR-0096 stages that context separately). + * row, as it grants no object ({@link canReadObject}). So does a non-system + * context that carries no principal at all (ADR-0096 D5 strict mode): the + * DENY filter, before any layer composes a scope, as the engine middleware's + * principal-less refusal (`403 PERMISSION_DENIED`) answers the same context. * * **⚠️ Request-scoped: call it per request, and never memoise what it * returns.** The documented use — `engine.find(object, { where: await @@ -627,8 +628,10 @@ export interface ISecurityService { * "allowed". A system context bypasses and returns `true`. A caller that * carries a principal and resolves no permission set gets `false` — the * ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate - * refuses that caller's read; only a context that carries no principal at - * all is admitted, as the middleware hands it through (ADR-0096). + * refuses that caller's read. So does a non-system context that carries no + * principal at all (ADR-0096 D5 strict mode): `false` before anything + * resolves, the answer the middleware's principal-less refusal + * (`403 PERMISSION_DENIED`) gives it. */ canExport(object: string, context?: SecurityContext): Promise; @@ -647,13 +650,13 @@ export interface ISecurityService { * deployment. Any door that bypasses the engine middleware MUST ask both. * * The verdict is the middleware's own read gate, arm for arm and in its order: - * the `isSystem` bypass, the ADR-0056 D2 deny baseline for a caller that - * carries a principal and resolves no permission set (only a context that - * carries no principal at all is handed through), the - * fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 - * `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and - * the ADR-0090 D10 delegator intersection for an on-behalf-of caller. It is - * computed from the SAME resolution the enforcement path uses — never + * the `isSystem` bypass, the ADR-0096 D5 refusal of a context that carries no + * principal at all (`false`, before anything resolves), the ADR-0056 D2 deny + * baseline for a caller that carries a principal and resolves no permission + * set, the fail-closed refusal on an unresolvable object posture, the + * ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD + * grant, and the ADR-0090 D10 delegator intersection for an on-behalf-of + * caller. It is computed from the SAME resolution the enforcement path uses — never * re-derived from permission sets by the caller — so a door that asks reaches * the same admission verdict `/data` reaches, by construction. * @@ -667,8 +670,10 @@ export interface ISecurityService { * "allowed". A system context bypasses and returns `true`. A caller that * carries a principal and resolves no permission set gets `false` — the * ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate - * refuses that caller; only a context that carries no principal at all is - * admitted, as the middleware hands it through (ADR-0096). + * refuses that caller. So does a non-system context that carries no + * principal at all (ADR-0096 D5 strict mode): `false` before anything + * resolves, the answer the middleware's principal-less refusal + * (`403 PERMISSION_DENIED`) gives it. * * **OPTIONAL, and absence is a defined state — not a bug.** A security service * that predates this method omits it, and a consumer resolving the service as diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 6a96208da70..a8a88bf3510 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -64,11 +64,13 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({ * `ExecutionContextSchema` gives `positions`/`permissions`/`isSystem` * parse-time defaults, which makes them REQUIRED in its inferred output * type. On a caller-supplied option that asserts something untrue: that - * every data-engine context carries a principal. Callers routinely pass a - * slice — `{ isSystem: true }` for a system read — and a flow run that - * resolves no identity passes provenance alone (`{ flowRunId }`, #3712), a - * context deliberately carrying no principal at all. `.partial()` states the - * real contract: supply what you have, the engine reads what it needs. + * every data-engine context carries a principal. A system context does not: + * callers routinely pass a slice, `{ isSystem: true }` for a system read. + * Every other context must carry the caller's principal. The security plugin + * refuses a non-system context with no user id, no position and no named + * permission set (`{ flowRunId }` alone included) with `403 PERMISSION_DENIED` + * (ADR-0096 D5). `.partial()` states the real contract: supply what you have, + * the engine reads what it needs. */ context: ExecutionContextSchema.partial().optional(), })); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 6c8a272e682..233330053c4 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -331,13 +331,16 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ * its own target record, which `isSystem` cannot express for a * `runAs:'user'` run without elevating it. * - * It may be the ONLY populated field of the envelope. A run that resolves no - * principal (a schedule-triggered `runAs:'user'` run) passes exactly - * `{ flowRunId }`: every principal gate keys on `isSystem`/`userId`/ - * `positions`/`permissions`, so such a context authorizes identically to no - * context at all, and the run keeps the #1888 unscoped posture it already had - * while becoming attributable (#3712). Surfaced to hooks as - * `HookContext.provenance`, deliberately not folded into `session`. + * It is never a principal, so a context carrying `{ flowRunId }` alone is + * not admitted: every principal gate keys on `isSystem`/`userId`/`positions`/ + * `permissions`, and a non-system context with none of the principal fields + * is refused with `403 PERMISSION_DENIED` (ADR-0096 D5). The automation + * engine builds no such context. A data node of a run that resolves no + * principal (a schedule-triggered `runAs:'user'` run) is refused with + * `AUTOMATION_UNSCOPED_RUN_DATA_ACCESS` before the data engine is called, and + * a `runAs:'system'` run passes `isSystem: true` with this id beside it. + * Surfaced to hooks as `HookContext.provenance`, deliberately not folded into + * `session`. */ flowRunId: z.string().optional(), @@ -496,9 +499,12 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ * caller rather than out of a parse — `options.context` and everything the * engine threads it into. * - * Some callers have no principal to state at all: a system read passes - * `{ isSystem: true }`, and a flow run with no resolvable identity passes - * provenance alone (`{ flowRunId }`, #3712). + * Any subset is legal input, but not every subset is admitted. A non-system + * context that carries no principal (no user id, no position, no named + * permission set) is refused by the security plugin with `403 PERMISSION_DENIED` + * (ADR-0096 D5); a run id alone (`{ flowRunId }`) is provenance, not a + * principal, and is refused the same way. A caller acting for no user passes the + * explicit system opt-in: a system read passes `{ isSystem: true }`. * * Spelled `ExecutionContextInput` until protocol 17; ADR-0122 phase 2 moved the * author state onto the bare name and retired that synonym.