@owlmeans/server-iam 0.1.18-rc.9 → 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.
- package/README.md +13 -4
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/server-iam/SKILL.md +142 -16
- package/build/append.d.ts +1 -5
- package/build/append.d.ts.map +1 -1
- package/build/append.js.map +1 -1
- package/build/consts.local.d.ts +3 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +3 -0
- package/build/consts.local.js.map +1 -0
- package/build/gate.d.ts +13 -14
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +128 -35
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/build/organization/types.d.ts +21 -0
- package/build/organization/types.d.ts.map +1 -0
- package/build/organization/types.js +2 -0
- package/build/organization/types.js.map +1 -0
- package/build/organization.d.ts +9 -0
- package/build/organization.d.ts.map +1 -0
- package/build/organization.js +30 -0
- package/build/organization.js.map +1 -0
- package/build/runtime.d.ts +15 -0
- package/build/runtime.d.ts.map +1 -0
- package/build/runtime.js +167 -0
- package/build/runtime.js.map +1 -0
- package/build/types.d.ts +42 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/types.local.d.ts +16 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/package.json +12 -10
- package/src/append.ts +1 -4
- package/src/consts.local.ts +2 -0
- package/src/gate.ts +157 -45
- package/src/index.ts +6 -0
- package/src/organization/types.ts +21 -0
- package/src/organization.ts +49 -0
- package/src/runtime.ts +193 -0
- package/src/types.local.ts +18 -0
- package/src/types.ts +43 -0
- package/tests/context.ts +100 -0
- package/tests/gate.spec.ts +132 -4
- package/tests/organization.spec.ts +52 -0
- package/tests/runtime.spec.ts +169 -0
- package/tests/tsconfig.json +12 -0
package/tests/context.ts
ADDED
|
@@ -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
|
package/tests/gate.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { AppType,
|
|
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 = (
|
|
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
|
-
|
|
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
|