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
17 changes: 17 additions & 0 deletions .changeset/28327-manage-connector-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
'@object-ui/fields': minor
'@object-ui/i18n': minor
---

feat(spec,runtime): an organization-scoped `manage_connector_connections` capability gates the connector connection door (ADR-0143 D5)

Clause-②: yes (widening)
Metadata-protocol: added

Connecting a vendor account to an `oauth2` `authorization_code` connector instance is an organization act (ADR-0143 Q1 = A: one connection per instance per organization). The door that does it now asks a capability declared for exactly that act, instead of the platform-scoped `manage_metadata`.

- **`@objectstack/spec`**: `PLATFORM_CAPABILITIES` gains `manage_connector_connections` (label "Manage Connector Connections", scope `org`). A permission set may grant it in `systemPermissions`, and a resource may name it in `requiredPermissions`. `ADMIN_FULL_ACCESS_CAPABILITIES` carries it, so platform administrators (`admin_full_access`) hold it by default. No other shipped set carries it: an organization administrator, or anyone else, holds it only by an explicit grant.
- **`@objectstack/runtime`**: `GET` / `POST` / `DELETE /api/v1/automation/connectors/:name/connection` and `GET /api/v1/automation/connectors/oauth/callback` require `manage_connector_connections`, fail-closed, with only the system context bypassing. Without it they answer `403 PERMISSION_DENIED` before the automation service is reached. `manage_metadata` alone no longer admits a caller to these four routes. The door first shipped in this same release line gated on `manage_metadata`; no published version served it, so no released caller loses access. A platform administrator on `admin_full_access` is admitted as before.
- **`@object-ui/fields`, `@object-ui/i18n`**: the Setup capability picker labels the new capability in all ten locales.
5 changes: 4 additions & 1 deletion content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,10 @@ a rotating refresh token is never spent twice.
`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`:
a failed boot). The connect is three calls, each needing the
organization-scoped `manage_connector_connections` capability (platform
administrators hold it by default; grant it to anyone else, an organization
administrator included, through a permission set):

```http
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down Expand Up @@ -736,6 +737,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log',
],
},
Expand Down Expand Up @@ -324,7 +324,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log',
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ const SINGLE: unknown = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down Expand Up @@ -475,6 +476,7 @@ const WALLED: unknown = {
positions: ['everyone', 'platform_admin'],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,9 @@ describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => {
* identity object's `Admin` field group, which the `everyone` baseline withholds.
* And `manage_mailboxes` (ruling 6116489638, Q4 → A on the inbound-mail record —
* platform and organization administrators hold the mailbox administrator
* capability by default).
* capability by default). And `manage_connector_connections` (ADR-0143 D5 —
* platform administrators hold the connector connection capability by default;
* the organization administrator's sets withhold it, pinned below).
*
* The set's `description` is admin-facing copy, not capability, so it is left
* out of the comparison (its shape is pinned by
Expand Down Expand Up @@ -424,6 +426,8 @@ describe('admin_full_access imports the kernel capability declaration unchanged
'view_all_audit_log',
// added on purpose — see the docblock above.
'manage_mailboxes',
// added on purpose (ADR-0143 D5) — see the docblock above.
'manage_connector_connections',
],
// [#21237] added on purpose — see the docblock above. The group is read off
// the identity object's declaration, never listed here.
Expand All @@ -444,6 +448,13 @@ describe('admin_full_access imports the kernel capability declaration unchanged
expect(holders).toEqual(['admin_full_access']);
});

it('[ADR-0143 D5] no other shipped set carries the connector connection capability: the organization administrator holds it only by explicit grant', () => {
const holders = (defaultPermissionSets as any[])
.filter((s) => (s.systemPermissions ?? []).includes('manage_connector_connections'))
.map((s) => s.name);
expect(holders).toEqual(['admin_full_access']);
});

it('the imported spec constant is the declaration content — no local fork', () => {
const admin = setByName('admin_full_access');
// Same values, sourced from the one spec-exported copy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ const SINGLE: unknown = {
],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down Expand Up @@ -442,6 +443,7 @@ const SINGLE: unknown = {
],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down Expand Up @@ -603,6 +605,7 @@ const WALLED: unknown = {
],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down Expand Up @@ -630,6 +633,7 @@ const WALLED: unknown = {
],
posture: 'PLATFORM_ADMIN',
systemPermissions: [
'manage_connector_connections',
'manage_mailboxes',
'manage_metadata',
'manage_platform_settings',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
// /automation/connectors/:name/connection` and `GET /automation/connectors/
// oauth/callback`.
//
// What is pinned here is the DOOR: who may reach it (`manage_metadata`, under
// every posture — a connection belongs to its organization), what it accepts
// What is pinned here is the DOOR: who may reach it (the organization-scoped
// `manage_connector_connections`, and not the platform-scoped `manage_metadata`,
// under every posture — a connection belongs to its organization), what it accepts
// (no query parameter on the connection routes; the closed authorization-
// response set on the callback, each once), how the service's refusals reach
// the wire, and that a token never travels back out. The connect flow, the
Expand All @@ -14,9 +15,11 @@

