From 1788eefbbda8bab95b32b3c50eacc5c61795bf67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 20:56:12 +0000 Subject: [PATCH] fix(runtime)!: connector connection door refuses delegated callers on begin and revoke ADR-0143 D5, ruling A (record 6548764795): connecting, reconnecting and revoking an organization's connector is a human administrator's act. The door's begin (POST) and revoke (DELETE) legs refuse a caller admitted by an API key or an OAuth access token, or carrying an agent envelope, with 403 PERMISSION_DENIED and a sentence naming its kind and the rule. The read leg, the callback, the capability holders and the system bypass are unchanged. Claude-Session: https://claude.ai/code/session_01L9qibvkQWJban49F52ZjBc Co-authored-by: Claude --- ...8-connector-connection-delegated-caller.md | 18 +++ content/docs/automation/connectors.mdx | 5 +- ...tomation-connector-connection-door.test.ts | 124 +++++++++++++++++- packages/runtime/src/domains/automation.ts | 59 ++++++++- 4 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 .changeset/28468-connector-connection-delegated-caller.md diff --git a/.changeset/28468-connector-connection-delegated-caller.md b/.changeset/28468-connector-connection-delegated-caller.md new file mode 100644 index 00000000000..6035afde727 --- /dev/null +++ b/.changeset/28468-connector-connection-delegated-caller.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the connector connection door's begin and revoke legs refuse a delegated or agent caller by name; connecting and revoking an organization's connector is a human administrator's act + +Clause-②: no (narrowing) — `POST` and `DELETE /api/v1/automation/connectors/:name/connection` refuse a delegated or agent caller by name: one admitted by an API key or an OAuth access token, or carrying an agent envelope (`principalKind: 'agent'`, `onBehalfOf`). Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** caller narrowing at the connector connection door (`@objectstack/runtime`, ADR-0143 D5). + +- **The rule.** The begin leg (connect, reconnect) and the revoke leg refuse a delegated or agent caller, whatever it holds, with `403 PERMISSION_DENIED` and a message that names its kind (an agent acting on a person's behalf, an OAuth access token, an API key) and the rule. D5 binds a connection to the session that began it, so connecting and revoking is taken by a person signed in. +- **Unchanged.** Who holds the capability (platform administrators, the organization-administrator sets, explicit grants), the system context's bypass, the read leg (`GET …/:name/connection`) and the OAuth callback. +- **Who sees it.** Any caller on these two steps that is not a person signed in. + +**The remedy.** Take the step from a signed-in session (the Console, or a session token), as the administrator who holds the capability. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index f9848c95ce3..34aa31dc3a6 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -535,7 +535,10 @@ a rotating refresh token is never spent twice. organization-scoped `manage_connector_connections` capability. Platform administrators and organization administrators hold it by default, so an organization's owners and admins can connect its vendors from the start; - grant it to anyone else through a permission set: + grant it to anyone else through a permission set. Connecting and revoking + is a human administrator's act, taken from a signed-in session: an agent + acting for them (an OAuth client) or an API key is refused by name, whatever + it holds. ```http POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt } diff --git a/packages/runtime/src/domains/automation-connector-connection-door.test.ts b/packages/runtime/src/domains/automation-connector-connection-door.test.ts index a8324bfa742..7c10576c85a 100644 --- a/packages/runtime/src/domains/automation-connector-connection-door.test.ts +++ b/packages/runtime/src/domains/automation-connector-connection-door.test.ts @@ -16,10 +16,16 @@ import { describe, it, expect, vi } from 'vitest'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { assembleExecutionContext } from '@objectstack/core'; import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; -import { CONNECTOR_CONNECTION_CAPABILITY, CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js'; +import { + CONNECTOR_CONNECTION_CAPABILITY, + CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS, + connectorConnectionDelegatedCaller, + connectorConnectionDelegatedCallerRefusal, +} from './automation.js'; /** An authorization code as a callback carries it — a dummy. */ const AUTH_CODE = 'dummy-authorization-code'; @@ -255,3 +261,119 @@ describe('ADR-0143 D5 — connector connection door: what it accepts', () => { } }); }); + +// ADR-0143 D5 · ruling A (record 6548764795): connecting, reconnecting and +// revoking an organization's connection is a human administrator's act. The +// begin (`POST`) and revoke (`DELETE`) legs refuse a caller that acts for a +// person instead of being that person signed in, by name, whatever it holds; +// the read leg and the callback are unchanged. +describe('ADR-0143 D5 — connector connection door: begin and revoke refuse a delegated caller by name', () => { + /** The organization administrator's resolved authorization, as core's resolver hands it to the assembler. */ + const holderAuthz = { + userId: 'u_orgadmin', + tenantId: 'org_a', + positions: ['organization_admin'], + permissions: [], + directPermissions: [], + systemPermissions: ORG_ADMIN_GRANT, + org_user_ids: ['u_orgadmin'], + accessible_org_ids: ['org_a'], + }; + const ENTRY = { localization: undefined, requestLocale: undefined, accessToken: undefined, authGate: undefined } as const; + + /** The holder, signed in: a session admitted them. */ + const HOLDER_SIGNED_IN = (): HttpProtocolContext => ({ + request: {}, + admission: { credential: 'session', authGate: undefined }, + executionContext: assembleExecutionContext({ authz: holderAuthz, oauth: undefined, ...ENTRY }), + } as HttpProtocolContext); + + /** + * An AI agent the holder connected over OAuth with `actions:execute`, as the + * shared assembler builds its envelope: `principalKind: 'agent'`, + * `onBehalfOf` the holder, and the holder's own capability list. + */ + const AGENT_FOR_HOLDER = (): HttpProtocolContext => ({ + request: {}, + admission: { credential: 'session', authGate: undefined }, + executionContext: assembleExecutionContext({ + authz: holderAuthz, + oauth: { userId: 'u_orgadmin', scopes: ['openid', 'actions:execute'], clientId: 'client_agent', scopePermissions: [], delegatesActions: true }, + ...ENTRY, + }), + } as HttpProtocolContext); + + /** An OAuth access token that names no client: the principal stays the holder, the credential is still the token. */ + const OAUTH_TOKEN_FOR_HOLDER = (): HttpProtocolContext => ({ + request: {}, + admission: { credential: 'session', authGate: undefined }, + executionContext: assembleExecutionContext({ + authz: holderAuthz, + oauth: { userId: 'u_orgadmin', scopes: ['openid', 'actions:execute'], scopePermissions: [], delegatesActions: true }, + ...ENTRY, + }), + } as HttpProtocolContext); + + /** An API key the holder minted: it resolves as the holder, admitted by the key. */ + const API_KEY_FOR_HOLDER = (): HttpProtocolContext => ({ + request: {}, + admission: { credential: 'api_key', authGate: undefined }, + executionContext: assembleExecutionContext({ authz: holderAuthz, oauth: undefined, ...ENTRY }), + } as HttpProtocolContext); + + const DELEGATED = [ + ['agent', AGENT_FOR_HOLDER, 'an agent acting on a person\'s behalf'], + ['oauth', OAUTH_TOKEN_FOR_HOLDER, 'an OAuth access token'], + ['api_key', API_KEY_FOR_HOLDER, 'an API key'], + ] as const; + + it('the fixtures are what they say: each delegated caller holds the capability its human holds', () => { + expect(HOLDER_SIGNED_IN().executionContext).toMatchObject({ principalKind: 'human', systemPermissions: ORG_ADMIN_GRANT }); + expect(AGENT_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'agent', onBehalfOf: { userId: 'u_orgadmin' }, systemPermissions: ORG_ADMIN_GRANT }); + expect(OAUTH_TOKEN_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'human', oauthScopes: ['openid', 'actions:execute'], systemPermissions: ORG_ADMIN_GRANT }); + expect(API_KEY_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'human', systemPermissions: ORG_ADMIN_GRANT }); + for (const [kind, ctx] of DELEGATED) expect(connectorConnectionDelegatedCaller(ctx())).toBe(kind); + expect(connectorConnectionDelegatedCaller(HOLDER_SIGNED_IN())).toBeUndefined(); + }); + + it.each(DELEGATED)('%s: refused on begin and on revoke, 403 naming its kind and the rule, and the service is never reached', async (kind, ctx, named) => { + for (const [verb, member] of [['POST', (h: Harness) => h.begin], ['DELETE', (h: Harness) => h.revoke]] as const) { + const h = boot(); + const { response } = await connection(h, verb, ctx()); + expect(statusOf(response)).toBe(403); + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + expect(messageOf(response)).toBe(connectorConnectionDelegatedCallerRefusal(kind)); + expect(messageOf(response)).toContain(named); + expect(messageOf(response)).toContain('human administrator'); + expect(member(h)).not.toHaveBeenCalled(); + for (const m of [h.get, h.begin, h.complete, h.revoke]) expect(m).not.toHaveBeenCalled(); + } + }); + + it('the same human, signed in, is admitted on begin and on revoke', async () => { + const h = boot(); + const begun = await connection(h, 'POST', HOLDER_SIGNED_IN()); + expect(statusOf(begun.response)).toBe(200); + expect(h.begin).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ userId: 'u_orgadmin', tenantId: 'org_a' }) }); + const revoked = await connection(h, 'DELETE', HOLDER_SIGNED_IN()); + expect(statusOf(revoked.response)).toBe(200); + expect(h.revoke).toHaveBeenCalledTimes(1); + }); + + it('a system call is admitted on begin and on revoke, even carrying an API-key admission', async () => { + const h = boot(); + const sys = { request: {}, admission: { credential: 'api_key', authGate: undefined }, executionContext: { userId: 'system', isSystem: true } } as HttpProtocolContext; + expect(statusOf((await connection(h, 'POST', sys)).response)).toBe(200); + expect(statusOf((await connection(h, 'DELETE', sys)).response)).toBe(200); + expect(h.begin).toHaveBeenCalledTimes(1); + expect(h.revoke).toHaveBeenCalledTimes(1); + }); + + it.each(DELEGATED)('%s: the read leg and the callback answer as before (admitted for a holder)', async (_kind, ctx) => { + const h = boot(); + expect(statusOf((await connection(h, 'GET', ctx())).response)).toBe(200); + expect(statusOf((await callback(h, ctx(), { code: AUTH_CODE, state: 's' })).response)).toBe(200); + expect(h.get).toHaveBeenCalledTimes(1); + expect(h.complete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 12aeffe3e19..ebb57ee6bc8 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -59,6 +59,9 @@ import { // Who may START a flow by name — the one rule the trigger door, the action door // and the declared endpoint share (the maintainer's ruling, letters B and A). import { flowStartRefusal } from '../flow-start-admission.js'; +// ADR-0143 D5 · ruling A (record 6548764795) — which credential admitted the +// caller, read the way the api-action forward table reads it. +import { apiForwardCredentialKind } from '../action-execution.js'; // [#12156] ADR-0126 §7.1 clone — the whole-definition copy, the keys it must // not carry forward, the same-name refusal and the references notice. import { @@ -943,18 +946,69 @@ export const CONNECTOR_CONNECTION_CAPABILITY = 'manage_connector_connections'; const CONNECTOR_CONNECTION_DENY_MESSAGE = `Connecting, reconnecting or revoking a connector instance, or reading its connection, requires the \`${CONNECTOR_CONNECTION_CAPABILITY}\` capability.`; +/** + * ADR-0143 D5 — a caller that acts for a person instead of being that person + * signed in, by kind: `agent` (an envelope that names a delegator, + * `principalKind: 'agent'` or `onBehalfOf`), `oauth` (admitted by an OAuth + * access token) or `api_key` (admitted by an API key). The credential half is + * the forward table's own reading ({@link apiForwardCredentialKind}), so the + * two doors cannot disagree about which credential admitted a caller. + */ +export type ConnectorConnectionDelegatedCaller = 'agent' | 'oauth' | 'api_key'; + +/** The delegated kind of this request's caller, or `undefined` for a person signed in (or no recorded credential). */ +export function connectorConnectionDelegatedCaller(context: HttpProtocolContext | undefined): ConnectorConnectionDelegatedCaller | undefined { + const ec: any = context?.executionContext; + if (ec?.principalKind === 'agent' || ec?.onBehalfOf) return 'agent'; + const credential = apiForwardCredentialKind(context); + return credential === 'oauth' || credential === 'api_key' ? credential : undefined; +} + +const CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES: Readonly> = { + agent: 'an agent acting on a person\'s behalf', + oauth: 'an OAuth access token, which a client holds to act for a person', + api_key: 'an API key, which acts for its owner without them present', +}; + +/** + * The refusal sentence for a delegated caller on the begin or revoke leg, + * naming its kind and the rule. + */ +export function connectorConnectionDelegatedCallerRefusal(kind: ConnectorConnectionDelegatedCaller): string { + return ( + 'Connecting, reconnecting or revoking a connector instance is a human administrator\'s act, taken from a ' + + `signed-in session, and this request comes from ${CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES[kind]}. ` + + 'Nothing was connected or revoked. Sign in and take this step yourself.' + ); +} + /** * ADR-0143 D5 — the connection door's capability gate: * {@link CONNECTOR_CONNECTION_CAPABILITY}, fail-closed, only `isSystem` * bypassing. The callback is gated too: the browser that lands on it is the * administrator's, carrying the session that began the connect. + * + * Ruling A (record 6548764795): the begin leg (`POST`: connect, reconnect) and + * the revoke leg (`DELETE`) refuse a delegated caller by name + * ({@link connectorConnectionDelegatedCaller}), whatever it holds. D5 binds a + * connection to the session that began it, so connecting and revoking an + * organization's vendor account is a person's act, never an agent's acting for + * them. The read leg and the callback are unchanged. */ function refuseUngrantedConnectorConnectionAccess( deps: DomainHandlerDeps, context: HttpProtocolContext, + method: string, ): HttpDispatcherResult | undefined { const ec: any = context?.executionContext; if (ec?.isSystem) return undefined; + const delegated = method === 'POST' || method === 'DELETE' ? connectorConnectionDelegatedCaller(context) : undefined; + if (delegated) { + return { + handled: true, + response: deps.error(connectorConnectionDelegatedCallerRefusal(delegated), FLOW_WRITE_DENY_STATUS, { code: FLOW_WRITE_DENY_CODE }), + }; + } if (new Set(ec?.systemPermissions ?? []).has(CONNECTOR_CONNECTION_CAPABILITY)) return undefined; return { handled: true, @@ -3096,9 +3150,10 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // the organization-scoped capability, ahead of the service probe and of // any query check, as above. No walled refusal: a // connection belongs to its organization (Q1 = A), so it is served under - // every tenancy posture. + // every tenancy posture. Begin and revoke refuse a delegated caller by + // name (ruling A, record 6548764795). if (isConnectorConnectionDoor(parts, m)) { - const refusal = refuseUngrantedConnectorConnectionAccess(deps, context); + const refusal = refuseUngrantedConnectorConnectionAccess(deps, context, m); if (refusal) return refusal; }