@owlmeans/server-iam 0.1.18-rc.7 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +13 -4
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-iam/SKILL.md +142 -16
  4. package/build/append.d.ts +1 -5
  5. package/build/append.d.ts.map +1 -1
  6. package/build/append.js.map +1 -1
  7. package/build/consts.local.d.ts +3 -0
  8. package/build/consts.local.d.ts.map +1 -0
  9. package/build/consts.local.js +3 -0
  10. package/build/consts.local.js.map +1 -0
  11. package/build/gate.d.ts +13 -14
  12. package/build/gate.d.ts.map +1 -1
  13. package/build/gate.js +128 -35
  14. package/build/gate.js.map +1 -1
  15. package/build/index.d.ts +6 -0
  16. package/build/index.d.ts.map +1 -1
  17. package/build/index.js +3 -0
  18. package/build/index.js.map +1 -1
  19. package/build/organization/types.d.ts +21 -0
  20. package/build/organization/types.d.ts.map +1 -0
  21. package/build/organization/types.js +2 -0
  22. package/build/organization/types.js.map +1 -0
  23. package/build/organization.d.ts +9 -0
  24. package/build/organization.d.ts.map +1 -0
  25. package/build/organization.js +30 -0
  26. package/build/organization.js.map +1 -0
  27. package/build/runtime.d.ts +15 -0
  28. package/build/runtime.d.ts.map +1 -0
  29. package/build/runtime.js +167 -0
  30. package/build/runtime.js.map +1 -0
  31. package/build/types.d.ts +42 -0
  32. package/build/types.d.ts.map +1 -0
  33. package/build/types.js +2 -0
  34. package/build/types.js.map +1 -0
  35. package/build/types.local.d.ts +16 -0
  36. package/build/types.local.d.ts.map +1 -0
  37. package/build/types.local.js +2 -0
  38. package/build/types.local.js.map +1 -0
  39. package/package.json +12 -10
  40. package/src/append.ts +1 -4
  41. package/src/consts.local.ts +2 -0
  42. package/src/gate.ts +157 -45
  43. package/src/index.ts +6 -0
  44. package/src/organization/types.ts +21 -0
  45. package/src/organization.ts +49 -0
  46. package/src/runtime.ts +193 -0
  47. package/src/types.local.ts +18 -0
  48. package/src/types.ts +43 -0
  49. package/tests/context.ts +100 -0
  50. package/tests/gate.spec.ts +132 -4
  51. package/tests/organization.spec.ts +52 -0
  52. package/tests/runtime.spec.ts +169 -0
  53. package/tests/tsconfig.json +12 -0