import { describe, it, expect, vi } from 'vitest';

import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security';

import { HttpDispatcher } from '../http-dispatcher.js';
import type { HttpProtocolContext } from '../http-dispatcher.js';
import { CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js';
import { CONNECTOR_CONNECTION_CAPABILITY, CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js';

/** An authorization code as a callback carries it — a dummy. */
const AUTH_CODE = 'dummy-authorization-code';
Expand Down Expand Up @@ -52,15 +55,24 @@ function boot(opts: { posture?: 'single' | 'group' | 'isolated'; service?: Recor
return { dispatcher: new HttpDispatcher(kernel as never), get, begin, complete, revoke };
}

const ADMIN = (): HttpProtocolContext => ({
/** A caller holding exactly `systemPermissions`, in organization `org_a`. */
const HOLDING = (systemPermissions: string[], userId = 'u_admin'): HttpProtocolContext => ({
request: {},
executionContext: { userId: 'u_admin', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: ['manage_metadata'] },
executionContext: { userId, tenantId: 'org_a', positions: [], permissions: [], systemPermissions },
} as HttpProtocolContext);

const MEMBER = (): HttpProtocolContext => ({
request: {},
executionContext: { userId: 'u_member', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: [] },
} as HttpProtocolContext);
/** The connection administrator: the capability, and nothing else. */
const ADMIN = (): HttpProtocolContext => HOLDING(['manage_connector_connections']);

const MEMBER = (): HttpProtocolContext => HOLDING([], 'u_member');

/**
* The organization administrator's shipped grant (`organization_admin` in
* plugin-security's default sets), transcribed: it does not carry the
* connection capability, which an organization administrator holds only by
* explicit grant.
*/
const ORG_ADMIN_GRANT = ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'];

const ANONYMOUS = (): HttpProtocolContext => ({ request: {}, executionContext: {} } as HttpProtocolContext);

Expand Down Expand Up @@ -88,15 +100,61 @@ describe('ADR-0143 D5 — connector connection door: who may reach it', () => {
['POST', (h: Harness) => connection(h, 'POST', MEMBER())],
['DELETE', (h: Harness) => connection(h, 'DELETE', MEMBER())],
['GET callback', (h: Harness) => callback(h, MEMBER(), { code: AUTH_CODE, state: 's' })],
] as const)('%s without `manage_metadata` is refused 403 and never reaches the service', async (_verb, call) => {
] as const)('%s without `manage_connector_connections` is refused 403 and never reaches the service', async (_verb, call) => {
const h = boot();
const { response } = await call(h);
expect(statusOf(response)).toBe(403);
expect(codeOf(response)).toBe('PERMISSION_DENIED');
expect(messageOf(response)).toContain('`manage_metadata`');
expect(messageOf(response)).toContain('`manage_connector_connections`');
for (const member of [h.get, h.begin, h.complete, h.revoke]) expect(member).not.toHaveBeenCalled();
});

it('asks the organization-scoped capability the spec declares for it', () => {
expect(CONNECTOR_CONNECTION_CAPABILITY).toBe('manage_connector_connections');
expect(PLATFORM_CAPABILITIES.find((c) => c.name === CONNECTOR_CONNECTION_CAPABILITY)?.scope).toBe('org');
});

const EVERY_MEMBER = [
['GET', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'GET', ctx), (h: Harness) => h.get],
['POST', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'POST', ctx), (h: Harness) => h.begin],
['DELETE', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'DELETE', ctx), (h: Harness) => h.revoke],
['GET callback', (h: Harness, ctx: HttpProtocolContext) => callback(h, ctx, { code: AUTH_CODE, state: 's' }), (h: Harness) => h.complete],
] as const;

it.each(EVERY_MEMBER)('%s: the platform-scoped `manage_metadata` alone no longer admits the caller', async (_verb, call) => {
for (const grant of [['manage_metadata'], ['manage_metadata', 'studio.access', 'setup.access', 'manage_users', 'manage_platform_settings']]) {
const h = boot();
const { response } = await call(h, HOLDING(grant));
expect(statusOf(response)).toBe(403);
expect(codeOf(response)).toBe('PERMISSION_DENIED');
for (const member of [h.get, h.begin, h.complete, h.revoke]) expect(member).not.toHaveBeenCalled();
}
});

it.each(EVERY_MEMBER)('%s: an organization administrator on the shipped grant is refused; granted the capability, admitted', async (_verb, call, member) => {
const refused = boot();
const denied = await call(refused, HOLDING(ORG_ADMIN_GRANT));
expect(statusOf(denied.response)).toBe(403);
expect(codeOf(denied.response)).toBe('PERMISSION_DENIED');
for (const m of [refused.get, refused.begin, refused.complete, refused.revoke]) expect(m).not.toHaveBeenCalled();

const admitted = boot();
const allowed = await call(admitted, HOLDING([...ORG_ADMIN_GRANT, 'manage_connector_connections']));
expect(statusOf(allowed.response)).toBe(200);
expect(member(admitted)).toHaveBeenCalledTimes(1);
expect(member(admitted)).toHaveBeenCalledWith(expect.objectContaining({ context: expect.objectContaining({ tenantId: 'org_a' }) }));
});

it('the system context passes without the capability; an absent capability list is refused (fail-closed)', async () => {
const system = boot();
const sys = { request: {}, executionContext: { userId: 'system', isSystem: true } } as HttpProtocolContext;
expect(statusOf((await connection(system, 'GET', sys)).response)).toBe(200);
const absent = boot();
const noList = { request: {}, executionContext: { userId: 'u_x', tenantId: 'org_a' } } as HttpProtocolContext;
expect(statusOf((await connection(absent, 'GET', noList)).response)).toBe(403);
expect(absent.get).not.toHaveBeenCalled();
});

it('a walled posture does not refuse the holder: a connection belongs to its organization', async () => {
for (const posture of ['group', 'isolated'] as const) {
const h = boot({ posture });
Expand Down
27 changes: 18 additions & 9 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,23 +931,31 @@ export const CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS: readonly string[] = Object.f
'code', 'state', 'error', 'error_description', 'error_uri', 'iss',
]);

/**
* ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check
* the server slice declares". The organization-scoped capability the spec
* declares for exactly this door (`PLATFORM_CAPABILITIES`). Connecting a vendor
* account is an organization act (Q1 = A), so the door asks this capability
* alone: the platform-scoped `manage_metadata` does not admit a caller here.
*/
export const CONNECTOR_CONNECTION_CAPABILITY = 'manage_connector_connections';

const CONNECTOR_CONNECTION_DENY_MESSAGE =
`Connecting, reconnecting or revoking a connector instance requires the \`${FLOW_AUTHORING_CAPABILITY}\` capability.`;
`Connecting, reconnecting or revoking a connector instance, or reading its connection, requires the \`${CONNECTOR_CONNECTION_CAPABILITY}\` capability.`;

/**
* ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check
* the server slice declares": `manage_metadata`, the capability the
* credential door above asks for the same instances, 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.
* 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.
*/
function refuseUngrantedConnectorConnectionAccess(
deps: DomainHandlerDeps,
context: HttpProtocolContext,
): HttpDispatcherResult | undefined {
const ec: any = context?.executionContext;
if (ec?.isSystem) return undefined;
if (new Set<string>(ec?.systemPermissions ?? []).has(FLOW_AUTHORING_CAPABILITY)) return undefined;
if (new Set<string>(ec?.systemPermissions ?? []).has(CONNECTOR_CONNECTION_CAPABILITY)) return undefined;
return {
handled: true,
response: deps.error(CONNECTOR_CONNECTION_DENY_MESSAGE, FLOW_WRITE_DENY_STATUS, { code: FLOW_WRITE_DENY_CODE }),
Expand Down Expand Up @@ -3084,8 +3092,9 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
if (refusal) return refusal;
}

// ADR-0143 D5 — CONNECTOR CONNECTION GATE: `manage_metadata`, ahead of the
// service probe and of any query check, as above. No walled refusal: a
// ADR-0143 D5 — CONNECTOR CONNECTION GATE: `manage_connector_connections`,
// 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.
if (isConnectorConnectionDoor(parts, m)) {
Expand Down
Loading
Loading