Skip to content

Commit 128684d

Browse files
Elon Muskclaude
andauthored
fix(runtime): gate the /automation definition writes on manage_metadata (#10242)
* fix(runtime): gate the /automation definition writes on manage_metadata (#10145) POST /automation, PUT /automation/:name and DELETE /automation/:name registered without any capability check, so any authenticated caller could author, modify and deregister flow definitions. Flow metadata is registered at environment scope, not organization scope, so on a walled multi-organization deployment the write crossed the tenant wall: a plain tenant org owner holding organization_admin (and answered 403 by /meta, /ai/tools and /packages) deleted a shipped flow that then read 404 for the actor, for an unrelated tenant and for the platform admin. The three definition writes now demand `manage_metadata` — ADR-0066 D1's authoring capability, the same key `PUT /meta/:type/:name` and every state-changing `/packages` route already carry. One predicate (`isFlowAuthoringWrite`), placed with the domain's anonymous floor and run-state read gate and ahead of both the service probe and body validation, so a refused caller writes nothing and cannot fingerprint whether automation is mounted. Execution routes are deliberately out of the write set: trigger (both shapes) and resume run flows rather than author them — resume is already fail-closed on resumeAuthority — and toggle mutates engine enablement, filed separately rather than folded into a security fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r * test(runtime): the handlerReady stub-slot row drives POST /automation as an author (#10145) The `POST /` row of the #4058 stub-slot pin is a definition write, so the manage_metadata gate — which sits ahead of the service probe on purpose — answers it 403 before the 501 that row exists to pin. The caller is per row now: the write row authors, the execution rows keep the ordinary caller. Every assertion is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 279188a commit 128684d

7 files changed

Lines changed: 568 additions & 8 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
**Behaviour change (security tightening):** the `/api/v1/automation` **definition writes** now require the `manage_metadata` capability (#10145).
6+
7+
`POST /api/v1/automation`, `PUT /api/v1/automation/:name` and `DELETE /api/v1/automation/:name``automation.create` / `automation.update` / `automation.delete` on the SDK — were reachable by **any authenticated caller**. They now answer **403 `PERMISSION_DENIED`** unless the caller holds `manage_metadata` (ADR-0066 D1's authoring capability), the same key the sibling `PUT /api/v1/meta/:type/:name` and every state-changing `/api/v1/packages/*` route already demand. Engine self-invocation (`isSystem`) bypasses, as on every other capability gate.
8+
9+
**Existing credentialed callers that author flows over HTTP will start getting 403** and must be granted `manage_metadata`. A flow is authored metadata: this closes the last write door onto the metadata plane that did not ask the metadata plane's question.
10+
11+
What was measured on a walled multi-organization deployment (`OS_TENANCY_POSTURE=isolated`): a plain tenant org owner holding `organization_admin` — the same session answered 403 by `PUT /meta/:type/:name`, `POST /ai/tools/:tool/execute` and `POST /packages/*` — created, modified and deleted flows through this door, all 200. Flow definitions are registered at **environment** scope, not organization scope, so the write crossed the tenant wall: a shipped flow deleted by one tenant read 404 for the actor, for an unrelated tenant **and** for the platform admin, and an injected flow read 200 for all three.
12+
13+
**Deliberately unchanged — execution is not authoring:**
14+
15+
- `POST /automation/:name/trigger` and the legacy `POST /automation/trigger/:name` **run** a flow. They keep their existing posture (authenticated, plus the flow's own `runAs` authorization envelope).
16+
- `POST /automation/:name/runs/:runId/resume` is already fail-closed through the suspended node's `resumeAuthority`; a metadata capability in front of it would refuse the very user the flow paused for.
17+
- `POST /automation/:name/toggle` mutates engine enablement rather than a definition, and is filed separately rather than folded into a security fix.
18+
- The reads (`GET /automation`, `GET /automation/:name`, the run surfaces) are untouched; run-state reads keep their `sys_automation_run` grant.
19+
20+
The gate sits ahead of the service probe and ahead of body validation, so a refused caller neither writes anything nor learns from a 501-vs-403 whether the deployment mounts automation at all.

packages/runtime/src/domains/automation-body-validation.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,22 @@ function makeDispatcher() {
4242
return { dispatcher: new HttpDispatcher(kernel), spies };
4343
}
4444

45-
const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any;
45+
/**
46+
* [#10145] The caller carries `manage_metadata` — the ADR-0066 D1 authoring
47+
* capability the `/automation` definition writes (`POST /`, `PUT /:name`,
48+
* `DELETE /:name`) now demand, the same gate the sibling `/meta` and
49+
* `/packages` writes already carry.
50+
*
51+
* The cases below are about BODY VALIDATION — which malformed shapes are
52+
* refused, with which field codes. They were written when any
53+
* authenticated session could register a flow, i.e. their `{ userId: 'user_1' }`
54+
* stub encoded exactly the premise the gate destroys, so without a capability
55+
* they would now stop at the 403 before reaching the behaviour each one is named
56+
* after. Only the CALLER changes here; every mechanism, assertion and expected
57+
* value is untouched. The gate itself is pinned in
58+
* `automation-write-capability-gate.test.ts`.
59+
*/
60+
const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any;
4661

4762
async function expectValidationFailure(run: Promise<unknown>, label: string) {
4863
let thrown: unknown;

packages/runtime/src/domains/automation-put-post-error-parity.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,22 @@ function makeDispatcher() {
107107
return { dispatcher: new HttpDispatcher(kernel), registered, calls };
108108
}
109109

110-
const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any;
110+
/**
111+
* [#10145] The caller carries `manage_metadata` — the ADR-0066 D1 authoring
112+
* capability the `/automation` definition writes (`POST /`, `PUT /:name`,
113+
* `DELETE /:name`) now demand, the same gate the sibling `/meta` and
114+
* `/packages` writes already carry.
115+
*
116+
* The cases below are about PARITY between the POST and PUT doors — that an
117+
* identical refusal is classified identically whichever door it arrives at. They were written when any
118+
* authenticated session could register a flow, i.e. their `{ userId: 'user_1' }`
119+
* stub encoded exactly the premise the gate destroys, so without a capability
120+
* they would now stop at the 403 before reaching the behaviour each one is named
121+
* after. Only the CALLER changes here; every mechanism, assertion and expected
122+
* value is untouched. The gate itself is pinned in
123+
* `automation-write-capability-gate.test.ts`.
124+
*/
125+
const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any;
111126

112127
/** A definition that is legal at every gate the fake runs. */
113128
const WELL_FORMED = {

packages/runtime/src/domains/automation-register-error-class.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,22 @@ function makeDispatcher(options?: { registerFlow?: (name: string, definition: un
139139
return { dispatcher: new HttpDispatcher(kernel), spies, registered };
140140
}
141141

142-
const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any;
142+
/**
143+
* [#10145] The caller carries `manage_metadata` — the ADR-0066 D1 authoring
144+
* capability the `/automation` definition writes (`POST /`, `PUT /:name`,
145+
* `DELETE /:name`) now demand, the same gate the sibling `/meta` and
146+
* `/packages` writes already carry.
147+
*
148+
* The cases below are about the ERROR CLASS a rejected definition is served as
149+
* (400 vs 500) and the unknown-flow 404. They were written when any
150+
* authenticated session could register a flow, i.e. their `{ userId: 'user_1' }`
151+
* stub encoded exactly the premise the gate destroys, so without a capability
152+
* they would now stop at the 403 before reaching the behaviour each one is named
153+
* after. Only the CALLER changes here; every mechanism, assertion and expected
154+
* value is untouched. The gate itself is pinned in
155+
* `automation-write-capability-gate.test.ts`.
156+
*/
157+
const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any;
143158

144159
/** A definition that is legal at every gate the fake runs. */
145160
const WELL_FORMED = {

0 commit comments

Comments
 (0)