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
23 changes: 23 additions & 0 deletions .changeset/26702-connector-oauth2-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@objectstack/runtime': minor
---

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)

Clause-②: yes
Metadata-protocol: added

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.

- **`@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`.
- `client_credentials` needs nothing more than the client secret its `credentialRef` names.
- `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.
- 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.
- 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`.
- **`@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.
- **`@objectstack/runtime`**: the connection door (D5), every route needing `manage_metadata`:
- `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.
- `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.
- No answer, refusal or log line carries a token, a code, a verifier or a client secret.
62 changes: 54 additions & 8 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -470,13 +470,57 @@ What the publish door checks, each refusal located at its key under `auth`:
- **`app-credential`** takes `vendor` (`feishu`, `dingtalk` or `wecom`), `appId`
and a required `credentialRef`. An inline `appSecret` is refused by name.

A server that does not yet run the per-dispatch token resolution holds such an
instance **degraded**, with a reason that names the grant-bearing kind, and
never calls the upstream without its credential. A datasource that borrows
from it (`external.connector`) is refused with `422`. Until the server serves
them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now;
don't emulate a grant by putting a short-lived access token behind a `bearer`
ref.
**How the server serves them.** An `oauth2` instance is served through a
**credential handle** the provider asks on every request: the access token is
obtained from `tokenUrl` (the client authenticating with HTTP Basic, or by
`client_id` alone as a public client), cached in memory only, refreshed when
it is within 30 seconds of its expiry, and refreshed once more, with one
retry, when the upstream answers `401`. Concurrent requests share one token
request, and with a cluster service a refresh runs under the cluster lock, so
a rotating refresh token is never spent twice.

- **`client_credentials`** needs nothing more: the client secret its
`credentialRef` names re-derives the token at any time.
- **`authorization_code`** needs an administrator to **connect** it, once per
organization. Until then the instance is **degraded** "not connected" (never
a failed boot). The connect is three calls, each needing `manage_metadata`:

```http
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }
GET /api/v1/automation/connectors/oauth/callback?code=…&state=… (the vendor redirects here)
DELETE /api/v1/automation/connectors/:name/connection → revoke
```

`GET …/:name/connection` answers the organization's status
(`not_connected`, `connected`, `reconnect_required`), never a token. The
`state` and the PKCE verifier are bound to the user and organization that
began the connect and are used once; a callback from anyone else is refused
before its code is exchanged. The redirect URI is the deployment's
(`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`,
or the automation plugin's `connectorOAuthRedirectUri`), never the
request's; register that URI with the vendor. The refresh token is kept
encrypted in `sys_connector_connection`, one row per organization; it is
not a sign-in account, and signing in never touches it. A run dispatches
with its own organization's connection; a run with no organization uses the
instance's only connection, and is refused when there are several. When the
vendor refuses the grant (`invalid_grant`), the connection is marked
**reconnect required** and the instance degraded until an administrator
connects it again. A revoke deletes the stored connection only: the server
does not yet revoke the refresh token at the vendor (RFC 7009), so it stays
valid there until it expires or is revoked in the vendor's console.

What is not served yet is held **degraded** with a reason that names it, and
never called without its credential: `app-credential` (its vendor exchanges
are not run yet), an `oauth2` instance on a provider that does not yet ask the
credential handle (the built-in `rest`, `openapi` and `mcp` providers do not
yet), an `mcp` instance that left its endpoints to discovery, and an `oauth2`
instance whose `tokenUrl` or `authorizationUrl` is not `https://` (plain
`http://` is accepted only to a loopback host, so the client secret and the
refresh token never cross a network in the clear). A datasource
that borrows from a grant-bearing instance (`external.connector`) is refused
with `422`. Until then, declare a static kind (`bearer`, `api-key` or `basic`)
to dispatch now; don't emulate a grant by putting a short-lived access token
behind a `bearer` ref.

### Request signing: `signed`

Expand Down Expand Up @@ -599,7 +643,9 @@ retry):
|:---|:---|
| `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` |
| Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory |
| `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` |
| `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` |
| 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 |
| 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 |
| `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` |
| `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 |
| 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 |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ One of the largest consumers of the flag;
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -628,6 +629,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/runtime/src/domains/automation.ts#handleAutomationRequest` | 1 | 52 |
| `packages/runtime/src/domains/automation.ts#isRunStarterOrRunStateReader` | 1 | 57 |
| `packages/runtime/src/domains/automation.ts#mayReadRunState` | 1 | 57 |
| `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorConnectionAccess` | 1 | 57c |
| `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorCredentialAccess` | 1 | 57b |
| `packages/runtime/src/domains/automation.ts#refuseUngrantedFlowWrite` | 1 | 57 |
| `packages/runtime/src/domains/automation.ts#refuseUngrantedRunLifecycleWrite` | 1 | 57 |
Expand Down
Loading
Loading