@@ -0,0 +1,100 @@
1
+ import { AppType, createService, makeBasicContext } from '@owlmeans/context'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+ import { createStaticResource } from '@owlmeans/static-resource'
4
+ import type { Resource } from '@owlmeans/resource'
5
+ import { AUTH_CACHE } from '@owlmeans/server-auth'
6
+ import { AuthRole } from '@owlmeans/auth'
7
+ import type { Auth } from '@owlmeans/auth'
8
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
9
+ import type { OidcOrganizationClaim, OidcPermissionSetClaim, OidcProviderConfig } from '@owlmeans/oidc'
10
+ import { DEFAULT_ALIAS, OIDC_TOKEN_STORE } from '@owlmeans/server-oidc-rp'
11
+ import type {
12
+ Config, Context, OIDCAuthCache, OidcClientAdapter, OidcClientService, OidcServerMetadata,
13
+ } from '@owlmeans/server-oidc-rp'
14
+
15
+ export const CLIENT = 'acme-preview'
16
+ export const ISSUER = 'https://iam.example.test/oidc'
17
+ export const ACCESS_TOKEN = 'provider-access-token'
18
+
19
+ export const ACME: OidcOrganizationClaim = { entitySlug: 'acme', entityKey: 'acme-key', owner: true, home: true, groups: ['admins'] }
20
+ export const BETA: OidcOrganizationClaim = { entitySlug: 'beta', entityKey: 'beta-key', owner: false, title: 'Beta' }
21
+ export const SETS: OidcPermissionSetClaim[] = [{ scope: CLIENT, permissions: { 'order--edit': true }, entitySlug: 'acme' }]
22
+
23
+ export interface Provider {
24
+ /** What discovery answers; mutable so a spec can take the runtime field away. */
25
+ metadata: Record<string, unknown>
26
+ getClientCalls: number
27
+ }
28
+
29
+ /** The relying party's view of the provider: discovery metadata, nothing else is reached. */
30
+ const makeProviderClientService = (provider: Provider, config: OidcProviderConfig): OidcClientService => {
31
+ const adapter: OidcClientAdapter = {
32
+ getMetadata: () => ({ issuer: ISSUER, ...provider.metadata }) as OidcServerMetadata,
33
+ getClientId: () => CLIENT,
34
+ getConfig: () => config,
35
+ makeAuthUrl: () => { throw new Error('no authorization in this spec') },
36
+ grantWithCredentials: async () => { throw new Error('no grant in this spec') },
37
+ grantWithCode: async () => { throw new Error('no grant in this spec') },
38
+ refresh: async () => { throw new Error('no refresh in this spec') },
39
+ introspect: async () => { throw new Error('no introspection in this spec') },
40
+ userinfo: async () => { throw new Error('no userinfo in this spec') },
41
+ }
42
+
43
+ return createService<OidcClientService>(DEFAULT_ALIAS, {
44
+ getConfiguration: async () => { throw new Error('no discovery in this spec') },
45
+ getClient: async () => {
46
+ provider.getClientCalls++
47
+ return adapter
48
+ },
49
+ getConfig: async () => config,
50
+ getDefault: () => CLIENT,
51
+ registerTemporaryProvider: cfg => cfg,
52
+ unregisterTemporaryProvider: () => undefined,
53
+ hasProvider: () => true,
54
+ entityToClientId: () => CLIENT,
55
+ providerApi: () => null,
56
+ accountLinking: () => null,
57
+ findProvider: () => config,
58
+ })
59
+ }
60
+
61
+ export const start = async (metadata: Record<string, unknown> = {}) => {
62
+ const config: OidcProviderConfig = { clientId: CLIENT, secret: 'client-secret', discoveryUrl: ISSUER }
63
+ const provider: Provider = { metadata, getClientCalls: 0 }
64
+
65
+ const context = makeBasicContext({
66
+ ready: false, service: 'server-iam-session-tests', type: AppType.Backend, oidc: { providers: [config] },
67
+ } as unknown as Config) as unknown as BasicContext<Config>
68
+ context.registerResource(createStaticResource(AUTH_CACHE, 'server-iam-session-cache'))
69
+ context.registerService(makeProviderClientService(provider, config))
70
+ context.configure()
71
+ await context.init()
72
+
73
+ return { context: context as unknown as Context, provider }
74
+ }
75
+
76
+ /** Seeds the `:token:` record a signed-in session of a tenanted client has, as the relying party writes it. */
77
+ export const seedSession = async (context: Context, token: string, record: Partial<OIDCAuthCache> = {}) => {
78
+ await context.resource<Resource<OIDCAuthCache>>(AUTH_CACHE).save({
79
+ id: `${OIDC_TOKEN_STORE}:token:${token}`,
80
+ client: CLIENT,
81
+ payload: { access_token: ACCESS_TOKEN, token_type: 'Bearer' },
82
+ acting: ACME.entityKey,
83
+ entity: { id: ACME.entityKey, slug: ACME.entitySlug, iamKey: ACME.entityKey },
84
+ organizations: [ACME, BETA],
85
+ sets: SETS,
86
+ ...record,
87
+ } as OIDCAuthCache)
88
+ }
89
+
90
+ export const requestOf = (token?: string): AbstractRequest => ({
91
+ alias: 'test', path: '/', params: {}, query: {}, headers: {},
92
+ ...(token != null
93
+ ? {
94
+ auth: {
95
+ type: 'oidc-wrapped-token', token, userId: 'acme-preview:subject', role: AuthRole.User,
96
+ scopes: [], entitySlug: ACME.entitySlug, isUser: true, createdAt: new Date(),
97
+ } satisfies Auth,
98
+ }
99
+ : {}),
100
+ }) as unknown as AbstractRequest
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { AppType, Layer, makeBasicContext } from '@owlmeans/context'
2
+ import { AppType, makeBasicContext } from '@owlmeans/context'
3
3
  import type { BasicConfig } from '@owlmeans/context'
4
4
  import type { AbstractRequest, AbstractResponse, GateService } from '@owlmeans/entrypoint'
