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
15 changes: 15 additions & 0 deletions .changeset/22302-security-service-principal-less-text.md
Original file line number Diff line number Diff line change
@@ -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 }`.
13 changes: 10 additions & 3 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/access-recipes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 19 additions & 14 deletions packages/spec/src/contracts/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean>;

Expand All @@ -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.
*
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions packages/spec/src/data/data-engine.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
26 changes: 16 additions & 10 deletions packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down Expand Up @@ -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.
Expand Down
Loading