Repository navigation
Commit e01edb2
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
File tree
- .changeset
- content/docs
- automation
- permissions
- docs/audits
- packages
- cli/src/utils
- runtime/src
- domains
- services/service-automation/src
- oauth2-grant
- spec
- api-surface
- export-origins
- src
- contracts
- system/constants
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
470 | 470 | | |
471 | 471 | | |
472 | 472 | | |
473 | | - | |
474 | | - | |
475 | | - | |
476 | | - | |
477 | | - | |
478 | | - | |
479 | | - | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
480 | 524 | | |
481 | 525 | | |
482 | 526 | | |
| |||
599 | 643 | | |
600 | 644 | | |
601 | 645 | | |
602 | | - | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
603 | 649 | | |
604 | 650 | | |
605 | 651 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
202 | 202 | | |
203 | 203 | | |
204 | 204 | | |
| 205 | + | |
205 | 206 | | |
206 | 207 | | |
207 | 208 | | |
| |||
628 | 629 | | |
629 | 630 | | |
630 | 631 | | |
| 632 | + | |
631 | 633 | | |
632 | 634 | | |
633 | 635 | | |
| |||
0 commit comments