Skip to content

Commit e01edb2

Browse files
feat(service-automation): serve oauth2 connector instances per dispatch, and connect an authorization_code instance per organization (ADR-0143 S1) (#28317)
domain:server#1 · os-elon-musk · session_016WV4cxkPFWL5Ht4oxW9eG5 <!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D5gP5MCiaBg7Xrj8AC3vqC8)_ Part of #26702 Clause-②: yes (four optional `IAutomationService` members, four server-only routes, a new system object) Metadata-protocol: added ## 维护者速读 - 改了什么:声明 `auth: { type: 'oauth2' }` 的连接器实例现在真正可以派发了。令牌按每次派发取得、过期前刷新、遇 401 刷新一次再重试,访问令牌只放内存。`client_credentials` 只需客户端密钥;`authorization_code` 由管理员按组织「连接」一次,刷新令牌加密存在新对象 `sys_connector_connection`。 - 新路由(都要 `manage_metadata`):`GET/POST/DELETE /automation/connectors/:name/connection`、回调 `GET /automation/connectors/oauth/callback`。state 与 PKCE 绑定发起的用户和组织、只能用一次;回调地址按部署固定,不从请求里取。 - 没变的:内置 rest/openapi/mcp 提供方本切片不接入(S2 才接),所以它们上的 oauth2 实例照旧降级;`app-credential` 照旧降级;数据源借用照旧 422。 - 偏离:复用 `manage_metadata`,没有新加能力;绑定到用户 + 组织而不是会话(执行上下文里没有会话 id)。详见下方 Deviations。 - 先看:`packages/services/service-automation/src/oauth2-grant/grant-handle.ts` 和 `plugin.ts` 里的 `completeConnectorConnection`。 - 复审:fable 契约复审 PASS(23bbe3f4,无必须修改项)。按席位裁定,本 PR 补了两条建议:存储读不了时连接门返回 503;非 loopback 的 `tokenUrl` / `authorizationUrl` 必须是 https。另外文档写明断开连接暂不在厂商侧吊销。其余建议由 #28326、#28327 承接。 - 需要你做的:本 PR 超过 3000 行,席位 ACCEPT 和增量复审通过后,需要你或治理审批人合并。 ## What changes ADR-0143 server slice 1 (S1 in the slice plan on #26702): the connection record (D3), per-dispatch resolution and refresh (D4), and the redirect route (D5) for `oauth2` connector instances. S0 (#28137, PR #28213) declared the kinds and held every such instance degraded. This slice serves them. **`@objectstack/service-automation`** - `oauth2-grant/token-endpoint.ts` is the one token request: the code exchange (with the PKCE verifier and the repeated `redirect_uri`), `client_credentials`, and refresh. S0 left the client-auth method open. This slice picks `client_secret_basic`, the method RFC 6749 §2.3.1 says every server must support, with each half form-encoded. A public client sends its `client_id` in the body. - No answer, a `429` or a `5xx` → `CONNECTOR_UPSTREAM_UNAVAILABLE`. - `invalid_grant` → `OAuth2GrantRefusedError`. - `invalid_client` / `unauthorized_client` / `401` → `OAuth2ClientRefusedError`. - A `2xx` without a bearer `access_token` is refused. - A refusal names at most a registered §5.2 code, never the server's text. - `oauth2-grant/grant-handle.ts` implements the S0 seam, `ConnectorCredentialHandle`, which `callWithBorrowedCredential` already drives: refresh inside the 30 s skew margin, and once on a `401` with one retry. - Single flight per instance (`client_credentials`) or per organization (`authorization_code`). With a cluster service, a refresh runs under `withLock('connector-connection:<instance>:<org>:refresh')`, and the row is re-read inside the lock. - A rotated refresh token is written only over the revision that was read. A row that moved meanwhile caches nothing. - `invalid_grant` marks the row `reconnect_required`, nulls the token, and re-judges the instance. - Selection: the run's organization picks the connection. A run with none uses the only connection, or is refused `422` naming the count. - `sys_connector_connection` (new, Q2 = B) holds one row per `(organization, instance)` (Q1 = A, unique `organization` index): `refresh_token` (`Field.secret`), `status`, `status_reason`, `scopes`, `expires_at`, `revision`, `connected_at`, `connected_by`. It has no access token and no data door (`apiEnabled: false`). The store is `connector-connections.ts`. - `oauth2-grant/authorization.ts` covers D5's begin half: an `S256` PKCE pair, a 256-bit `state`, and the authorization URL. The pending authorization waits in the cluster KV for ten minutes, keyed by the SHA-256 of the state and taken once (`delete … { ifVersion }`), or in process on a single node. Nothing pending is written to the connection record. - `plugin.ts`: - The reconcile serves `oauth2` through the handle, with no static `auth`. - An `authorization_code` instance with no working connection is held degraded, "not connected" or "reconnect required". That is hold kind `connection`, never a failed boot. - The four connection-door members are implemented. The callback takes the state once, checks expiry, then checks that the user and organization match the ones that began the connect, all before the code is exchanged. - The connect seeds the access token it obtained, so the first dispatch spends no refresh. - An `oauth2` instance whose `tokenUrl` or `authorizationUrl` is not `https:` is held degraded at the reconcile and refused `400` at the door. Plain `http:` is accepted only to a loopback host (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`). The reason names the field, never the URL. The check is in `oauth2-grant/endpoint-transport.ts`, server-side; the spec still admits any URL. - `getConnectorConnection` answers `503` when the deployment has no data engine or the store read fails, as its contract says, never "not connected". The engine's own message is not carried. - `engine.ts`: `registerConnectorProvider(key, factory, { credentialHandle: true })` is the opt-in, and `providerAppliesCredentialHandle(key)` reads it. **`@objectstack/spec`**: `IAutomationService` gains four optional members (`getConnectorConnection`, `beginConnectorConnection`, `completeConnectorConnection`, `revokeConnectorConnection`) and their request and result types. `sys_connector_connection` joins the `service-automation` platform object names. **`@objectstack/runtime`**: four routes, all `server-only` in the route ledger. - `GET` / `POST` / `DELETE /automation/connectors/:name/connection` take no query parameter. - `GET /automation/connectors/oauth/callback` takes a closed set: `code`, `state`, `error`, `error_description`, `error_uri`, `iss`. A repeated parameter is refused by name. - Every route needs `manage_metadata`, fail-closed, with only `isSystem` bypassing. There is no walled refusal, because a connection belongs to its organization. Also in this PR: the tenancy census (in reach, `organization_id`), an organization-ownership inventory row (`attribution`), and the connectors docs page with two new failure-mode rows. The page also says a revoke deletes only the stored connection: the refresh token is not yet revoked at the vendor (RFC 7009). ## Scope choices the seat accepted (for the contract review) - **(a) A server-only provider guard.** The opt-in is a registration option, `registerConnectorProvider(…, { credentialHandle: true })`, with no new spec flag. An `oauth2` instance on a provider that has not opted in is held degraded, so it is never called without its token. The built-in `rest` / `openapi` / `mcp` providers do not opt in in this slice. Wiring them is S2, so until S2 lands, a real deployment serves `oauth2` only through a third-party provider that opts in. - **(b) `app-credential` stays degraded until S2.** Its vendor exchanges belong to the providers slice's shared transport. S1 serves `oauth2` with both grants. ## Deviations from the dispatch, stated 1. **Capability.** The claim named a new `manage_connector_connections` capability. This PR reuses `manage_metadata`, the capability the credential door already asks for on the same instances. A new capability would also have meant changes to the eval-user fixture, the UI capability widget and the default grants, which are outside this slice. The `capabilities.ts` seam is not touched. This is a narrowing; a dedicated capability can be split out later without a contract break. 2. **Binding is to user plus organization, not to a session.** `ExecutionContext` carries `userId` and `tenantId` but no session id. The threat D5 names is a code injected into another user's callback (login CSRF, RFC 6749 §10.12). Binding to the user and organization that began the connect refuses it before the code is exchanged. What it does not cover is two sessions of the same user in the same organization. Within that user's own grants, that is not a privilege change. 3. **`iss` is accepted but not validated.** RFC 9207 validation needs the issuer from server metadata, which arrives with S2's discovery. Until then the key is in the closed set so a compliant server's callback is not refused. 4. **D3's "dynamically registered client"** is not persisted here. DCR is part of the MCP authorization flow (S2), and an `mcp` instance that leaves its endpoints to discovery stays degraded with a reason that says so. 5. **Cross-replica re-judging.** A refused grant re-runs the reconcile on the replica that saw it. Another replica's handle still refuses at dispatch, because it reads the row on each dispatch, and its descriptor catches up on its next reconcile. 6. **Datasource lending** (`external.connector` through `connector-credential-source.ts`) still refuses grant-bearing kinds with `422`. It is unchanged here and belongs to a later slice. 7. **The callback answers JSON.** The Console's Connect flow (S4) decides where the browser lands after the redirect. 8. **Re-materialization is per "any working connection", not per organization.** A connect, revoke or refusal in one organization, while another stays connected, does not rebuild the instance, so the other organizations keep their cached access tokens. The handle reads the row on every dispatch. ## Contract review follow-ups The fable contract review at `23bbe3f4` (comment 6539266538) was PASS, with no must-fix. The seat ruled on its six should-fixes as follows. - **Applied here:** - SF1: the 503 on an unreadable store. - SF5: https-only endpoints off loopback. - The docs sentence for the SF6 gap. - **Carried to #28326:** - SF2: seal the pending PKCE verifier. - SF3: update the connection row conditionally on its revision. - SF6: RFC 7009 revocation at the vendor on disconnect. - **Carried to #28327:** - SF4: a dedicated org-scoped `manage_connector_connections` capability, before S4. ## Why ADR-0143 opens outbound connector OAuth in the open tier. S0 shipped the declarations and held them degraded. Without S1 an `oauth2` instance can be declared but never dispatched. ## Verification - New tests: - `oauth2-grant/token-endpoint.test.ts`, `authorization.test.ts`, `grant-handle.test.ts`: 36 cases. - `oauth2-grant/endpoint-transport.test.ts`: 16 cases (which schemes and hosts are accepted). - `connector-connection-door.test.ts`: 19 cases on a real ObjectQL over SQLite, a real token endpoint on a local `node:http` server, and a provider that drives `callWithBorrowedCredential`. - `connector-materialization.test.ts`: the S0 stand-in block is replaced by 9 cases plus the static-kinds control. - Runtime `domains/automation-connector-connection-door.test.ts`: 12 cases. - The two registered-object pins (`activation-ledger-registration`, `plugin-suspended-run-wiring`) gain `sys_connector_connection`. - `pnpm --filter @objectstack/service-automation test`: 240 files, 3047 passed. `typecheck` is green for service-automation, runtime, spec and cli. - The no-leak pins cover the log lines (kernel logger at `debug`, stdout, stderr, console), `GET /connectors` descriptors, the degraded reason, every door answer and an engine read of the row. None of them carries the client secret, the code, the verifier, either refresh token or either access token. The logs are asserted non-empty. - The writes pin: across a connect, a refresh and a revoke, the only objects written are `sys_connector_connection` and `sys_secret`, and nothing named `metadata`, `account`, `session` or `user` is read or written (D6). - Ablations, 31 of them through `scripts/ablation-replace.mjs`, each red and each restored: registered-codes-only, Basic form-encoding, the bearer check, the skew margin, both single flights, the several-connections 422, the reconnect mark on `invalid_grant`, the cluster lock, the stale-revision write, the user/organization binding, state expiry, take-once (memory and KV `ifVersion`), the provider opt-in, the callback's closed set, the repeated-parameter refusal, the `manage_metadata` gate, the no-query rule on the connection routes, a verifier in a log line, "not connected" failing the boot, the re-judge on a refused grant, the seeded first token, `refresh_token` as a `Field.secret`, the https-only endpoint check (reconcile and door), http only to loopback (unit and reconcile), and the door's 503 on no engine, on a failed read, and without the engine's message. - Gates: every family `dispatch-gates --commands` derives, plus `pnpm lint`. Exit codes and the `--ran` reconcile are in the os-dev report on #26702. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (The spec change is interfaces and a name constant.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (`.changeset/26702-connector-oauth2-connections.md`), and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxkPFWL5Ht4oxW9eG5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a878ff6 commit e01edb2

34 files changed

Lines changed: 3804 additions & 105 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(service-automation): `oauth2` connector instances are served per dispatch, and an administrator connects an `authorization_code` one for their organization (ADR-0143 S1, part of #26702)
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
ADR-0143 S0 (#28137) let a declarative connector instance declare `auth: { type: 'oauth2', ... }`, and this server held every such instance degraded. This slice serves it.
13+
14+
- **`@objectstack/service-automation`**: an `oauth2` instance is materialized with a `credentialHandle` and no static `auth` (D4). The handle obtains the access token from `tokenUrl` (a confidential client authenticates with HTTP Basic, a public client sends its `client_id`), keeps it in memory only, refreshes it within the 30-second skew margin and once on a `401`, and shares one token request among concurrent dispatches. With a cluster service a refresh runs under the cluster lock. An unreachable or `5xx` token endpoint is `CONNECTOR_UPSTREAM_UNAVAILABLE`.
15+
- `client_credentials` needs nothing more than the client secret its `credentialRef` names.
16+
- `authorization_code` keeps its refresh token in a new object, `sys_connector_connection` (D3): one row per organization and instance, the token a `Field.secret`, never an access token, never a sign-in account (D6). An instance nobody connected is degraded "not connected", never a failed boot. A refused grant (`invalid_grant`) marks the row `reconnect_required` and degrades the instance until it is connected again. A dispatch uses its run's organization's connection; a run with no organization uses the only one, or is refused `422` when there are several.
17+
- A provider opts in with `registerConnectorProvider(key, factory, { credentialHandle: true })`, saying it asks the handle on every request. An `oauth2` instance on any other provider stays degraded, so it is never called without its token. The built-in `rest`, `openapi` and `mcp` providers do not opt in yet (S2), and `app-credential` stays degraded until S2.
18+
- A new plugin option, `connectorOAuthRedirectUri`, fixes the redirect URI; otherwise it is `OS_BASE_URL` (or `OS_AUTH_URL`) plus `/api/v1/automation/connectors/oauth/callback`.
19+
- **`@objectstack/spec`**: `IAutomationService` gains four optional members, `getConnectorConnection`, `beginConnectorConnection`, `completeConnectorConnection` and `revokeConnectorConnection`, with their request and result types (`ConnectorConnectionState`, `ConnectorConnectionBegin`, …). `sys_connector_connection` joins the `service-automation` platform object names.
20+
- **`@objectstack/runtime`**: the connection door (D5), every route needing `manage_metadata`:
21+
- `GET` / `POST` / `DELETE /api/v1/automation/connectors/:name/connection` answer the organization's connection status, begin a connect (a fresh `state` and an `S256` PKCE challenge bound to the caller's user and organization, answered as the vendor's authorization URL), or revoke it. They take no query parameter.
22+
- `GET /api/v1/automation/connectors/oauth/callback` is the vendor's redirect. Its query set is closed (`code`, `state`, `error`, `error_description`, `error_uri`, `iss`), each parameter once. The `state` is used once, and a callback from another user or organization is refused before its code is exchanged.
23+
- No answer, refusal or log line carries a token, a code, a verifier or a client secret.

‎content/docs/automation/connectors.mdx‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -470,13 +470,57 @@ What the publish door checks, each refusal located at its key under `auth`:
470470
- **`app-credential`** takes `vendor` (`feishu`, `dingtalk` or `wecom`), `appId`
471471
and a required `credentialRef`. An inline `appSecret` is refused by name.
472472

473-
A server that does not yet run the per-dispatch token resolution holds such an
474-
instance **degraded**, with a reason that names the grant-bearing kind, and
475-
never calls the upstream without its credential. A datasource that borrows
476-
from it (`external.connector`) is refused with `422`. Until the server serves
477-
them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now;
478-
don't emulate a grant by putting a short-lived access token behind a `bearer`
479-
ref.
473+
**How the server serves them.** An `oauth2` instance is served through a
474+
**credential handle** the provider asks on every request: the access token is
475+
obtained from `tokenUrl` (the client authenticating with HTTP Basic, or by
476+
`client_id` alone as a public client), cached in memory only, refreshed when
477+
it is within 30 seconds of its expiry, and refreshed once more, with one
478+
retry, when the upstream answers `401`. Concurrent requests share one token
479+
request, and with a cluster service a refresh runs under the cluster lock, so
480+
a rotating refresh token is never spent twice.
481+
482+
- **`client_credentials`** needs nothing more: the client secret its
483+
`credentialRef` names re-derives the token at any time.
484+
- **`authorization_code`** needs an administrator to **connect** it, once per
485+
organization. Until then the instance is **degraded** "not connected" (never
486+
a failed boot). The connect is three calls, each needing `manage_metadata`:
487+
488+
```http
489+
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }
490+
GET /api/v1/automation/connectors/oauth/callback?code=…&state=… (the vendor redirects here)
491+
DELETE /api/v1/automation/connectors/:name/connection → revoke
492+
```
493+
494+
`GET …/:name/connection` answers the organization's status
495+
(`not_connected`, `connected`, `reconnect_required`), never a token. The
496+
`state` and the PKCE verifier are bound to the user and organization that
497+
began the connect and are used once; a callback from anyone else is refused
498+
before its code is exchanged. The redirect URI is the deployment's
499+
(`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`,
500+
or the automation plugin's `connectorOAuthRedirectUri`), never the
501+
request's; register that URI with the vendor. The refresh token is kept
502+
encrypted in `sys_connector_connection`, one row per organization; it is
503+
not a sign-in account, and signing in never touches it. A run dispatches
504+
with its own organization's connection; a run with no organization uses the
505+
instance's only connection, and is refused when there are several. When the
506+
vendor refuses the grant (`invalid_grant`), the connection is marked
507+
**reconnect required** and the instance degraded until an administrator
508+
connects it again. A revoke deletes the stored connection only: the server
509+
does not yet revoke the refresh token at the vendor (RFC 7009), so it stays
510+
valid there until it expires or is revoked in the vendor's console.
511+
512+
What is not served yet is held **degraded** with a reason that names it, and
513+
never called without its credential: `app-credential` (its vendor exchanges
514+
are not run yet), an `oauth2` instance on a provider that does not yet ask the
515+
credential handle (the built-in `rest`, `openapi` and `mcp` providers do not
516+
yet), an `mcp` instance that left its endpoints to discovery, and an `oauth2`
517+
instance whose `tokenUrl` or `authorizationUrl` is not `https://` (plain
518+
`http://` is accepted only to a loopback host, so the client secret and the
519+
refresh token never cross a network in the clear). A datasource
520+
that borrows from a grant-bearing instance (`external.connector`) is refused
521+
with `422`. Until then, declare a static kind (`bearer`, `api-key` or `basic`)
522+
to dispatch now; don't emulate a grant by putting a short-lived access token
523+
behind a `bearer` ref.
480524

481525
### Request signing: `signed`
482526

@@ -599,7 +643,9 @@ retry):
599643
|:---|:---|
600644
| `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` |
601645
| Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory |
602-
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
646+
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) this server does not serve yet (`app-credential`; `oauth2` on a provider that does not ask the credential handle) | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
647+
| An `authorization_code` instance nobody connected, or whose grant the vendor refused | **Not fatal**: the instance registers **degraded**, "not connected" or "reconnect required" — connect it (`POST …/connectors/:name/connection`), and it materializes without a restart |
648+
| The token endpoint is unreachable or answers `5xx` / `429` | **Not fatal**: the dispatch fails `CONNECTOR_UPSTREAM_UNAVAILABLE` and is retried on the existing backoff; the connection stays connected |
603649
| `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` |
604650
| `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart |
605651
| The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff |

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ One of the largest consumers of the flag;
202202
| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` |
203203
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal` |
204204
| 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` |
205+
| 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` |
205206
| 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `assertDeploymentAuthority` |
206207
| 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `isOrganizationWrite`, `bindWebhookProvenanceStamp`, `bindDeclaredWebhookNameGuard` |
207208
| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` |
@@ -628,6 +629,7 @@ and CI refuses one that differs from what the tree produces.
628629
| `packages/runtime/src/domains/automation.ts#handleAutomationRequest` | 1 | 52 |
629630
| `packages/runtime/src/domains/automation.ts#isRunStarterOrRunStateReader` | 1 | 57 |
630631
| `packages/runtime/src/domains/automation.ts#mayReadRunState` | 1 | 57 |
632+
| `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorConnectionAccess` | 1 | 57c |
631633
| `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorCredentialAccess` | 1 | 57b |
632634
| `packages/runtime/src/domains/automation.ts#refuseUngrantedFlowWrite` | 1 | 57 |
633635
| `packages/runtime/src/domains/automation.ts#refuseUngrantedRunLifecycleWrite` | 1 | 57 |

0 commit comments

Comments
 (0)