|
4 | 4 | // /automation/connectors/:name/connection` and `GET /automation/connectors/ |
5 | 5 | // oauth/callback`. |
6 | 6 | // |
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 |
9 | 10 | // (no query parameter on the connection routes; the closed authorization- |
10 | 11 | // response set on the callback, each once), how the service's refusals reach |
11 | 12 | // the wire, and that a token never travels back out. The connect flow, the |
|
14 | 15 |
|
15 | 16 | import { describe, it, expect, vi } from 'vitest'; |
16 | 17 |
|
| 18 | +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; |
| 19 | + |
17 | 20 | import { HttpDispatcher } from '../http-dispatcher.js'; |
18 | 21 | 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'; |
20 | 23 |
|
21 | 24 | /** An authorization code as a callback carries it — a dummy. */ |
22 | 25 | const AUTH_CODE = 'dummy-authorization-code'; |
@@ -52,15 +55,24 @@ function boot(opts: { posture?: 'single' | 'group' | 'isolated'; service?: Recor |
52 | 55 | return { dispatcher: new HttpDispatcher(kernel as never), get, begin, complete, revoke }; |
53 | 56 | } |
54 | 57 |
|
55 | | -const ADMIN = (): HttpProtocolContext => ({ |
| 58 | +/** A caller holding exactly `systemPermissions`, in organization `org_a`. */ |
| 59 | +const HOLDING = (systemPermissions: string[], userId = 'u_admin'): HttpProtocolContext => ({ |
56 | 60 | request: {}, |
57 | | - executionContext: { userId: 'u_admin', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: ['manage_metadata'] }, |
| 61 | + executionContext: { userId, tenantId: 'org_a', positions: [], permissions: [], systemPermissions }, |
58 | 62 | } as HttpProtocolContext); |
59 | 63 |
|
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']; |
64 | 76 |
|
65 | 77 | const ANONYMOUS = (): HttpProtocolContext => ({ request: {}, executionContext: {} } as HttpProtocolContext); |
66 | 78 |
|
@@ -88,15 +100,61 @@ describe('ADR-0143 D5 — connector connection door: who may reach it', () => { |
88 | 100 | ['POST', (h: Harness) => connection(h, 'POST', MEMBER())], |
89 | 101 | ['DELETE', (h: Harness) => connection(h, 'DELETE', MEMBER())], |
90 | 102 | ['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) => { |
92 | 104 | const h = boot(); |
93 | 105 | const { response } = await call(h); |
94 | 106 | expect(statusOf(response)).toBe(403); |
95 | 107 | expect(codeOf(response)).toBe('PERMISSION_DENIED'); |
96 | | - expect(messageOf(response)).toContain('`manage_metadata`'); |
| 108 | + expect(messageOf(response)).toContain('`manage_connector_connections`'); |
97 | 109 | for (const member of [h.get, h.begin, h.complete, h.revoke]) expect(member).not.toHaveBeenCalled(); |
98 | 110 | }); |
99 | 111 |
|
| 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 | + |
100 | 158 | it('a walled posture does not refuse the holder: a connection belongs to its organization', async () => { |
101 | 159 | for (const posture of ['group', 'isolated'] as const) { |
102 | 160 | const h = boot({ posture }); |
|
0 commit comments