Skip to content

Commit 9eaa209

Browse files
feat(spec,runtime): an organization-scoped manage_connector_connections capability gates the connector connection door (ADR-0143 D5) (#28400)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoHYfNHsvqsysdsGt6GdpVVZ)_ Fixes #28327 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: updated here — content/docs/automation/connectors.mdx names the connect door's capability; no skill under skills/** teaches that door's gate ## 维护者速读 - 改了什么:新增组织级能力 `manage_connector_connections`,连接器 OAuth2 连接的四个入口(查看状态、发起连接、回调、撤销)改为只认这个能力,不再认平台级的 `manage_metadata`。 - 为什么改:给连接器绑定供应商账号是"组织"的事(ADR-0143 Q1 = A),却用平台级元数据权限把关;S4 控制台连接流程上线前要先把权限放对。 - 风险与回滚:平台管理员(`admin_full_access`)默认持有,行为不变;组织管理员默认不持有,需显式授予(不放宽任何人)。该入口今早才合并、尚未发版,没有已发布的调用方失去权限。回滚即还原本 PR。 - 先看:`packages/runtime/src/domains/automation.ts`(`refuseUngrantedConnectorConnectionAccess`)、`packages/spec/src/security/capabilities.ts`(新条目)、`packages/runtime/src/domains/automation-connector-connection-door.test.ts`(两个方向的钉子)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/automation-connector-connection-door.test.ts`,预期 22 个测试全绿,其中"`manage_metadata` alone no longer admits"与"organization administrator on the shipped grant is refused"各 4 条。 - 需要你做的:组织管理员是否应默认持有该能力(放宽,属人工地板),请裁决;代码本身无需你操作,席位落地。 ## What changes - **`@objectstack/spec`**: `PLATFORM_CAPABILITIES` gains `manage_connector_connections` (label "Manage Connector Connections", `scope: 'org'`), the capability ADR-0143 D5 delegates to the server slice. `ADMIN_FULL_ACCESS_CAPABILITIES` (the eval fixture `admin_full_access` is built from) carries it; no other shipped set does. - **`@objectstack/runtime`**: the connection door's gate (`refuseUngrantedConnectorConnectionAccess`) asks `CONNECTOR_CONNECTION_CAPABILITY` = `manage_connector_connections` for all four members: `GET` / `POST` / `DELETE /automation/connectors/:name/connection` and `GET /automation/connectors/oauth/callback`. Fail-closed, only `isSystem` bypassing, placed where it was (ahead of the service probe and the query checks). The refusal text names the new capability. The four route-ledger rows say so. - **Setup widget (`@object-ui/fields`, `@object-ui/i18n`)**: the capability picker's curated label set and `capability.label.manage_connector_connections` in `useFieldTranslation` and all ten locale packs (the spec-parity and locale-parity pins require both). - **Docs**: `content/docs/automation/connectors.mdx` named `manage_metadata` for the connect calls; it now names the new capability and who holds it. - **Changeset** `28327-manage-connector-connections.md` (minor on the four moved packages). Untouched, by the order's serial note: no provider and no `connector-credential-source.ts` edit (#26702 S2 owns those). The credential door (`/connectors/credential-refs`) and its Setup nav entry keep `manage_metadata`: they act on environment-level credentials, not on an organization's connection. ## Tests - `packages/runtime/src/domains/automation-connector-connection-door.test.ts`: the refusal pins name the new capability; new pins, each over all four members: - `manage_metadata` alone, and the whole platform-only set (`manage_metadata`, `studio.access`, `setup.access`, `manage_users`, `manage_platform_settings`) without the new capability → 403 `PERMISSION_DENIED`, no service member called. - the organization administrator's shipped grant (`manage_org_users`, `setup.access`, `setup.write`, `manage_mailboxes`) → 403, no member called; the same grant plus `manage_connector_connections` → 200 and the member called once with the caller's organization. - `isSystem` passes without the capability; an absent capability list is refused. - the gate's constant is the spec-declared, `org`-scoped capability. - `packages/spec/src/identity/platform-admin-capabilities.test.ts`: `ADMIN_FULL_ACCESS_CAPABILITIES` carries it and it is declared `org`. - `packages/plugins/plugin-security`: `default-permission-sets.test.ts` pins `admin_full_access` as its only shipped holder; the platform-administrator envelopes in four golden/equivalence suites gain it (the `TENANT_ADMIN` envelopes stay without it). ## Acceptance notes - **Default grant to organization administrators is NOT made.** ADR-0143 says Console Connect / Reconnect is organization-level, and the order's security floor says this card tightens and widens no one. Adding the capability to `organization_admin` / `organization_admin_no_bypass` would let every organization owner or admin connect a vendor account where today they cannot: a widening of an access-control boundary, which is a human floor in this lane. Until ruled, an organization administrator connects only after an explicit grant. Ablation 3 below shows the pin that holds this. - **The `manage_metadata` → `manage_connector_connections` move narrows the door relative to `main`** for a set that grants `manage_metadata` without `admin_full_access`. The door landed with #28317 (2026-10-31 08:28Z) and its changeset `26702-connector-oauth2-connections.md` is still pending in `.changeset/`, so no published version served it: no released caller loses access, and the `Clause-②` arm is declared on the protocol change (a new accepted capability name). - The door test transcribes the organization administrator's grant rather than importing it (runtime does not depend on plugin-security); the seeded-set side is pinned in plugin-security. ## Verification Implementation commit `7354e08f4` on base `origin/main` `3ba520691`; head `3979c8fbb` merges `origin/main` `ac09e7b85` (no overlapping file). After the merge: install, rebuild of the runtime closure, `pnpm --filter @objectstack/spec check:generated` exit 0 (14 artifacts up to date), and the door, spec, five plugin-security and ui parity files re-run green (22 / 403 / 92 / 68 tests). <details><summary>Build and tests</summary> - `pnpm --filter '@objectstack/runtime^...' --filter @objectstack/runtime build` → VERDICT command-exit 0. - door test + `packages/runtime/src/domains/packages-capability-gate.test.ts` → Test Files 2 passed, Tests 101 passed; spec `src/identity/platform-admin-capabilities.test.ts src/security/` → Test Files 16 passed, Tests 403 passed. - `pnpm --filter @objectstack/plugin-security test` → Test Files 238 passed, Tests 5125 passed / 45 skipped. - `pnpm --filter @objectstack/runtime test` → Test Files 491 passed, Tests 7981 passed / 19 skipped. - `pnpm --filter @objectstack/runtime typecheck`, `pnpm --filter @objectstack/spec typecheck` → exit 0 (same lock run, VERDICT command-exit 0). - ui, from `ui/`: `vitest run packages/fields/src/widgets/CapabilityMultiSelectField packages/i18n/src/__tests__/all-locales-key-parity.test.ts packages/i18n/src/__tests__/en-zh-key-parity.test.ts` → Test Files 4 passed, Tests 68 passed. - lint: `eslint --no-inline-config` over the touched non-ui files → exit 0; `ui/` eslint over the touched ui files → exit 0 (3 pre-existing warnings in `CapabilityMultiSelectField.tsx`, none on changed lines). </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs, restored to the HEAD blob with an empty git diff HEAD)</summary> The subjects are imported from source by relative path (`./automation.js`, `./default-permission-sets.js`), so no dist rebuild sits between mutation and run. 1. Put `manage_metadata` back beside the new capability in the gate → door test: 4 failed / 18 passed, exactly the four "`manage_metadata` alone no longer admits the caller" cases. 2. Remove the gate (`return undefined`) → door test: 13 failed / 9 passed: every refusal pin, every `manage_metadata`-alone pin, every organization-administrator pin and the fail-closed pin. 3. Add the capability to the `organization_admin` shipped set → plugin-security: 4 failed / 28 passed: the "no other shipped set carries the connector connection capability" pin (`expected [ 'admin_full_access', …(2) ] to deeply equal [ 'admin_full_access' ]`) and the three per-posture `TENANT_ADMIN` envelope goldens. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7354e08f4`: 113 families derived, each run with stdin closed, exit codes recorded before any pipe. `--ran` reconciliation: **113 derived, 111 run, 2 NOT-MEASURED, 0 UNRUN**. - Three first runs exited on a missing build or shallow history and were re-run after clearing it, green: `check-plugin-teardown-shape.mjs --self-test` (fetched its pinned fixture commit), `check:skill-examples` and `pnpm --dir ui exec vitest run scripts/` (built `@objectstack/client` and `@objectstack/client-react`; then Test Files 169 passed / 2 skipped). - NOT MEASURED, reason: prerequisite not met (exit 3, nothing read): `pnpm check:dual-build-cjs-loads` (needs every package's `dist/`, i.e. a full `pnpm build`) and `pnpm check:i18n` (needs the built CLI and its extract closure). No committed translation bundle names a capability, so neither reads a file this diff changes; both run in CI's `Lint & Repo Gates`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwTBxFYpcsutpHMRHe)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63a9bcb commit 9eaa209

25 files changed

Lines changed: 173 additions & 28 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
'@object-ui/fields': minor
5+
'@object-ui/i18n': minor
6+
---
7+
8+
feat(spec,runtime): an organization-scoped `manage_connector_connections` capability gates the connector connection door (ADR-0143 D5)
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: added
12+
13+
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`.
14+
15+
- **`@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.
16+
- **`@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.
17+
- **`@object-ui/fields`, `@object-ui/i18n`**: the Setup capability picker labels the new capability in all ten locales.

‎content/docs/automation/connectors.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,10 @@ a rotating refresh token is never spent twice.
483483
`credentialRef` names re-derives the token at any time.
484484
- **`authorization_code`** needs an administrator to **connect** it, once per
485485
organization. Until then the instance is **degraded** "not connected" (never
486-
a failed boot). The connect is three calls, each needing `manage_metadata`:
486+
a failed boot). The connect is three calls, each needing the
487+
organization-scoped `manage_connector_connections` capability (platform
488+
administrators hold it by default; grant it to anyone else, an organization
489+
administrator included, through a permission set):
487490

488491
```http
489492
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }

