@owlmeans/iam 0.1.18-rc.8 → 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 +185 -15
- package/agent-meta/manifest.json +92 -4
- package/agent-meta/skills/iam/SKILL.md +91 -177
- package/agent-meta/skills/iam/parts/clients.md +93 -0
- package/agent-meta/skills/iam/parts/permissions.md +106 -0
- package/agent-meta/skills/iam/parts/service.md +115 -0
- package/agent-meta/skills/iam/parts/tenancy.md +72 -0
- package/build/consts.d.ts +170 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +221 -0
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +58 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +21 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +26 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +38 -0
- package/build/errors.js.map +1 -1
- package/build/facet.d.ts +10 -0
- package/build/facet.d.ts.map +1 -0
- package/build/facet.js +16 -0
- package/build/facet.js.map +1 -0
- package/build/gate/consts.local.d.ts +2 -0
- package/build/gate/consts.local.d.ts.map +1 -0
- package/build/gate/consts.local.js +3 -0
- package/build/gate/consts.local.js.map +1 -0
- package/build/gate/index.d.ts +4 -0
- package/build/gate/index.d.ts.map +1 -0
- package/build/gate/index.js +4 -0
- package/build/gate/index.js.map +1 -0
- package/build/gate/param.d.ts +6 -0
- package/build/gate/param.d.ts.map +1 -0
- package/build/gate/param.js +83 -0
- package/build/gate/param.js.map +1 -0
- package/build/gate/resolve.d.ts +10 -0
- package/build/gate/resolve.d.ts.map +1 -0
- package/build/gate/resolve.js +93 -0
- package/build/gate/resolve.js.map +1 -0
- package/build/gate/types.d.ts +109 -0
- package/build/gate/types.d.ts.map +1 -0
- package/build/gate/types.js +2 -0
- package/build/gate/types.js.map +1 -0
- package/build/gate/types.local.d.ts +5 -0
- package/build/gate/types.local.d.ts.map +1 -0
- package/build/gate/types.local.js +2 -0
- package/build/gate/types.local.js.map +1 -0
- package/build/gate/validate.d.ts +4 -0
- package/build/gate/validate.d.ts.map +1 -0
- package/build/gate/validate.js +100 -0
- package/build/gate/validate.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/build/permission/types.d.ts +35 -0
- package/build/permission/types.d.ts.map +1 -0
- package/build/permission/types.js +2 -0
- package/build/permission/types.js.map +1 -0
- package/build/permission.d.ts +8 -0
- package/build/permission.d.ts.map +1 -0
- package/build/permission.js +34 -0
- package/build/permission.js.map +1 -0
- package/build/runtime/types.d.ts +90 -0
- package/build/runtime/types.d.ts.map +1 -0
- package/build/runtime/types.js +2 -0
- package/build/runtime/types.js.map +1 -0
- package/build/runtime.d.ts +75 -0
- package/build/runtime.d.ts.map +1 -0
- package/build/runtime.js +75 -0
- package/build/runtime.js.map +1 -0
- package/build/schemas.d.ts +13 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +108 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +429 -30
- package/build/types.d.ts.map +1 -1
- package/build/utils/types.d.ts +12 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils.d.ts +7 -6
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +10 -1
- package/build/utils.js.map +1 -1
- package/package.json +12 -7
- package/src/consts.local.ts +26 -0
- package/src/consts.ts +241 -1
- package/src/errors.ts +44 -0
- package/src/facet.ts +17 -0
- package/src/gate/consts.local.ts +3 -0
- package/src/gate/index.ts +3 -0
- package/src/gate/param.ts +108 -0
- package/src/gate/resolve.ts +114 -0
- package/src/gate/types.local.ts +4 -0
- package/src/gate/types.ts +117 -0
- package/src/gate/validate.ts +123 -0
- package/src/index.ts +9 -0
- package/src/permission/types.ts +33 -0
- package/src/permission.ts +48 -0
- package/src/runtime/types.ts +107 -0
- package/src/runtime.ts +142 -0
- package/src/schemas.ts +122 -0
- package/src/types.ts +492 -31
- package/src/utils/types.ts +11 -0
- package/src/utils.ts +12 -7
- package/tests/gate-param.spec.ts +233 -0
- package/tests/runtime.spec.ts +101 -0
- package/tests/tsconfig.json +12 -0
- package/tests/utils.spec.ts +47 -1
package/src/utils.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import type { Authorization } from '@owlmeans/auth'
|
|
1
|
+
import type { Authorization, PermissionSet } from '@owlmeans/auth'
|
|
2
|
+
import type { OidcPermissionSetClaim } from '@owlmeans/oidc'
|
|
3
|
+
import type { HasPermissionOptions } from './utils/types.js'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
scope?: string
|
|
6
|
-
/** Resource-scoped check: the set must list this id — unless the set itself is unscoped. */
|
|
7
|
-
resourceId?: string
|
|
8
|
-
}
|
|
5
|
+
/** The organization a set is bound to, when it is a claim set that kept its binding. */
|
|
6
|
+
const boundTo = (set: PermissionSet): string | undefined => (set as OidcPermissionSetClaim).entitySlug
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* True when any PermissionSet grants `permission`. An unscoped set (no resources)
|
|
12
10
|
* satisfies a resourceId check too — a project-wide grant covers every resource.
|
|
11
|
+
*
|
|
12
|
+
* The organization follows the same rule the other way round, fail-closed: a set without a binding
|
|
13
|
+
* applies everywhere, a bound one only when `opts.entitySlug` names its organization. A relying
|
|
14
|
+
* party strips the binding from the sets of the organization a session acts in and drops every
|
|
15
|
+
* other bound set, so a bound set reaching a check is one that leaked — and it must not count
|
|
16
|
+
* merely because the caller did not say where the request acts.
|
|
13
17
|
*/
|
|
14
18
|
export const hasPermission = (
|
|
15
19
|
auth: Authorization,
|
|
@@ -19,4 +23,5 @@ export const hasPermission = (
|
|
|
19
23
|
(opts?.scope == null || set.scope === opts.scope)
|
|
20
24
|
&& set.permissions[permission] === true
|
|
21
25
|
&& (opts?.resourceId == null || set.resources == null || set.resources.includes(opts.resourceId))
|
|
26
|
+
&& (boundTo(set) == null || (opts?.entitySlug != null && boundTo(set) === opts.entitySlug))
|
|
22
27
|
) ?? false
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
gateParamHelper, gateValidationHelper, resolveGateResource, permissionNameHelper,
|
|
4
|
+
GateParamSource, GateParamErrorCode, GateResolutionFailure
|
|
5
|
+
} from '../src/index.js'
|
|
6
|
+
import type { GateResourceSelector } from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
const selectorOf = (param: string): GateResourceSelector => {
|
|
9
|
+
const resource = gateParamHelper.parseGateParam(param).resource
|
|
10
|
+
if (resource == null) {
|
|
11
|
+
throw new Error(`"${param}" produced no selector`)
|
|
12
|
+
}
|
|
13
|
+
return resource
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('gate params — the grammar', () => {
|
|
17
|
+
test('an unscoped param is the permission and nothing else', () => {
|
|
18
|
+
const parsed = gateParamHelper.parseGateParam('enquiry--modify')
|
|
19
|
+
expect(parsed.permission).toBe('enquiry--modify')
|
|
20
|
+
expect(parsed.resource).toBeUndefined()
|
|
21
|
+
expect(parsed.error).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('the bare form stays FLAT and keeps searching params then query', () => {
|
|
25
|
+
const parsed = gateParamHelper.parseGateParam('enquiry--view@enquiryId')
|
|
26
|
+
expect(parsed.permission).toBe('enquiry--view')
|
|
27
|
+
expect(parsed.resourceParam).toBe('enquiryId')
|
|
28
|
+
expect(parsed.resource?.path).toEqual(['enquiryId'])
|
|
29
|
+
expect(parsed.resource?.sources).toEqual([GateParamSource.Params, GateParamSource.Query])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A query key may legally contain a dot, so splitting the bare form on one would change the
|
|
34
|
+
* meaning of selectors already deployed inside customer projects.
|
|
35
|
+
*/
|
|
36
|
+
test('a dot in the bare form is part of the key, never a path', () => {
|
|
37
|
+
expect(selectorOf('a--view@filter.orgId').path).toEqual(['filter.orgId'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a qualified selector names its source and walks a path', () => {
|
|
41
|
+
const selector = selectorOf('order--modify@body:order.id')
|
|
42
|
+
expect(selector.source).toBe(GateParamSource.Body)
|
|
43
|
+
expect(selector.path).toEqual(['order', 'id'])
|
|
44
|
+
expect(selector.sources).toEqual([GateParamSource.Body])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('the qualified form does not populate the deprecated flat field', () => {
|
|
48
|
+
expect(gateParamHelper.parseGateParam('order--modify@body:order.id').resourceParam).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('an unknown source is reported rather than treated as a key', () => {
|
|
52
|
+
const parsed = gateParamHelper.parseGateParam('a--view@cookies:sid')
|
|
53
|
+
expect(parsed.error?.code).toBe(GateParamErrorCode.UnknownSource)
|
|
54
|
+
expect(parsed.resource).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test.each([
|
|
58
|
+
['a--view@', GateParamErrorCode.EmptySelector],
|
|
59
|
+
['a--view@body:', GateParamErrorCode.EmptySelector],
|
|
60
|
+
['a--view@body:order..id', GateParamErrorCode.EmptySegment],
|
|
61
|
+
])('%s is refused as %s', (param, code) => {
|
|
62
|
+
expect(gateParamHelper.parseGateParam(param).error?.code).toBe(code)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('format is the inverse of parse for both forms', () => {
|
|
66
|
+
for (const param of ['a--view', 'a--view@id', 'a--view@body:order.id', 'a--view@auth:profileId']) {
|
|
67
|
+
const parsed = gateParamHelper.parseGateParam(param)
|
|
68
|
+
expect(gateParamHelper.formatGateParam(parsed.permission, parsed.resource)).toBe(param)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('parseGateSelector reports a problem instead of throwing', () => {
|
|
73
|
+
const result = gateParamHelper.parseGateSelector('')
|
|
74
|
+
expect('code' in result).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('gate params — resolution', () => {
|
|
79
|
+
const req = {
|
|
80
|
+
params: { enquiryId: 'e-1', n: 7 },
|
|
81
|
+
query: { tenant: 't-9', filter: { orgId: 'o-3' } },
|
|
82
|
+
body: { order: { id: 'o-77' }, nested: { deep: { value: 'v' } } },
|
|
83
|
+
headers: { 'x-tenant-id': 'h-1', 'x-many': ['a', 'b'], 'x-one': ['solo'] },
|
|
84
|
+
auth: { profileId: 'p-1', entityId: 'ent-1' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('the bare form prefers a route param over a query key of the same name', () => {
|
|
88
|
+
const ambiguous = { ...req, query: { ...req.query, enquiryId: 'from-query' } }
|
|
89
|
+
expect(resolveGateResource(ambiguous, selectorOf('a--view@enquiryId')).id).toBe('e-1')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('the bare form still falls back to the query — deployed apps depend on it', () => {
|
|
93
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@tenant'))
|
|
94
|
+
expect(resolution.id).toBe('t-9')
|
|
95
|
+
expect(resolution.from).toBe(GateParamSource.Query)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test.each([
|
|
99
|
+
['a--view@body:order.id', 'o-77'],
|
|
100
|
+
['a--view@body:nested.deep.value', 'v'],
|
|
101
|
+
['a--view@query:filter.orgId', 'o-3'],
|
|
102
|
+
['a--view@auth:profileId', 'p-1'],
|
|
103
|
+
['a--view@headers:x-tenant-id', 'h-1'],
|
|
104
|
+
])('%s resolves to %s', (param, expected) => {
|
|
105
|
+
expect(resolveGateResource(req, selectorOf(param)).id).toBe(expected)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a header name resolves case-insensitively', () => {
|
|
109
|
+
expect(resolveGateResource(req, selectorOf('a--view@headers:X-Tenant-Id')).id).toBe('h-1')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('a single-element header array unwraps', () => {
|
|
113
|
+
expect(resolveGateResource(req, selectorOf('a--view@headers:x-one')).id).toBe('solo')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/** Picking one of several repeated headers would be an authorization decision made by accident. */
|
|
117
|
+
test('a repeated header is refused, never silently narrowed', () => {
|
|
118
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@headers:x-many'))
|
|
119
|
+
expect(resolution.id).toBeUndefined()
|
|
120
|
+
expect(resolution.reason).toBe(GateResolutionFailure.NotScalar)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a non-string scalar stringifies, preserving what AJV coercion produces', () => {
|
|
124
|
+
expect(resolveGateResource(req, selectorOf('a--view@params:n')).id).toBe('7')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('an object is refused rather than stringified to "[object Object]"', () => {
|
|
128
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@body:order'))
|
|
129
|
+
expect(resolution.reason).toBe(GateResolutionFailure.NotScalar)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a missing path reports why nothing was found', () => {
|
|
133
|
+
expect(resolveGateResource(req, selectorOf('a--view@body:absent')).reason)
|
|
134
|
+
.toBe(GateResolutionFailure.NotProvided)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('an absent source is distinguished from an absent key', () => {
|
|
138
|
+
expect(resolveGateResource({}, selectorOf('a--view@body:order.id')).reason)
|
|
139
|
+
.toBe(GateResolutionFailure.SourceMissing)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('gate params — validation against the declaration', () => {
|
|
144
|
+
test('a bare selector naming a real route param passes', () => {
|
|
145
|
+
expect(gateValidationHelper.validateGateParams(['a--view@enquiryId'], { routePath: '/enquiries/:enquiryId' }))
|
|
146
|
+
.toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The bare form searches the query too, and an entrypoint that declares no query filter strips
|
|
151
|
+
* nothing — so an unknown name is only provably wrong when both routes to it are closed.
|
|
152
|
+
*/
|
|
153
|
+
test('a bare selector on an entrypoint with no query filter is left alone', () => {
|
|
154
|
+
expect(gateValidationHelper.validateGateParams(['a--view@whatever'], { routePath: '/enquiries/:enquiryId' }))
|
|
155
|
+
.toEqual([])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a bare selector is reported once BOTH the params and query filters close the door', () => {
|
|
159
|
+
const issues = gateValidationHelper.validateGateParams(['a--view@whatever'], {
|
|
160
|
+
routePath: '/enquiries/:enquiryId',
|
|
161
|
+
filter: {
|
|
162
|
+
params: { properties: { enquiryId: {} }, additionalProperties: false },
|
|
163
|
+
query: { properties: { page: {} }, additionalProperties: false }
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
expect(issues[0]?.code).toBe(GateParamErrorCode.UnknownRouteParam)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `removeAdditional` strips an undeclared key before the gate runs, so the endpoint denies every
|
|
171
|
+
* request with a clean build and nothing logged. That is the whole reason this check exists.
|
|
172
|
+
*/
|
|
173
|
+
test('a body selector the filter forbids is reported as unreachable', () => {
|
|
174
|
+
const issues = gateValidationHelper.validateGateParams(['a--modify@body:orderId'], {
|
|
175
|
+
routePath: '/orders/transfer',
|
|
176
|
+
filter: { body: { properties: { amount: {} }, additionalProperties: false } }
|
|
177
|
+
})
|
|
178
|
+
expect(issues[0]?.code).toBe(GateParamErrorCode.UnreachableKey)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a body selector the filter declares passes', () => {
|
|
182
|
+
expect(gateValidationHelper.validateGateParams(['a--modify@body:orderId'], {
|
|
183
|
+
filter: { body: { properties: { orderId: {} }, additionalProperties: false } }
|
|
184
|
+
})).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('an open body schema strips nothing, so nothing is reported', () => {
|
|
188
|
+
expect(gateValidationHelper.validateGateParams(['a--modify@body:orderId'], {
|
|
189
|
+
filter: { body: { properties: { amount: {} } } }
|
|
190
|
+
})).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('an auth selector is never subject to the filter', () => {
|
|
194
|
+
expect(gateValidationHelper.validateGateParams(['a--modify@auth:profileId'], {
|
|
195
|
+
routePath: '/me', filter: { body: { properties: {}, additionalProperties: false } }
|
|
196
|
+
})).toEqual([])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('a malformed param is reported without an audit', () => {
|
|
200
|
+
expect(gateValidationHelper.validateGateParams(['a--view@cookies:sid'])[0]?.code)
|
|
201
|
+
.toBe(GateParamErrorCode.UnknownSource)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('permission names', () => {
|
|
206
|
+
test('splits on TWO hyphens', () => {
|
|
207
|
+
expect(permissionNameHelper.parsePermissionName('enquiry--modify')).toMatchObject({
|
|
208
|
+
resource: 'enquiry', action: 'modify'
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/** A single hyphen is not a separator, and a name already granted cannot be re-read. */
|
|
213
|
+
test('a single-hyphen legacy name stays one resource with no action', () => {
|
|
214
|
+
const parsed = permissionNameHelper.parsePermissionName('article-create')
|
|
215
|
+
expect(parsed.resource).toBe('article-create')
|
|
216
|
+
expect(parsed.action).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('compose round-trips both shapes', () => {
|
|
220
|
+
for (const name of ['enquiry--modify', 'article-create', 'project--admin']) {
|
|
221
|
+
const parsed = permissionNameHelper.parsePermissionName(name)
|
|
222
|
+
expect(permissionNameHelper.composePermissionName(parsed)).toBe(name)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
/** The live corruption this whole change exists to stop. */
|
|
227
|
+
test('a name carrying a gate selector is reported, not quietly split', () => {
|
|
228
|
+
const parsed = permissionNameHelper.parsePermissionName('enquiry--view@enquiryId')
|
|
229
|
+
expect(parsed.problem).toBeDefined()
|
|
230
|
+
expect(permissionNameHelper.isPermissionName('enquiry--view@enquiryId')).toBe(false)
|
|
231
|
+
expect(permissionNameHelper.isPermissionName('enquiry--view')).toBe(true)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import Ajv from 'ajv'
|
|
3
|
+
import addFormats from 'ajv-formats'
|
|
4
|
+
import { protocols } from '@owlmeans/entrypoint'
|
|
5
|
+
import type { EntrypointProtocol, EntrypointTree } from '@owlmeans/entrypoint'
|
|
6
|
+
import { IAM_RUNTIME_GUARD, IAM_RUNTIME_PATH, IAM_RUNTIME_ROUTES, makeIamRuntimeProtocols } from '@owlmeans/iam'
|
|
7
|
+
|
|
8
|
+
const SERVICE = 'iam-api'
|
|
9
|
+
const tree = makeIamRuntimeProtocols({ service: SERVICE })
|
|
10
|
+
const all = protocols(tree as unknown as EntrypointTree) as EntrypointProtocol[]
|
|
11
|
+
|
|
12
|
+
/** Every object schema reachable from `schema`, so closure can be asserted at every level. */
|
|
13
|
+
const objectSchemas = (schema: unknown): Record<string, unknown>[] => {
|
|
14
|
+
if (schema == null || typeof schema !== 'object') {
|
|
15
|
+
return []
|
|
16
|
+
}
|
|
17
|
+
const node = schema as Record<string, unknown>
|
|
18
|
+
const own = node.type === 'object' ? [node] : []
|
|
19
|
+
const nested = [
|
|
20
|
+
...Object.values((node.properties ?? {}) as Record<string, unknown>),
|
|
21
|
+
...(node.items != null ? [node.items] : []),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
return [...own, ...nested.flatMap(objectSchemas)]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sectionsOf = (declaration: EntrypointProtocol): unknown[] => [
|
|
28
|
+
...Object.values(declaration.contract?.requestSchemas ?? {}),
|
|
29
|
+
declaration.contract?.responseSchemas?.default,
|
|
30
|
+
].filter(schema => schema != null)
|
|
31
|
+
|
|
32
|
+
describe('@owlmeans/iam — runtime IAM API declarations', () => {
|
|
33
|
+
test('every route, the base included, is pinned to the serving service', () => {
|
|
34
|
+
expect(all.length).toBe(12)
|
|
35
|
+
all.forEach(declaration => expect(declaration.route.route.service).toBe(SERVICE))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('the base carries the runtime guard and every leaf hangs under it', () => {
|
|
39
|
+
expect(tree.base.guards).toEqual([IAM_RUNTIME_GUARD])
|
|
40
|
+
expect(tree.base.route.route.path).toBe(IAM_RUNTIME_PATH)
|
|
41
|
+
all.filter(declaration => declaration !== tree.base)
|
|
42
|
+
.forEach(declaration => expect(declaration.route.route.parent).toBe(tree.base.alias))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a custom base path is honoured', () => {
|
|
46
|
+
expect(makeIamRuntimeProtocols({ service: SERVICE, path: '/api/runtime' }).base.route.route.path)
|
|
47
|
+
.toBe('/api/runtime')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test.each([
|
|
51
|
+
[tree.organizations.list, 'GET', IAM_RUNTIME_ROUTES.organizations],
|
|
52
|
+
[tree.organizations.create, 'POST', IAM_RUNTIME_ROUTES.organizations],
|
|
53
|
+
[tree.organizations.update, 'POST', IAM_RUNTIME_ROUTES.organization],
|
|
54
|
+
[tree.members.list, 'GET', IAM_RUNTIME_ROUTES.members],
|
|
55
|
+
[tree.members.add, 'POST', IAM_RUNTIME_ROUTES.members],
|
|
56
|
+
[tree.members.update, 'POST', IAM_RUNTIME_ROUTES.member],
|
|
57
|
+
[tree.members.remove, 'POST', IAM_RUNTIME_ROUTES.memberRemove],
|
|
58
|
+
[tree.permissions.list, 'GET', IAM_RUNTIME_ROUTES.permissions],
|
|
59
|
+
[tree.grants.list, 'GET', IAM_RUNTIME_ROUTES.grants],
|
|
60
|
+
[tree.grants.assign, 'POST', IAM_RUNTIME_ROUTES.grants],
|
|
61
|
+
[tree.grants.revoke, 'POST', IAM_RUNTIME_ROUTES.grantsRevoke],
|
|
62
|
+
] as const)('%#: %p answers on its method and shared path', (declaration, method, path) => {
|
|
63
|
+
expect(declaration.route.route.method?.toUpperCase()).toBe(method)
|
|
64
|
+
expect(declaration.route.route.path).toBe(path)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('every request section and every response is a closed schema', () => {
|
|
68
|
+
const leaves = all.filter(declaration => declaration !== tree.base)
|
|
69
|
+
leaves.forEach(declaration => {
|
|
70
|
+
const schemas = sectionsOf(declaration)
|
|
71
|
+
expect(schemas.length).toBeGreaterThan(0)
|
|
72
|
+
schemas.flatMap(objectSchemas).forEach(schema => expect(schema.additionalProperties).toBe(false))
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('every schema compiles the way the server compiles it, and strips what it does not declare', () => {
|
|
77
|
+
// The server's own options: `removeAdditional` is what makes an undeclared key vanish.
|
|
78
|
+
const ajv = new Ajv({ removeAdditional: true, useDefaults: true, coerceTypes: true, allErrors: true, strict: false })
|
|
79
|
+
addFormats(ajv)
|
|
80
|
+
all.flatMap(sectionsOf).forEach(schema => expect(() => ajv.compile(schema as object)).not.toThrow())
|
|
81
|
+
|
|
82
|
+
const invite = ajv.compile(tree.members.add.contract!.requestSchemas.body!)
|
|
83
|
+
const body: Record<string, unknown> = { email: 'Person@Example.test', owner: true, entityId: '0123456789abcdef01234567' }
|
|
84
|
+
expect(invite(body)).toBe(true)
|
|
85
|
+
expect(body).toEqual({ email: 'Person@Example.test', owner: true })
|
|
86
|
+
expect(invite({ email: 'not-an-address' })).toBe(false)
|
|
87
|
+
|
|
88
|
+
const params = ajv.compile(tree.members.update.contract!.requestSchemas.params!)
|
|
89
|
+
expect(params({ entitySlug: 'acme', profileId: 'acme-preview:3vQB7B6MrGQZaxCuFg4oh' })).toBe(true)
|
|
90
|
+
expect(params({ entitySlug: 'acme', profileId: 'has space' })).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('the wire names organizations by slug only — no id field anywhere', () => {
|
|
94
|
+
const names = all.flatMap(sectionsOf).flatMap(objectSchemas)
|
|
95
|
+
.flatMap(schema => Object.keys((schema.properties ?? {}) as object))
|
|
96
|
+
expect(names).toContain('entitySlug')
|
|
97
|
+
expect(names).not.toContain('entityId')
|
|
98
|
+
expect(names).not.toContain('accountId')
|
|
99
|
+
expect(names).not.toContain('entityKey')
|
|
100
|
+
})
|
|
101
|
+
})
|
package/tests/utils.spec.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { hasPermission } from '@owlmeans/iam'
|
|
2
|
+
import { hasPermission, IamUnsupported, unsupportedFacet } from '@owlmeans/iam'
|
|
3
|
+
import type { IamSubjectFacet } from '@owlmeans/iam'
|
|
3
4
|
import type { Authorization } from '@owlmeans/auth'
|
|
5
|
+
import type { OidcPermissionSetClaim } from '@owlmeans/oidc'
|
|
4
6
|
|
|
5
7
|
const auth: Authorization = {
|
|
6
8
|
scopes: [],
|
|
@@ -37,3 +39,47 @@ describe('@owlmeans/iam — hasPermission', () => {
|
|
|
37
39
|
expect(hasPermission(auth, 'article--modify', { scope: 'other-project' })).toBe(false)
|
|
38
40
|
})
|
|
39
41
|
})
|
|
42
|
+
|
|
43
|
+
describe('@owlmeans/iam — hasPermission and the acting organization', () => {
|
|
44
|
+
const sets: OidcPermissionSetClaim[] = [
|
|
45
|
+
{ scope: 'my-project', permissions: { 'report--view': true } },
|
|
46
|
+
{ scope: 'my-project', permissions: { 'order--edit': true }, entitySlug: 'acme' },
|
|
47
|
+
{
|
|
48
|
+
scope: 'my-project', title: 'order--delete', permissions: { 'order--delete': true },
|
|
49
|
+
resources: ['ord-1'], entitySlug: 'acme',
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
const bound: Authorization = { scopes: [], permissions: sets }
|
|
53
|
+
|
|
54
|
+
test.each([
|
|
55
|
+
['an unbound set applies with no organization named', 'report--view', {}, true],
|
|
56
|
+
['an unbound set applies in any organization', 'report--view', { entitySlug: 'beta' }, true],
|
|
57
|
+
['a bound set applies in its organization', 'order--edit', { entitySlug: 'acme' }, true],
|
|
58
|
+
['a bound set does not apply in another organization', 'order--edit', { entitySlug: 'beta' }, false],
|
|
59
|
+
// Fail-closed: a bound set that leaked into a token must not count where the caller named none.
|
|
60
|
+
['a bound set does not apply when no organization is named', 'order--edit', {}, false],
|
|
61
|
+
['bound and resource-scoped: both checks pass', 'order--delete', { entitySlug: 'acme', resourceId: 'ord-1' }, true],
|
|
62
|
+
['bound and resource-scoped: wrong record', 'order--delete', { entitySlug: 'acme', resourceId: 'ord-2' }, false],
|
|
63
|
+
['bound and resource-scoped: wrong organization', 'order--delete', { entitySlug: 'beta', resourceId: 'ord-1' }, false],
|
|
64
|
+
] as const)('%s', (_, permission, opts, expected) => {
|
|
65
|
+
expect(hasPermission(bound, permission, opts)).toBe(expected)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('@owlmeans/iam — unsupportedFacet', () => {
|
|
70
|
+
const facet = unsupportedFacet<IamSubjectFacet>('subjects')
|
|
71
|
+
|
|
72
|
+
test('every method throws IamUnsupported naming the facet', () => {
|
|
73
|
+
expect(() => facet.identify('client', 'account')).toThrow(IamUnsupported)
|
|
74
|
+
expect(() => facet.resolve('client', 'account')).toThrow('iam:unsupported:subjects')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('an awaited call rejects the same way', async () => {
|
|
78
|
+
const attempt = async () => await facet.signIn('client', { email: 'a@example.test' })
|
|
79
|
+
expect(attempt()).rejects.toBeInstanceOf(IamUnsupported)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('is not a thenable, so awaiting the facet itself does not throw', async () => {
|
|
83
|
+
expect(await Promise.resolve(facet)).toBe(facet)
|
|
84
|
+
})
|
|
85
|
+
})
|