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
18 changes: 18 additions & 0 deletions .changeset/28468-connector-connection-delegated-caller.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata an author writes changes meaning: one HTTP door narrows which callers may take two of its steps. The refusal is the door's existing 403 PERMISSION_DENIED with a sentence naming the caller's kind, so there is nothing for a migration to rewrite and no tombstone. -->

**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.
5 changes: 4 additions & 1 deletion content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
59 changes: 57 additions & 2 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Record<ConnectorConnectionDelegatedCaller, string>> = {
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<string>(ec?.systemPermissions ?? []).has(CONNECTOR_CONNECTION_CAPABILITY)) return undefined;
return {
handled: true,
Expand Down Expand Up @@ -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;
}

Expand Down
Loading