‎packages/plugins/plugin-security/src/builtin-positions.boot.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = {
687687
positions: ['everyone', 'platform_admin'],
688688
posture: 'PLATFORM_ADMIN',
689689
systemPermissions: [
690+
'manage_connector_connections',
690691
'manage_mailboxes',
691692
'manage_metadata',
692693
'manage_platform_settings',
@@ -736,6 +737,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = {
736737
positions: ['everyone', 'platform_admin'],
737738
posture: 'PLATFORM_ADMIN',
738739
systemPermissions: [
740+
'manage_connector_connections',
739741
'manage_mailboxes',
740742
'manage_metadata',
741743
'manage_platform_settings',

‎packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = {
285285
positions: ['everyone', 'platform_admin'],
286286
posture: 'PLATFORM_ADMIN',
287287
systemPermissions: [
288-
'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
288+
'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
289289
'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log',
290290
],
291291
},
@@ -324,7 +324,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = {
324324
positions: ['everyone', 'platform_admin'],
325325
posture: 'PLATFORM_ADMIN',
326326
systemPermissions: [
327-
'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
327+
'manage_connector_connections', 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users',
328328
'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log',
329329
],
330330
},

‎packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ const SINGLE: unknown = {
383383
positions: ['everyone', 'platform_admin'],
384384
posture: 'PLATFORM_ADMIN',
385385
systemPermissions: [
386+
'manage_connector_connections',
386387
'manage_mailboxes',
387388
'manage_metadata',
388389
'manage_platform_settings',
@@ -475,6 +476,7 @@ const WALLED: unknown = {
475476
positions: ['everyone', 'platform_admin'],
476477
posture: 'PLATFORM_ADMIN',
477478
systemPermissions: [
479+
'manage_connector_connections',
478480
'manage_mailboxes',
479481
'manage_metadata',
480482
'manage_platform_settings',

‎packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,9 @@ describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => {
390390
* identity object's `Admin` field group, which the `everyone` baseline withholds.
391391
* And `manage_mailboxes` (ruling 6116489638, Q4 → A on the inbound-mail record —
392392
* platform and organization administrators hold the mailbox administrator
393-
* capability by default).
393+
* capability by default). And `manage_connector_connections` (ADR-0143 D5 —
394+
* platform administrators hold the connector connection capability by default;
395+
* the organization administrator's sets withhold it, pinned below).
394396
*
395397
* The set's `description` is admin-facing copy, not capability, so it is left
396398
* out of the comparison (its shape is pinned by
@@ -424,6 +426,8 @@ describe('admin_full_access imports the kernel capability declaration unchanged
424426
'view_all_audit_log',
425427
// added on purpose — see the docblock above.
426428
'manage_mailboxes',
429+
// added on purpose (ADR-0143 D5) — see the docblock above.
430+
'manage_connector_connections',
427431
],
428432
// [#21237] added on purpose — see the docblock above. The group is read off
429433
// the identity object's declaration, never listed here.
@@ -444,6 +448,13 @@ describe('admin_full_access imports the kernel capability declaration unchanged
444448
expect(holders).toEqual(['admin_full_access']);
445449
});
446450

451+
it('[ADR-0143 D5] no other shipped set carries the connector connection capability: the organization administrator holds it only by explicit grant', () => {
452+
const holders = (defaultPermissionSets as any[])
453+
.filter((s) => (s.systemPermissions ?? []).includes('manage_connector_connections'))
454+
.map((s) => s.name);
455+
expect(holders).toEqual(['admin_full_access']);
456+
});
457+
447458
it('the imported spec constant is the declaration content — no local fork', () => {
448459
const admin = setByName('admin_full_access');
449460
// Same values, sourced from the one spec-exported copy.

‎packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ const SINGLE: unknown = {
415415
],
416416
posture: 'PLATFORM_ADMIN',
417417
systemPermissions: [
418+
'manage_connector_connections',
418419
'manage_mailboxes',
419420
'manage_metadata',
420421
'manage_platform_settings',
@@ -442,6 +443,7 @@ const SINGLE: unknown = {
442443
],
443444
posture: 'PLATFORM_ADMIN',
444445
systemPermissions: [
446+
'manage_connector_connections',
445447
'manage_mailboxes',
446448
'manage_metadata',
447449
'manage_platform_settings',
@@ -603,6 +605,7 @@ const WALLED: unknown = {
603605
],
604606
posture: 'PLATFORM_ADMIN',
605607
systemPermissions: [
608+
'manage_connector_connections',
606609
'manage_mailboxes',
607610
'manage_metadata',
608611
'manage_platform_settings',
@@ -630,6 +633,7 @@ const WALLED: unknown = {
630633
],
631634
posture: 'PLATFORM_ADMIN',
632635
systemPermissions: [
636+
'manage_connector_connections',
633637
'manage_mailboxes',
634638
'manage_metadata',
635639
'manage_platform_settings',

‎packages/runtime/src/domains/automation-connector-connection-door.test.ts‎

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
// /automation/connectors/:name/connection` and `GET /automation/connectors/
55
// oauth/callback`.
66
//
7-
// What is pinned here is the DOOR: who may reach it (`manage_metadata`, under
8-
// every posture — a connection belongs to its organization), what it accepts
7+
// What is pinned here is the DOOR: who may reach it (the organization-scoped
8+
// `manage_connector_connections`, and not the platform-scoped `manage_metadata`,
9+
// under every posture — a connection belongs to its organization), what it accepts
910
// (no query parameter on the connection routes; the closed authorization-
1011
// response set on the callback, each once), how the service's refusals reach
1112
// the wire, and that a token never travels back out. The connect flow, the
@@ -14,9 +15,11 @@
1415

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

18+
import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security';
19+
1720
import { HttpDispatcher } from '../http-dispatcher.js';
1821
import type { HttpProtocolContext } from '../http-dispatcher.js';
19-
import { CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js';
22+
import { CONNECTOR_CONNECTION_CAPABILITY, CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js';
2023

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

55-
const ADMIN = (): HttpProtocolContext => ({
58+
/** A caller holding exactly `systemPermissions`, in organization `org_a`. */
59+
const HOLDING = (systemPermissions: string[], userId = 'u_admin'): HttpProtocolContext => ({
5660
request: {},
57-
executionContext: { userId: 'u_admin', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: ['manage_metadata'] },
61+
executionContext: { userId, tenantId: 'org_a', positions: [], permissions: [], systemPermissions },
5862
} as HttpProtocolContext);
5963

60-
const MEMBER = (): HttpProtocolContext => ({
61-
request: {},
62-
executionContext: { userId: 'u_member', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: [] },
63-
} as HttpProtocolContext);
64+
/** The connection administrator: the capability, and nothing else. */
65+
const ADMIN = (): HttpProtocolContext => HOLDING(['manage_connector_connections']);
66+
67+
const MEMBER = (): HttpProtocolContext => HOLDING([], 'u_member');
68+
69+
/**
70+
* The organization administrator's shipped grant (`organization_admin` in
71+
* plugin-security's default sets), transcribed: it does not carry the
72+
* connection capability, which an organization administrator holds only by
73+
* explicit grant.
74+
*/
75+
const ORG_ADMIN_GRANT = ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'];
6476

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

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

112+
it('asks the organization-scoped capability the spec declares for it', () => {
113+
expect(CONNECTOR_CONNECTION_CAPABILITY).toBe('manage_connector_connections');
114+
expect(PLATFORM_CAPABILITIES.find((c) => c.name === CONNECTOR_CONNECTION_CAPABILITY)?.scope).toBe('org');
115+
});
116+
117+
const EVERY_MEMBER = [
118+
['GET', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'GET', ctx), (h: Harness) => h.get],
119+
['POST', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'POST', ctx), (h: Harness) => h.begin],
120+
['DELETE', (h: Harness, ctx: HttpProtocolContext) => connection(h, 'DELETE', ctx), (h: Harness) => h.revoke],
121+
['GET callback', (h: Harness, ctx: HttpProtocolContext) => callback(h, ctx, { code: AUTH_CODE, state: 's' }), (h: Harness) => h.complete],
122+
] as const;
123+
124+
it.each(EVERY_MEMBER)('%s: the platform-scoped `manage_metadata` alone no longer admits the caller', async (_verb, call) => {
125+
for (const grant of [['manage_metadata'], ['manage_metadata', 'studio.access', 'setup.access', 'manage_users', 'manage_platform_settings']]) {
126+
const h = boot();
127+
const { response } = await call(h, HOLDING(grant));
128+
expect(statusOf(response)).toBe(403);
129+
expect(codeOf(response)).toBe('PERMISSION_DENIED');
130+
for (const member of [h.get, h.begin, h.complete, h.revoke]) expect(member).not.toHaveBeenCalled();
131+
}
132+
});
133+
134+
it.each(EVERY_MEMBER)('%s: an organization administrator on the shipped grant is refused; granted the capability, admitted', async (_verb, call, member) => {
135+
const refused = boot();
136+
const denied = await call(refused, HOLDING(ORG_ADMIN_GRANT));
137+
expect(statusOf(denied.response)).toBe(403);
138+
expect(codeOf(denied.response)).toBe('PERMISSION_DENIED');
139+
for (const m of [refused.get, refused.begin, refused.complete, refused.revoke]) expect(m).not.toHaveBeenCalled();
140+
141+
const admitted = boot();
142+
const allowed = await call(admitted, HOLDING([...ORG_ADMIN_GRANT, 'manage_connector_connections']));
143+
expect(statusOf(allowed.response)).toBe(200);
144+
expect(member(admitted)).toHaveBeenCalledTimes(1);
145+
expect(member(admitted)).toHaveBeenCalledWith(expect.objectContaining({ context: expect.objectContaining({ tenantId: 'org_a' }) }));
146+
});
147+
148+
it('the system context passes without the capability; an absent capability list is refused (fail-closed)', async () => {
149+
const system = boot();
150+
const sys = { request: {}, executionContext: { userId: 'system', isSystem: true } } as HttpProtocolContext;
151+
expect(statusOf((await connection(system, 'GET', sys)).response)).toBe(200);
152+
const absent = boot();
153+
const noList = { request: {}, executionContext: { userId: 'u_x', tenantId: 'org_a' } } as HttpProtocolContext;
154+
expect(statusOf((await connection(absent, 'GET', noList)).response)).toBe(403);
155+
expect(absent.get).not.toHaveBeenCalled();
156+
});
157+
100158
it('a walled posture does not refuse the holder: a connection belongs to its organization', async () => {
101159
for (const posture of ['group', 'isolated'] as const) {
102160
const h = boot({ posture });

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -931,23 +931,31 @@ export const CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS: readonly string[] = Object.f
931931
'code', 'state', 'error', 'error_description', 'error_uri', 'iss',
932932
]);
933933

934+
/**
935+
* ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check
936+
* the server slice declares". The organization-scoped capability the spec
937+
* declares for exactly this door (`PLATFORM_CAPABILITIES`). Connecting a vendor
938+
* account is an organization act (Q1 = A), so the door asks this capability
939+
* alone: the platform-scoped `manage_metadata` does not admit a caller here.
940+
*/
941+
export const CONNECTOR_CONNECTION_CAPABILITY = 'manage_connector_connections';
942+
934943
const CONNECTOR_CONNECTION_DENY_MESSAGE =
935-
`Connecting, reconnecting or revoking a connector instance requires the \`${FLOW_AUTHORING_CAPABILITY}\` capability.`;
944+
`Connecting, reconnecting or revoking a connector instance, or reading its connection, requires the \`${CONNECTOR_CONNECTION_CAPABILITY}\` capability.`;
936945

937946
/**
938-
* ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check
939-
* the server slice declares": `manage_metadata`, the capability the
940-
* credential door above asks for the same instances, fail-closed, only
941-
* `isSystem` bypassing. The callback is gated too: the browser that lands on
942-
* it is the administrator's, carrying the session that began the connect.
947+
* ADR-0143 D5 — the connection door's capability gate:
948+
* {@link CONNECTOR_CONNECTION_CAPABILITY}, fail-closed, only `isSystem`
949+
* bypassing. The callback is gated too: the browser that lands on it is the
950+
* administrator's, carrying the session that began the connect.
943951
*/
944952
function refuseUngrantedConnectorConnectionAccess(
945953
deps: DomainHandlerDeps,
946954
context: HttpProtocolContext,
947955
): HttpDispatcherResult | undefined {
948956
const ec: any = context?.executionContext;
949957
if (ec?.isSystem) return undefined;
950-
if (new Set<string>(ec?.systemPermissions ?? []).has(FLOW_AUTHORING_CAPABILITY)) return undefined;
958+
if (new Set<string>(ec?.systemPermissions ?? []).has(CONNECTOR_CONNECTION_CAPABILITY)) return undefined;
951959
return {
952960
handled: true,
953961
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
30843092
if (refusal) return refusal;
30853093
}
30863094

3087-
// ADR-0143 D5 — CONNECTOR CONNECTION GATE: `manage_metadata`, ahead of the
3088-
// service probe and of any query check, as above. No walled refusal: a
3095+
// ADR-0143 D5 — CONNECTOR CONNECTION GATE: `manage_connector_connections`,
3096+
// the organization-scoped capability, ahead of the service probe and of
3097+
// any query check, as above. No walled refusal: a
30893098
// connection belongs to its organization (Q1 = A), so it is served under
30903099
// every tenancy posture.
30913100
if (isConnectorConnectionDoor(parts, m)) {

0 commit comments

Comments
 (0)