From d55b01d8b921bc68f4d99133a0a352e82518aa2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:19:32 +0000 Subject: [PATCH 1/4] feat(plugin-security): organization_admin holds manage_connector_connections by default (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxkKcPULsq3fo4JFAX --- .../src/objects/default-permission-sets.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index f5fd7d52890..ff8c86b04b5 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -437,7 +437,15 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // `manage_mailboxes` (ruling 6116489638, Q4 → A): an organization // administrator reads its organization's mailboxes and their unassociated // pool; the organization wall bounds it to its own organization's rows. - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'], + // + // `manage_connector_connections` ([ADR-0143] D5, ruling B on the + // capability's card, record 6545223261): the organization administrator is + // who connects their organization's OAuth2 vendors, so the shipped set holds + // it from day one instead of waiting on a hand-bound permission set. The + // capability is `org`-scoped and this set is granted per organization, so + // the holder connects their own organization's instances only. The no-bypass + // variant inherits it through `deriveWallLessOrgAdmin`: never copy it there. + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes', 'manage_connector_connections'], // [#21237] Keeps the identity object's `Admin` group for an org admin, who // resolves `member_default` too; the derived no-bypass variant carries it // over. See `identityAdminFieldSecurity`. From 59701d600e233c05701f683d7d72ef3b0c5bc19d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:31:59 +0000 Subject: [PATCH 2/4] test(plugin-security,runtime): pin organization administrators as default holders of manage_connector_connections The shipped-set pin names admin_full_access and both organization-admin sets as the only holders and member_default / viewer_readonly as withheld; the no-bypass variant's list equals its parent's. The grant goldens gain the capability on every organization-admin envelope inside the organization (and the agent acting on its behalf), never outside it. The connection door admits the shipped organization-admin grant for its own organization and refuses the same person active in another one. Docs: connectors.mdx names organization admins as default holders. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxkKcPULsq3fo4JFAX --- .../28427-org-admin-connector-connections.md | 14 ++++++ content/docs/automation/connectors.mdx | 7 +-- .../src/builtin-positions.boot.test.ts | 8 ++-- ...nt-permission-set-name.equivalence.test.ts | 8 ++-- .../src/grant-readers-by-name.golden.test.ts | 4 +- .../objects/default-permission-sets.test.ts | 24 ++++++++-- ...lve-authz-grant-set-by-name.golden.test.ts | 2 + ...tomation-connector-connection-door.test.ts | 46 +++++++++++++------ 8 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 .changeset/28427-org-admin-connector-connections.md diff --git a/.changeset/28427-org-admin-connector-connections.md b/.changeset/28427-org-admin-connector-connections.md new file mode 100644 index 00000000000..c3289569636 --- /dev/null +++ b/.changeset/28427-org-admin-connector-connections.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +feat(plugin-security): organization administrators hold `manage_connector_connections` by default, so an organization's owners and admins can connect its OAuth2 connectors without a hand-bound permission set (ADR-0143 D5, ruling B) + +Clause-②: yes (widening: the shipped `organization_admin` set, and `organization_admin_no_bypass` through its derivation, now admit their holders at the connector connection door; no other set changes) +Metadata-protocol: none + +The connector connection door (`GET` / `POST` / `DELETE /api/v1/automation/connectors/:name/connection` and the OAuth callback) asks the organization-scoped `manage_connector_connections` capability. Until now only platform administrators held it by default, so an organization owner or admin was refused until someone granted it to them through a permission set. + +- **Who gains it.** Every member whose organization role contains `owner` or `admin`, through the `organization_admin` set the platform already grants them per organization: `organization_admin` under a walled posture, `organization_admin_no_bypass` on a wall-less one. The no-bypass variant inherits the capability from `organization_admin`'s declaration; it is not a second list. +- **What it reaches.** Their own organization's connection only. The grant is scoped to the organization it was made in, and the door acts on the caller's active organization, so an organization admin who is a plain member elsewhere is still refused there. No token is ever shown to the holder. +- **Unchanged.** `member_default`, `viewer_readonly` and every other shipped set still hold the capability only by an explicit grant. No other capability of any set changes. An agent acting on behalf of an organization administrator resolves the administrator's system permissions as before, so it gains this one with them. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 81747cf2fcf..70373f73952 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -516,9 +516,10 @@ a rotating refresh token is never spent twice. - **`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 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): + 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: ```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 b29f70198ac..bb981765273 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -659,7 +659,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -677,7 +677,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['field_default', 'member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -709,7 +709,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -727,7 +727,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['field_default', 'member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], 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 4181382fd56..d4d76f1c97a 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 @@ -257,7 +257,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -275,7 +275,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -296,7 +296,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -314,7 +314,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], 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 dccb0a2c301..87fe02aafc7 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 @@ -369,7 +369,7 @@ const SINGLE: unknown = { permissions: ['member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], tabPermissions: null, }, reconcile: { action: 'noop' }, @@ -462,7 +462,7 @@ const WALLED: unknown = { permissions: ['member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], tabPermissions: null, }, reconcile: { action: 'noop' }, 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 7321569bbc1..2e968f73b09 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 @@ -392,7 +392,8 @@ describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => { * platform and organization administrators hold the mailbox administrator * 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). + * so do both organization-administrator sets since ruling B, record + * 6545223261, 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 @@ -448,11 +449,28 @@ 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', () => { + it('[ADR-0143 D5] the connector connection capability ships with the platform administrator and both organization-administrator sets, and no other', () => { + // Ruling B (record 6545223261): the organization administrator connects + // their organization's OAuth2 vendors from day one. Every other shipped + // set, the member and read-only baselines included, holds it only by an + // 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']); + expect(holders).toEqual(['admin_full_access', 'organization_admin', 'organization_admin_no_bypass']); + for (const withheld of ['member_default', 'viewer_readonly']) { + expect(setByName(withheld)).toBeDefined(); + expect(setByName(withheld).systemPermissions ?? []).not.toContain('manage_connector_connections'); + } + }); + + it('[ADR-0143 D5] the wall-less organization administrator inherits the capability through the derivation, not a second literal', () => { + // `deriveWallLessOrgAdmin` copies the parent's system permissions + // verbatim; the two lists are one list, so the grant reaches both + // variants from the parent's declaration alone. + expect(setByName('organization_admin_no_bypass').systemPermissions).toEqual( + setByName('organization_admin').systemPermissions, + ); }); it('the imported spec constant is the declaration content — no local fork', () => { 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 995642fa076..7da57c71d05 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 @@ -371,6 +371,7 @@ const SINGLE: unknown = { ], posture: 'TENANT_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', @@ -561,6 +562,7 @@ const WALLED: unknown = { ], posture: 'TENANT_ADMIN', systemPermissions: [ + 'manage_connector_connections', 'manage_mailboxes', 'manage_org_users', 'setup.access', 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 0de191e28a6..a8324bfa742 100644 --- a/packages/runtime/src/domains/automation-connector-connection-door.test.ts +++ b/packages/runtime/src/domains/automation-connector-connection-door.test.ts @@ -55,10 +55,10 @@ function boot(opts: { posture?: 'single' | 'group' | 'isolated'; service?: Recor return { dispatcher: new HttpDispatcher(kernel as never), get, begin, complete, revoke }; } -/** A caller holding exactly `systemPermissions`, in organization `org_a`. */ -const HOLDING = (systemPermissions: string[], userId = 'u_admin'): HttpProtocolContext => ({ +/** A caller holding exactly `systemPermissions`, in organization `tenantId` (default `org_a`). */ +const HOLDING = (systemPermissions: string[], userId = 'u_admin', tenantId = 'org_a'): HttpProtocolContext => ({ request: {}, - executionContext: { userId, tenantId: 'org_a', positions: [], permissions: [], systemPermissions }, + executionContext: { userId, tenantId, positions: [], permissions: [], systemPermissions }, } as HttpProtocolContext); /** The connection administrator: the capability, and nothing else. */ @@ -68,11 +68,22 @@ 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. + * plugin-security's default sets, which `organization_admin_no_bypass` + * inherits), transcribed: since ruling B (record 6545223261) it carries the + * connection capability. The source is pinned in plugin-security's + * `default-permission-sets.test.ts`; this transcription pins what the door does + * with it. */ -const ORG_ADMIN_GRANT = ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes']; +const ORG_ADMIN_GRANT = ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes', 'manage_connector_connections']; + +/** + * The same organization administrator, active in ANOTHER organization where they + * are a plain member. Their `organization_admin` grant is organization-scoped, + * and core's resolver applies such a grant only in the organization it names + * (`grantAppliesInTenant`), so what reaches the door there is the member + * baseline: no system permission at all. + */ +const ORG_ADMIN_IN_ANOTHER_ORGANIZATION = (): HttpProtocolContext => HOLDING([], 'u_orgadmin', 'org_b'); const ANONYMOUS = (): HttpProtocolContext => ({ request: {}, executionContext: {} } as HttpProtocolContext); @@ -131,18 +142,23 @@ describe('ADR-0143 D5 — connector connection door: who may reach it', () => { } }); - it.each(EVERY_MEMBER)('%s: an organization administrator on the shipped grant is refused; granted the capability, admitted', async (_verb, call, member) => { + it.each(EVERY_MEMBER)('%s: an organization administrator on the shipped grant is admitted for their own organization, and refused in another', async (_verb, call, member) => { + const admitted = boot(); + const allowed = await call(admitted, HOLDING(ORG_ADMIN_GRANT, 'u_orgadmin')); + expect(statusOf(allowed.response)).toBe(200); + expect(member(admitted)).toHaveBeenCalledTimes(1); + // The door acts on the caller's own organization: the service is handed + // `org_a` and nothing that could name another. + expect(member(admitted)).toHaveBeenCalledWith( + expect.objectContaining({ context: expect.objectContaining({ userId: 'u_orgadmin', tenantId: 'org_a' }) }), + ); + const refused = boot(); - const denied = await call(refused, HOLDING(ORG_ADMIN_GRANT)); + const denied = await call(refused, ORG_ADMIN_IN_ANOTHER_ORGANIZATION()); expect(statusOf(denied.response)).toBe(403); expect(codeOf(denied.response)).toBe('PERMISSION_DENIED'); + expect(messageOf(denied.response)).toContain('`manage_connector_connections`'); 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 () => { From 29c358576aa95cc4463f0cf1b616609dd81a113f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:37:41 +0000 Subject: [PATCH 3/4] test(runtime): the organization admin's full grant in the packages commit-trail pin carries manage_connector_connections Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxkKcPULsq3fo4JFAX --- packages/runtime/src/domains/packages-capability-gate.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/packages-capability-gate.test.ts b/packages/runtime/src/domains/packages-capability-gate.test.ts index 09a7af8afeb..7271cbb874c 100644 --- a/packages/runtime/src/domains/packages-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-capability-gate.test.ts @@ -341,7 +341,7 @@ describe('[#23546] /packages/:id/commits — the commit trail demands studio.acc for (const [label, caps] of [ ['zero-capability', []], ['setup.access-only (the organization admin\'s capability)', ['setup.access']], - ['the organization admin\'s full grant (setup.access, setup.write, manage_org_users, manage_mailboxes)', ['setup.access', 'setup.write', 'manage_org_users', 'manage_mailboxes']], + ['the organization admin\'s full grant (setup.access, setup.write, manage_org_users, manage_mailboxes, manage_connector_connections)', ['setup.access', 'setup.write', 'manage_org_users', 'manage_mailboxes', 'manage_connector_connections']], ['WRITE-only (manage_metadata), which this door never admitted', ['manage_metadata']], ['setup.access + manage_metadata', ['setup.access', 'manage_metadata']], ] as const) { From 94355f4ffc090cdcc439643aef7c7b21a4197fb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:16:25 +0000 Subject: [PATCH 4/4] docs(adr-0143): D5 records the connection capability's default holders (ruling B) Platform administrators and both organization-administrator sets hold manage_connector_connections by default; every other position only by explicit grant. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxkKcPULsq3fo4JFAX --- docs/adr/0143-outbound-connector-auth-is-open-tier.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0143-outbound-connector-auth-is-open-tier.md b/docs/adr/0143-outbound-connector-auth-is-open-tier.md index 615208b908e..02f307b6269 100644 --- a/docs/adr/0143-outbound-connector-auth-is-open-tier.md +++ b/docs/adr/0143-outbound-connector-auth-is-open-tier.md @@ -226,6 +226,10 @@ materialization. rule 5). - Who may connect, reconnect or revoke is a capability check the server slice declares. Whose grant a run then uses is the [first decision](#q1--connection-ownership-and-execution-identity). +- That capability is the organization-scoped `manage_connector_connections`. Its default holders are platform + administrators and both organization-administrator sets (`organization_admin`, and + `organization_admin_no_bypass` through its derivation); every other position holds it only by explicit grant + (ruling B, record 6545223261). ### D6 — An integration grant is never a sign-in method