diff --git a/.changeset/28327-manage-connector-connections.md b/.changeset/28327-manage-connector-connections.md new file mode 100644 index 00000000000..ce1634c335e --- /dev/null +++ b/.changeset/28327-manage-connector-connections.md @@ -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. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 49f31da03d5..58d830fac2c 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -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 } diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 7e79913d56f..b29f70198ac 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -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', @@ -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', diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 00c660d8b54..4181382fd56 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -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', ], }, @@ -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', ], }, diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 398658c30f7..dccb0a2c301 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -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', @@ -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', diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts index 66c92de3870..7321569bbc1 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts @@ -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 @@ -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. @@ -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. diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index 80c956e3564..995642fa076 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -415,6 +415,7 @@ const SINGLE: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', @@ -442,6 +443,7 @@ const SINGLE: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', @@ -603,6 +605,7 @@ const WALLED: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', @@ -630,6 +633,7 @@ const WALLED: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 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 26810413f8b..0de191e28a6 100644 --- a/packages/runtime/src/domains/automation-connector-connection-door.test.ts +++ b/packages/runtime/src/domains/automation-connector-connection-door.test.ts @@ -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 @@ -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'; @@ -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); @@ -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 }); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 561af647d0c..12aeffe3e19 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -931,15 +931,23 @@ 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, @@ -947,7 +955,7 @@ function refuseUngrantedConnectorConnectionAccess( ): HttpDispatcherResult | undefined { const ec: any = context?.executionContext; if (ec?.isSystem) return undefined; - if (new Set(ec?.systemPermissions ?? []).has(FLOW_AUTHORING_CAPABILITY)) return undefined; + if (new Set(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 }), @@ -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)) { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 7d4773ceb9c..0e4db3aaaa9 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -542,17 +542,18 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: "Store the value for one declared reference, then reconcile: every instance naming it re-materializes without a restart. Body is the closed `{ value }`. Answers the reference's listing entry after the reconcile, never the value. Refused, before anything is written: an undeclared reference 404 `RESOURCE_NOT_FOUND`, one an environment variable of the same name supplies 409 `RESOURCE_CONFLICT` (env wins in the chain, so a stored value would never be read), an empty or oversized value, one starting with `secret:` (the data engine would store it as a handle to another secret) or an unknown body key 400 `VALIDATION_ERROR`, and no crypto provider 503 `SERVICE_UNAVAILABLE`. Writes in one process run one at a time, with their reconcile; a concurrent first write from another replica answers 409 `RESOURCE_CONFLICT`. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-credential-door.test.ts`" }, { route: 'DELETE /automation/connectors/credential-refs/:ref', domain: '/automation', disposition: 'server-only', note: "Remove the stored value for one reference, then reconcile: every instance naming it is torn down and registered degraded, naming the reference. 404 `RESOURCE_NOT_FOUND` when nothing is stored for it. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-credential-door.test.ts`" }, - // ADR-0143 D5 — the connector connection door: an administrator connects an - // `oauth2` `authorization_code` instance for their organization (Q1 = A). The + // ADR-0143 D5 — the connector connection door: a holder of the organization- + // scoped `manage_connector_connections` connects an `oauth2` + // `authorization_code` instance for their organization (Q1 = A). The // store is `sys_connector_connection`, whose refresh token is a `Field.secret`. { route: 'GET /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', - note: "The caller's organization's connection of one declared `oauth2` `authorization_code` instance: `status` (`not_connected` / `connected` / `reconnect_required`), its reason, the granted scopes, the access token's expiry and who connected it. ⛔ Never a token. Refused: an undeclared instance 404 `RESOURCE_NOT_FOUND`; one whose auth is not that grant, or a caller with no organization, 400 `VALIDATION_ERROR`. ⚑ `manage_metadata`, fail-closed, only `isSystem` bypassing; no walled refusal, since a connection belongs to its organization. Closed query set: none. NOT JS-SDK surface on this leg: the driver is the Console's Connect / Reconnect flow (ADR-0143 S4). Pinned in `domains/automation-connector-connection-door.test.ts`" }, + note: "The caller's organization's connection of one declared `oauth2` `authorization_code` instance: `status` (`not_connected` / `connected` / `reconnect_required`), its reason, the granted scopes, the access token's expiry and who connected it. ⛔ Never a token. Refused: an undeclared instance 404 `RESOURCE_NOT_FOUND`; one whose auth is not that grant, or a caller with no organization, 400 `VALIDATION_ERROR`. ⚑ `manage_connector_connections`, the organization-scoped capability (ADR-0143 D5), fail-closed, only `isSystem` bypassing; the platform-scoped `manage_metadata` does not admit a caller. No walled refusal, since a connection belongs to its organization. Closed query set: none. NOT JS-SDK surface on this leg: the driver is the Console's Connect / Reconnect flow (ADR-0143 S4). Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'POST /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', note: "Begin connecting (or reconnecting) the instance for the caller's organization: answers `{ authorizationUrl, expiresAt }`, the vendor URL carrying a fresh `state` and an `S256` PKCE challenge. Nothing is written to the connection record until the callback completes; the pending authorization (verifier included) waits ten minutes in the cluster KV, or in process on a single node. The redirect URI is the deployment's (`OS_AUTH_URL` / `OS_BASE_URL`, or the plugin option), never the request's — 503 `SERVICE_UNAVAILABLE` when there is none. No body. Same refusals, gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'DELETE /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', note: "Revoke the caller's organization's connection: the row is removed, the cached access token dropped, and the instance re-judged — its next dispatch there is refused 'not connected'. 404 `RESOURCE_NOT_FOUND` when it was not connected. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'GET /automation/connectors/oauth/callback', domain: '/automation', disposition: 'server-only', - note: "The authorization server's redirect (ADR-0143 D5). Closed query set: `code`, `state`, `error`, `error_description`, `error_uri`, `iss`; an unknown or repeated one is 400 `VALIDATION_ERROR`. The `state` is taken once and must have been begun by the same user in the same organization, else 400 before any code is exchanged; an `error` answer is 400 naming its registered code only. The code is exchanged with the PKCE verifier and the connection stored; answers the connection's status. 503 when the token endpoint cannot be reached. Same capability gate as the rows above: the browser landing here is the administrator's. Pinned in `domains/automation-connector-connection-door.test.ts`" }, + note: "The authorization server's redirect (ADR-0143 D5). Closed query set: `code`, `state`, `error`, `error_description`, `error_uri`, `iss`; an unknown or repeated one is 400 `VALIDATION_ERROR`. The `state` is taken once and must have been begun by the same user in the same organization, else 400 before any code is exchanged; an `error` answer is 400 naming its registered code only. The code is exchanged with the PKCE verifier and the connection stored; answers the connection's status. 503 when the token endpoint cannot be reached. Same capability gate as the rows above (`manage_connector_connections`): the browser landing here is the administrator's. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' }, { route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' }, { route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle', diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 557842f02b9..d393350ad59 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -163,6 +163,11 @@ export const ADMIN_FULL_ACCESS_CAPABILITIES: Pick c.name === 'view_all_audit_log')?.scope).toBe('org'); }); + it('carries the connector connection capability, declared org-scoped (ADR-0143 D5)', () => { + // Platform administrators hold it by default through this grant; the + // organization administrator's shipped sets withhold it, pinned on the + // seeded sets in plugin-security's `default-permission-sets.test.ts`. + expect(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions).toContain('manage_connector_connections'); + expect(PLATFORM_CAPABILITIES.find((c) => c.name === 'manage_connector_connections')?.scope).toBe('org'); + }); + it('every granted system permission is a declared built-in capability (ADR-0066 registry)', () => { const unknown = (ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []).filter( (name) => !PLATFORM_CAPABILITY_NAMES.has(name), diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index c72cd36650e..94296c2ae59 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -97,6 +97,18 @@ export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ // row it reaches carries the organization column, so the organization wall // still bounds a holder to its own organization's mail. { name: 'manage_mailboxes', label: 'Manage Mailboxes', description: 'Administer inbound mailboxes: read the mailbox directory, and read every mirrored inbound message and its record associations in the holder’s organization past the mail read gates, the unassociated pool (messages no record matched) included. Without it a message is served only to its mailbox’s owner and to a caller who can read a record it is associated with.', scope: 'org' }, + // [ADR-0143 D5] The connector connection capability. Connecting a vendor + // account to an `oauth2` `authorization_code` connector instance is an + // organization act (Q1 = A: one connection per instance per organization), + // so the door that reads, begins, completes and revokes that connection + // (`/automation/connectors/:name/connection` and the OAuth callback, in + // `@objectstack/runtime`) asks this capability and nothing else — never the + // platform-scoped `manage_metadata`. Held by default by platform + // administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`); every other position, + // the organization administrator included, only by explicit grant. `org`: + // the door acts on the caller's own organization's connection, and the + // holder never sees a token. + { name: 'manage_connector_connections', label: 'Manage Connector Connections', description: 'Connect, reconnect and revoke the holder’s organization’s connection of an OAuth2 connector instance, and read its status. A connection authorizes the platform to call the vendor on the organization’s behalf; no token is ever shown to the holder.', scope: 'org' }, ]; /** Set of built-in capability names, for fast membership checks (lint, gating). */ diff --git a/ui/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/ui/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index 8a55d96c016..d4b7dea5ec2 100644 --- a/ui/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/ui/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -167,6 +167,8 @@ const CURATED_CAPABILITY_LABELS = new Set([ 'view_all_audit_log', // `@objectstack/spec` 18 added it to `PLATFORM_CAPABILITIES` (objectui#12030). 'manage_mailboxes', + // ADR-0143 D5: the organization-scoped connector connection capability. + 'manage_connector_connections', ]); export function CapabilityMultiSelectField({ diff --git a/ui/packages/fields/src/widgets/useFieldTranslation.ts b/ui/packages/fields/src/widgets/useFieldTranslation.ts index 2b5e20c2953..7584ac0bcde 100644 --- a/ui/packages/fields/src/widgets/useFieldTranslation.ts +++ b/ui/packages/fields/src/widgets/useFieldTranslation.ts @@ -260,6 +260,7 @@ const FIELD_DEFAULTS: Record = { 'capability.label.manage_sharing': 'Manage Sharing', 'capability.label.view_all_audit_log': 'View All Audit Log', 'capability.label.manage_mailboxes': 'Manage Mailboxes', + 'capability.label.manage_connector_connections': 'Manage Connector Connections', }; export const useFieldTranslation = createSafeTranslation( diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 879aa5f1e43..4ce4c967158 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -20,6 +20,7 @@ const ar = { manage_org_users: "إدارة مستخدمي المؤسسة", manage_metadata: "إدارة البيانات الوصفية", manage_mailboxes: "إدارة صناديق البريد", + manage_connector_connections: "إدارة اتصالات الموصلات", manage_platform_settings: "إدارة إعدادات المنصة", setup_access: "الوصول إلى الإعداد", setup_write: "كتابة الإعدادات", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index ea6a9dd02c8..37a8ef77b03 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -20,6 +20,7 @@ const de = { manage_org_users: "Organisationsbenutzer verwalten", manage_metadata: "Metadaten verwalten", manage_mailboxes: "Postfächer verwalten", + manage_connector_connections: "Konnektor-Verbindungen verwalten", manage_platform_settings: "Plattformeinstellungen verwalten", setup_access: "Zugriff auf die Einrichtung", setup_write: "Einstellungen schreiben", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ebb30e80a89..e3ebb9b766f 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -31,6 +31,7 @@ const en = { manage_org_users: 'Manage Organization Users', manage_metadata: 'Manage Metadata', manage_mailboxes: 'Manage Mailboxes', + manage_connector_connections: 'Manage Connector Connections', manage_platform_settings: 'Manage Platform Settings', setup_access: 'Setup Access', setup_write: 'Write Settings', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index e40411ebc2c..83261d83cdd 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -20,6 +20,7 @@ const es = { manage_org_users: "Gestionar usuarios de la organización", manage_metadata: "Gestionar metadatos", manage_mailboxes: "Gestionar los buzones", + manage_connector_connections: "Gestionar las conexiones de conectores", manage_platform_settings: "Gestionar la configuración de la plataforma", setup_access: "Acceso a la configuración", setup_write: "Escritura de la configuración", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 97144b35faa..f23239a5070 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -20,6 +20,7 @@ const fr = { manage_org_users: "Gérer les utilisateurs de l'organisation", manage_metadata: "Gérer les métadonnées", manage_mailboxes: "Gérer les boîtes aux lettres", + manage_connector_connections: "Gérer les connexions des connecteurs", manage_platform_settings: "Gérer les paramètres de la plateforme", setup_access: "Accès à la configuration", setup_write: "Écriture des paramètres", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index db0c142aad4..047e6366ef3 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -20,6 +20,7 @@ const ja = { manage_org_users: "組織ユーザーの管理", manage_metadata: "メタデータ管理", manage_mailboxes: "メールボックスの管理", + manage_connector_connections: "コネクタ接続の管理", manage_platform_settings: "プラットフォーム設定の管理", setup_access: "設定へのアクセス", setup_write: "設定の書き込み", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 6b3f822eaea..98ce948dc06 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -20,6 +20,7 @@ const ko = { manage_org_users: "조직 사용자 관리", manage_metadata: "메타데이터 관리", manage_mailboxes: "메일함 관리", + manage_connector_connections: "커넥터 연결 관리", manage_platform_settings: "플랫폼 설정 관리", setup_access: "설정 접근", setup_write: "설정 쓰기", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 39e88d673a2..9a20e65e70c 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -20,6 +20,7 @@ const pt = { manage_org_users: "Gerenciar usuários da organização", manage_metadata: "Gerenciar metadados", manage_mailboxes: "Gerenciar caixas de correio", + manage_connector_connections: "Gerenciar conexões de conectores", manage_platform_settings: "Gerenciar configurações da plataforma", setup_access: "Acesso à configuração", setup_write: "Gravação de configurações", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index c1075a0893a..fc4338982e5 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -20,6 +20,7 @@ const ru = { manage_org_users: "Управление пользователями организации", manage_metadata: "Управление метаданными", manage_mailboxes: "Управление почтовыми ящиками", + manage_connector_connections: "Управление подключениями коннекторов", manage_platform_settings: "Управление настройками платформы", setup_access: "Доступ к настройке", setup_write: "Запись настроек", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 75ac9394fef..1589f80696e 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -30,6 +30,7 @@ const zh = { manage_org_users: '管理组织用户', manage_metadata: '管理元数据', manage_mailboxes: '管理邮箱', + manage_connector_connections: '管理连接器连接', manage_platform_settings: '管理平台设置', setup_access: '访问 Setup', setup_write: '保存设置',