5
5
  import type { Auth } from '@owlmeans/auth'
@@ -11,7 +11,6 @@ const makeGate = async (): Promise<GateService> => {
11
11
  const ctx = makeBasicContext<BasicConfig>({
12
12
  ready: false,
13
13
  service: 'server-iam-tests',
14
- layer: Layer.Service,
15
14
  type: AppType.Backend,
16
15
  })
17
16
  ctx.registerService(makeIamGate())
@@ -21,12 +20,16 @@ const makeGate = async (): Promise<GateService> => {
21
20
  return ctx.service<GateService>(OIDC_GATE)
22
21
  }
23
22
 
24
- const makeRequest = (params: Record<string, string> = {}): AbstractRequest => ({
23
+ const makeRequest = (
24
+ params: Record<string, string> = {},
25
+ rest: Partial<Pick<AbstractRequest, 'query' | 'body' | 'headers'>> = {}
26
+ ): AbstractRequest => ({
25
27
  alias: 'test',
26
28
  headers: {},
27
29
  params,
28
30
  query: {},
29
31
  body: {},
32
+ ...rest,
30
33
  path: '/',
31
34
  auth: {
32
35
  type: 'oidc-wrapped-token',
@@ -34,7 +37,7 @@ const makeRequest = (params: Record<string, string> = {}): AbstractRequest => ({
34
37
  userId: 'user-1',
35
38
  role: AuthRole.User,
36
39
  scopes: [],
37
- entityId: 'entity-1',
40
+ entitySlug: 'entity-1',
38
41
  isUser: true,
39
42
  createdAt: new Date(),
40
43
  permissions: [
@@ -72,4 +75,129 @@ describe('@owlmeans/server-iam — makeIamGate (claims mode)', () => {
72
75
  gate.assert(makeRequest({ depId: 'dep-87654321' }), res, ['department--modify@depId'])
73
76
  ).rejects.toBeInstanceOf(AuthForbidden)
74
77
  })
78
+
79
+ test('resolves a resource id from an explicitly named source', async () => {
80
+ const gate = await makeGate()
81
+ await gate.assert(
82
+ makeRequest({}, { body: { department: { id: 'dep-12345678' } } }), res,
83
+ ['department--modify@body:department.id']
84
+ )
85
+ })
86
+
87
+ test('resolves a resource id from the auth object', async () => {
88
+ const gate = await makeGate()
89
+ // `entity-1` is this subject's own organization slug, and the unscoped grant covers every resource.
90
+ await gate.assert(makeRequest(), res, ['article--modify@auth:entitySlug'])
91
+ })
92
+
93
+ /**
94
+ * Params are OR'd, so a param the gate cannot even evaluate must contribute nothing. Letting one
95
+ * throw would turn a typo in an unrelated sibling into a denial of a grant the subject really has.
96
+ */
97
+ test('a malformed param does not refuse a sibling that passes', async () => {
98
+ const gate = await makeGate()
99
+ await gate.assert(makeRequest(), res, ['article--modify@cookies:sid', 'article--modify'])
100
+ })
101
+
102
+ test('an unresolvable selector refuses without breaking the OR', async () => {
103
+ const gate = await makeGate()
104
+ await gate.assert(makeRequest(), res, ['department--modify@missingParam', 'article--modify'])
105
+
106
+ expect(
107
+ gate.assert(makeRequest(), res, ['department--modify@missingParam'])
108
+ ).rejects.toBeInstanceOf(AuthForbidden)
109
+ })
110
+
111
+ /**
112
+ * The stored grant is keyed `department--modify`; the gate splits the selector off before looking
113
+ * it up. A grant written under the whole string — the corruption this grammar exists to stop —
114
+ * is a key nothing reads.
115
+ */
116
+ test('a permission granted WITH the selector in its name never satisfies the gate', async () => {
117
+ const gate = await makeGate()
118
+ const req = makeRequest({ depId: 'dep-1' })
119
+ ;(req.auth as unknown as Auth).permissions = [
120
+ { scope: 'my-project', permissions: { 'department--modify@depId': true } }
121
+ ]
122
+
123
+ expect(
124
+ gate.assert(req, res, ['department--modify@depId'])
125
+ ).rejects.toBeInstanceOf(AuthForbidden)
126
+ })
127
+ })
128
+
129
+ /**
130
+ * The four permission kinds, as a relying party hands them to the gate. Normally the relying party
131
+ * strips the binding of the acting organization's sets and drops every other bound set; a bound set
132
+ * in a token is therefore a leak, and the gate applies it only in its own organization.
133
+ */
134
+ describe('@owlmeans/server-iam — makeIamGate and the acting organization', () => {
135
+ const CLIENT = 'acme-preview'
136
+
137
+ const kindsRequest = (opts: {
138
+ entitySlug?: string
139
+ entity?: { id: string, slug: string }
140
+ params?: Record<string, string>
141
+ }): AbstractRequest => ({
142
+ alias: 'kinds',
143
+ headers: {},
144
+ params: opts.params ?? {},
145
+ query: {},
146
+ body: {},
147
+ path: '/',
148
+ ...(opts.entity != null ? { entity: { ...opts.entity, iamKey: opts.entity.id } } : {}),
149
+ auth: {
150
+ type: 'oidc-wrapped-token',
151
+ token: 'test',
152
+ userId: 'user-1',
153
+ role: AuthRole.User,
154
+ scopes: [],
155
+ ...(opts.entitySlug != null ? { entitySlug: opts.entitySlug } : {}),
156
+ isUser: true,
157
+ createdAt: new Date(),
158
+ permissions: [
159
+ // unbound
160
+ { scope: CLIENT, permissions: { 'report--view': true } },
161
+ // entity-bound
162
+ { scope: CLIENT, permissions: { 'order--edit': true }, entitySlug: 'acme' },
163
+ // resource-bound
164
+ { scope: CLIENT, title: 'department--modify', permissions: { 'department--modify': true }, resources: ['dep-1'] },
165
+ // both
166
+ {
167
+ scope: CLIENT, title: 'order--delete', permissions: { 'order--delete': true },
168
+ resources: ['ord-1'], entitySlug: 'acme',
169
+ },
170
+ // the per-organization marker, granted against the organization's stable id
171
+ { scope: CLIENT, permissions: { 'tenant-0123456789abcdef01234567--admin': true } },
172
+ ],
173
+ } as Auth,
174
+ }) as unknown as AbstractRequest
175
+
176
+ test.each([
177
+ ['unbound: applies in any organization', 'report--view', { entitySlug: 'beta' }, true],
178
+ ['unbound: applies with no organization at all', 'report--view', {}, true],
179
+ ['entity-bound: applies in its organization', 'order--edit', { entitySlug: 'acme' }, true],
180
+ ['entity-bound: refused in another organization', 'order--edit', { entitySlug: 'beta' }, false],
181
+ ['entity-bound: refused with no organization (fail-closed)', 'order--edit', {}, false],
182
+ ['resource-bound: the granted record', 'department--modify@depId', { entitySlug: 'beta', params: { depId: 'dep-1' } }, true],
183
+ ['resource-bound: another record', 'department--modify@depId', { entitySlug: 'acme', params: { depId: 'dep-2' } }, false],
184
+ ['both: its organization and its record', 'order--delete@id', { entitySlug: 'acme', params: { id: 'ord-1' } }, true],
185
+ ['both: its organization, another record', 'order--delete@id', { entitySlug: 'acme', params: { id: 'ord-2' } }, false],
186
+ ['both: its record, another organization', 'order--delete@id', { entitySlug: 'beta', params: { id: 'ord-1' } }, false],
187
+ // The guard-attached entity is what the request acts in; the token's slug is the fallback.
188
+ ['the attached entity wins over the token slug', 'order--edit',
189
+ { entitySlug: 'beta', entity: { id: 'acme-key', slug: 'acme' } }, true],
190
+ ['the attached entity refuses another organization', 'order--edit',
191
+ { entitySlug: 'acme', entity: { id: 'beta-key', slug: 'beta' } }, false],
192
+ ['{entity} is still the attached entity id', 'tenant-{entity}--admin',
193
+ { entity: { id: '0123456789abcdef01234567', slug: 'acme' } }, true],
194
+ ] as const)('%s', async (_, param, opts, granted) => {
195
+ const gate = await makeGate()
196
+ const attempt = gate.assert(kindsRequest(opts), res, [param])
197
+ if (granted) {
198
+ await attempt
199
+ } else {
200
+ await expect(attempt).rejects.toBeInstanceOf(AuthForbidden)
201
+ }
202
+ })
75
203
  })
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
3
+ import { ORGANIZATION_REFUSAL } from '@owlmeans/oidc'
4
+ import { makeOrganizationScope } from '@owlmeans/server-iam'
5
+ import { ACME, BETA, requestOf, seedSession, start } from './context.js'
6
+
7
+ describe('@owlmeans/server-iam — organizations of the session', () => {
8
+ test('organizationsOf answers every claimed organization as a request entity keyed by its IAM key', async () => {
9
+ const { context } = await start()
10
+ await seedSession(context, 'session-1')
11
+
12
+ expect(await makeOrganizationScope(context, requestOf('session-1')).organizationsOf()).toEqual([
13
+ { id: ACME.entityKey, slug: ACME.entitySlug, iamKey: ACME.entityKey },
14
+ { id: BETA.entityKey, slug: BETA.entitySlug, iamKey: BETA.entityKey },
15
+ ])
16
+ })
17
+
18
+ test('organizationOf resolves one by slug — not only the acting one', async () => {
19
+ const { context } = await start()
20
+ await seedSession(context, 'session-2')
21
+
22
+ expect(await makeOrganizationScope(context, requestOf('session-2')).organizationOf('beta'))
23
+ .toEqual({ id: 'beta-key', slug: 'beta', iamKey: 'beta-key' })
24
+ })
25
+
26
+ test('an organization the subject is not in is refused', async () => {
27
+ const { context } = await start()
28
+ await seedSession(context, 'session-3')
29
+
30
+ const attempt = makeOrganizationScope(context, requestOf('session-3')).organizationOf('gamma')
31
+ await expect(attempt).rejects.toBeInstanceOf(AuthForbidden)
32
+ await expect(attempt).rejects.toThrow(ORGANIZATION_REFUSAL)
33
+ })
34
+
35
+ /** A client without the organizations scope keeps no acting organization: there is nothing to resolve. */
36
+ test('a session that acts in no organization has none', async () => {
37
+ const { context } = await start()
38
+ await seedSession(context, 'session-4', { acting: undefined, entity: undefined })
39
+
40
+ expect(await makeOrganizationScope(context, requestOf('session-4')).organizationsOf()).toEqual([])
41
+ await expect(makeOrganizationScope(context, requestOf('session-4')).organizationOf('acme')).rejects.toBeInstanceOf(AuthForbidden)
42
+ })
43
+
44
+ test('a request without authentication, or whose session is gone, is a 401', async () => {
45
+ const { context } = await start()
46
+
47
+ await expect(makeOrganizationScope(context, requestOf()).organizationsOf()).rejects.toBeInstanceOf(AuthorizationError)
48
+ const gone = makeOrganizationScope(context, requestOf('never-signed-in')).organizationOf('acme')
49
+ await expect(gone).rejects.toBeInstanceOf(AuthorizationError)
50
+ await expect(gone).rejects.not.toBeInstanceOf(AuthForbidden)
51
+ })
52
+ })
@@ -0,0 +1,169 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import type { Server } from 'bun'
3
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
4
+ import { ResilientError } from '@owlmeans/error'
5
+ import { IAM_API_METADATA, ORGANIZATION_OWNER_REFUSAL, ORGANIZATION_REFUSAL } from '@owlmeans/oidc'
6
+ import { IamClientError, IamError, IamGrantMode, IamGrantOrigin } from '@owlmeans/iam'
7
+ import { makeIamRuntimeClient } from '@owlmeans/server-iam'
8
+ import type { IamRuntimeClient } from '@owlmeans/server-iam'
9
+ import { ACCESS_TOKEN, requestOf, seedSession, start } from './context.js'
10
+
11
+ /**
12
+ * The runtime client against a stand-in for the provider's runtime API, served on an OS-assigned
13
+ * port. The stand-in records what reached it and answers whatever `answer` says for the path.
14
+ */
15
+ interface Seen {
16
+ method: string
17
+ path: string
18
+ query: Record<string, string>
19
+ authorization: string | null
20
+ body?: unknown
21
+ }
22
+
23
+ const seen: Seen[] = []
24
+ let answer: (seen: Seen) => Response = () => Response.json({ items: [] })
25
+ let server: Server<undefined>
26
+ let base: string
27
+
28
+ beforeAll(() => {
29
+ server = Bun.serve({
30
+ port: 0,
31
+ hostname: '127.0.0.1',
32
+ fetch: async request => {
33
+ const url = new URL(request.url)
34
+ const text = request.method === 'POST' ? await request.text() : ''
35
+ const entry: Seen = {
36
+ method: request.method,
37
+ path: url.pathname,
38
+ query: Object.fromEntries(url.searchParams),
39
+ authorization: request.headers.get('authorization'),
40
+ ...(text !== '' ? { body: JSON.parse(text) } : {}),
41
+ }
42
+ seen.push(entry)
43
+ return answer(entry)
44
+ },
45
+ })
46
+ base = `http://127.0.0.1:${server.port}/iam/api/runtime`
47
+ })
48
+
49
+ afterAll(() => server.stop(true))
50
+
51
+ const lastSeen = (): Seen => seen[seen.length - 1]
52
+
53
+ const runtimeOf = async (token: string, metadata?: Record<string, unknown>) => {
54
+ const env = await start(metadata ?? { [IAM_API_METADATA]: `${base}/` })
55
+ await seedSession(env.context, token)
56
+
57
+ return { ...env, client: makeIamRuntimeClient(env.context, requestOf(token)) }
58
+ }
59
+
60
+ describe('@owlmeans/server-iam — iamRuntime', () => {
61
+ test('calls the base the provider advertises, as the session subject', async () => {
62
+ const { client } = await runtimeOf('runtime-1')
63
+ answer = () => Response.json({ items: [{ entitySlug: 'acme', owner: true, home: true }] })
64
+
65
+ expect(await client.organizations.list()).toEqual([{ entitySlug: 'acme', owner: true, home: true }])
66
+ expect(lastSeen()).toEqual({
67
+ method: 'GET', path: '/iam/api/runtime/organizations', query: {}, authorization: `Bearer ${ACCESS_TOKEN}`,
68
+ })
69
+ })
70
+
71
+ test('discovery is read once per client', async () => {
72
+ const { client, provider } = await runtimeOf('runtime-2')
73
+ answer = () => Response.json({ items: [] })
74
+
75
+ await client.organizations.list()
76
+ await client.members.list('acme')
77
+ await client.permissions.list('acme')
78
+ expect(provider.getClientCalls).toBe(1)
79
+ })
80
+
81
+ test.each([
82
+ ['create', (c: Client) => c.organizations.create({ title: 'Gamma' }), 'POST', '/organizations', { title: 'Gamma' }],
83
+ ['update', (c: Client) => c.organizations.update('acme', { title: 'Acme' }), 'POST', '/organizations/acme', { title: 'Acme' }],
84
+ ['add', (c: Client) => c.members.add('acme', { email: 'a@example.test', owner: false }),
85
+ 'POST', '/organizations/acme/members', { email: 'a@example.test', owner: false }],
86
+ ['member update', (c: Client) => c.members.update('acme', 'acme-preview:sub', { groups: ['staff'] }),
87
+ 'POST', '/organizations/acme/members/acme-preview%3Asub', { groups: ['staff'] }],
88
+ ['remove', (c: Client) => c.members.remove('acme', 'acme-preview:sub'),
89
+ 'POST', '/organizations/acme/members/acme-preview%3Asub/remove', {}],
90
+ ['assign', (c: Client) => c.grants.assign('acme', { profileId: 'acme-preview:sub', permission: 'order--edit' }),
91
+ 'POST', '/organizations/acme/grants', { profileId: 'acme-preview:sub', permission: 'order--edit' }],
92
+ ['revoke', (c: Client) => c.grants.revoke('acme', {
93
+ profileId: 'acme-preview:sub', permission: 'order--edit', mode: IamGrantMode.All,
94
+ }), 'POST', '/organizations/acme/grants/revoke', { profileId: 'acme-preview:sub', permission: 'order--edit', mode: 'all' }],
95
+ ] as const)('%s: method, path and body', async (_, run, method, path, body) => {
96
+ const { client } = await runtimeOf(`runtime-call-${path}`)
97
+ answer = () => Response.json({ ok: true, entitySlug: 'acme', owner: true, profileId: 'acme-preview:sub', groups: [] })
98
+
99
+ await run(client)
100
+ expect(lastSeen()).toMatchObject({ method, path: `/iam/api/runtime${path}`, body, authorization: `Bearer ${ACCESS_TOKEN}` })
101
+ })
102
+
103
+ test('a grant listing sends its filter as the query and answers the items', async () => {
104
+ const { client } = await runtimeOf('runtime-3')
105
+ const grant = { profileId: 'acme-preview:sub', permission: 'order--edit', origin: IamGrantOrigin.Group, through: 'staff' }
106
+ answer = () => Response.json({ items: [grant] })
107
+
108
+ expect(await client.grants.list('acme', { profileId: 'acme-preview:sub' })).toEqual([grant])
109
+ expect(lastSeen()).toMatchObject({ method: 'GET', path: '/iam/api/runtime/organizations/acme/grants', query: { profileId: 'acme-preview:sub' } })
110
+ })
111
+
112
+ test('a bare 403 on an owner route is the owner refusal, on a member route the organization refusal', async () => {
113
+ const { client } = await runtimeOf('runtime-4')
114
+ answer = () => new Response('incident-1', { status: 403, headers: { 'X-Incident-ID': 'incident-1' } })
115
+
116
+ const write = client.members.add('acme', { email: 'a@example.test' })
117
+ await expect(write).rejects.toBeInstanceOf(AuthForbidden)
118
+ await expect(write).rejects.toThrow(ORGANIZATION_OWNER_REFUSAL)
119
+ await expect(write.catch(error => error.incidentId)).resolves.toBe('incident-1')
120
+
121
+ const read = client.members.list('beta')
122
+ await expect(read).rejects.toBeInstanceOf(AuthForbidden)
123
+ await expect(read.catch(error => (error as Error).message.includes(ORGANIZATION_OWNER_REFUSAL))).resolves.toBe(false)
124
+ await expect(read).rejects.toThrow(ORGANIZATION_REFUSAL)
125
+ })
126
+
127
+ test('a marshalled error is rebuilt into its own class', async () => {
128
+ const { client } = await runtimeOf('runtime-5')
129
+ const marshalled = ResilientError.marshal(new AuthForbidden(ORGANIZATION_OWNER_REFUSAL)).message
130
+ answer = () => new Response(marshalled, { status: 403 })
131
+
132
+ const attempt = client.grants.assign('acme', { profileId: 'acme-preview:sub', permission: 'order--edit' })
133
+ await expect(attempt).rejects.toBeInstanceOf(AuthForbidden)
134
+ await expect(attempt).rejects.toThrow(ORGANIZATION_OWNER_REFUSAL)
135
+ })
136
+
137
+ test('a 401 is a session to sign in again; any other failure an IAM error', async () => {
138
+ const { client } = await runtimeOf('runtime-6')
139
+
140
+ answer = () => new Response('incident-2', { status: 401 })
141
+ const unauthorized = client.organizations.list()
142
+ await expect(unauthorized).rejects.toBeInstanceOf(AuthorizationError)
143
+ await expect(unauthorized).rejects.not.toBeInstanceOf(AuthForbidden)
144
+
145
+ answer = () => new Response('incident-3', { status: 502 })
146
+ await expect(client.organizations.list()).rejects.toBeInstanceOf(IamError)
147
+ })
148
+
149
+ test('a provider that advertises no runtime API is a client error, and is asked again next time', async () => {
150
+ const { client, provider } = await runtimeOf('runtime-7', {})
151
+
152
+ await expect(client.organizations.list()).rejects.toBeInstanceOf(IamClientError)
153
+ provider.metadata[IAM_API_METADATA] = base
154
+ answer = () => Response.json({ items: [] })
155
+ expect(await client.organizations.list()).toEqual([])
156
+ expect(provider.getClientCalls).toBe(2)
157
+ })
158
+
159
+ test('a session without a provider token is refused before anything is sent', async () => {
160
+ const env = await start({ [IAM_API_METADATA]: base })
161
+ await seedSession(env.context, 'runtime-8', { payload: { token_type: 'Bearer' } })
162
+ const before = seen.length
163
+
164
+ await expect(makeIamRuntimeClient(env.context, requestOf('runtime-8')).organizations.list()).rejects.toBeInstanceOf(AuthForbidden)
165
+ expect(seen.length).toBe(before)
166
+ })
167
+ })
168
+
169
+ type Client = IamRuntimeClient
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }