@owlmeans/iam 0.1.18-rc.2 → 0.1.18-rc.21
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/iam/SKILL.md +193 -56
- package/build/consts.d.ts +91 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +97 -0
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts +13 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +17 -0
- package/build/errors.js.map +1 -1
- 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 +31 -0
- package/build/gate/param.d.ts.map +1 -0
- package/build/gate/param.js +99 -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/validate.d.ts +15 -0
- package/build/gate/validate.d.ts.map +1 -0
- package/build/gate/validate.js +107 -0
- package/build/gate/validate.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/permission.d.ts +21 -0
- package/build/permission.d.ts.map +1 -0
- package/build/permission.js +39 -0
- package/build/permission.js.map +1 -0
- package/build/types.d.ts +251 -6
- package/build/types.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/consts.ts +105 -0
- package/src/errors.ts +19 -0
- package/src/gate/index.ts +3 -0
- package/src/gate/param.ts +122 -0
- package/src/gate/resolve.ts +114 -0
- package/src/gate/validate.ts +132 -0
- package/src/index.ts +2 -0
- package/src/permission.ts +48 -0
- package/src/types.ts +283 -6
- package/tests/gate-param.spec.ts +234 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams,
|
|
4
|
+
parsePermissionName, composePermissionName, isPermissionName,
|
|
5
|
+
GateParamSource, GateParamErrorCode, GateResolutionFailure
|
|
6
|
+
} from '../src/index.js'
|
|
7
|
+
import type { GateResourceSelector } from '../src/index.js'
|
|
8
|
+
|
|
9
|
+
const selectorOf = (param: string): GateResourceSelector => {
|
|
10
|
+
const resource = parseGateParam(param).resource
|
|
11
|
+
if (resource == null) {
|
|
12
|
+
throw new Error(`"${param}" produced no selector`)
|
|
13
|
+
}
|
|
14
|
+
return resource
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('gate params — the grammar', () => {
|
|
18
|
+
test('an unscoped param is the permission and nothing else', () => {
|
|
19
|
+
const parsed = parseGateParam('enquiry--modify')
|
|
20
|
+
expect(parsed.permission).toBe('enquiry--modify')
|
|
21
|
+
expect(parsed.resource).toBeUndefined()
|
|
22
|
+
expect(parsed.error).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('the bare form stays FLAT and keeps searching params then query', () => {
|
|
26
|
+
const parsed = parseGateParam('enquiry--view@enquiryId')
|
|
27
|
+
expect(parsed.permission).toBe('enquiry--view')
|
|
28
|
+
expect(parsed.resourceParam).toBe('enquiryId')
|
|
29
|
+
expect(parsed.resource?.path).toEqual(['enquiryId'])
|
|
30
|
+
expect(parsed.resource?.sources).toEqual([GateParamSource.Params, GateParamSource.Query])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A query key may legally contain a dot, so splitting the bare form on one would change the
|
|
35
|
+
* meaning of selectors already deployed inside customer projects.
|
|
36
|
+
*/
|
|
37
|
+
test('a dot in the bare form is part of the key, never a path', () => {
|
|
38
|
+
expect(selectorOf('a--view@filter.orgId').path).toEqual(['filter.orgId'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a qualified selector names its source and walks a path', () => {
|
|
42
|
+
const selector = selectorOf('order--modify@body:order.id')
|
|
43
|
+
expect(selector.source).toBe(GateParamSource.Body)
|
|
44
|
+
expect(selector.path).toEqual(['order', 'id'])
|
|
45
|
+
expect(selector.sources).toEqual([GateParamSource.Body])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('the qualified form does not populate the deprecated flat field', () => {
|
|
49
|
+
expect(parseGateParam('order--modify@body:order.id').resourceParam).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('an unknown source is reported rather than treated as a key', () => {
|
|
53
|
+
const parsed = parseGateParam('a--view@cookies:sid')
|
|
54
|
+
expect(parsed.error?.code).toBe(GateParamErrorCode.UnknownSource)
|
|
55
|
+
expect(parsed.resource).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test.each([
|
|
59
|
+
['a--view@', GateParamErrorCode.EmptySelector],
|
|
60
|
+
['a--view@body:', GateParamErrorCode.EmptySelector],
|
|
61
|
+
['a--view@body:order..id', GateParamErrorCode.EmptySegment],
|
|
62
|
+
])('%s is refused as %s', (param, code) => {
|
|
63
|
+
expect(parseGateParam(param).error?.code).toBe(code)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('format is the inverse of parse for both forms', () => {
|
|
67
|
+
for (const param of ['a--view', 'a--view@id', 'a--view@body:order.id', 'a--view@auth:profileId']) {
|
|
68
|
+
const parsed = parseGateParam(param)
|
|
69
|
+
expect(formatGateParam(parsed.permission, parsed.resource)).toBe(param)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('parseGateSelector reports a problem instead of throwing', () => {
|
|
74
|
+
const result = parseGateSelector('')
|
|
75
|
+
expect('code' in result).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('gate params — resolution', () => {
|
|
80
|
+
const req = {
|
|
81
|
+
params: { enquiryId: 'e-1', n: 7 },
|
|
82
|
+
query: { tenant: 't-9', filter: { orgId: 'o-3' } },
|
|
83
|
+
body: { order: { id: 'o-77' }, nested: { deep: { value: 'v' } } },
|
|
84
|
+
headers: { 'x-tenant-id': 'h-1', 'x-many': ['a', 'b'], 'x-one': ['solo'] },
|
|
85
|
+
auth: { profileId: 'p-1', entityId: 'ent-1' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('the bare form prefers a route param over a query key of the same name', () => {
|
|
89
|
+
const ambiguous = { ...req, query: { ...req.query, enquiryId: 'from-query' } }
|
|
90
|
+
expect(resolveGateResource(ambiguous, selectorOf('a--view@enquiryId')).id).toBe('e-1')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('the bare form still falls back to the query — deployed apps depend on it', () => {
|
|
94
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@tenant'))
|
|
95
|
+
expect(resolution.id).toBe('t-9')
|
|
96
|
+
expect(resolution.from).toBe(GateParamSource.Query)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test.each([
|
|
100
|
+
['a--view@body:order.id', 'o-77'],
|
|
101
|
+
['a--view@body:nested.deep.value', 'v'],
|
|
102
|
+
['a--view@query:filter.orgId', 'o-3'],
|
|
103
|
+
['a--view@auth:profileId', 'p-1'],
|
|
104
|
+
['a--view@headers:x-tenant-id', 'h-1'],
|
|
105
|
+
])('%s resolves to %s', (param, expected) => {
|
|
106
|
+
expect(resolveGateResource(req, selectorOf(param)).id).toBe(expected)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a header name resolves case-insensitively', () => {
|
|
110
|
+
expect(resolveGateResource(req, selectorOf('a--view@headers:X-Tenant-Id')).id).toBe('h-1')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a single-element header array unwraps', () => {
|
|
114
|
+
expect(resolveGateResource(req, selectorOf('a--view@headers:x-one')).id).toBe('solo')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/** Picking one of several repeated headers would be an authorization decision made by accident. */
|
|
118
|
+
test('a repeated header is refused, never silently narrowed', () => {
|
|
119
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@headers:x-many'))
|
|
120
|
+
expect(resolution.id).toBeUndefined()
|
|
121
|
+
expect(resolution.reason).toBe(GateResolutionFailure.NotScalar)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('a non-string scalar stringifies, preserving what AJV coercion produces', () => {
|
|
125
|
+
expect(resolveGateResource(req, selectorOf('a--view@params:n')).id).toBe('7')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an object is refused rather than stringified to "[object Object]"', () => {
|
|
129
|
+
const resolution = resolveGateResource(req, selectorOf('a--view@body:order'))
|
|
130
|
+
expect(resolution.reason).toBe(GateResolutionFailure.NotScalar)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a missing path reports why nothing was found', () => {
|
|
134
|
+
expect(resolveGateResource(req, selectorOf('a--view@body:absent')).reason)
|
|
135
|
+
.toBe(GateResolutionFailure.NotProvided)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('an absent source is distinguished from an absent key', () => {
|
|
139
|
+
expect(resolveGateResource({}, selectorOf('a--view@body:order.id')).reason)
|
|
140
|
+
.toBe(GateResolutionFailure.SourceMissing)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('gate params — validation against the declaration', () => {
|
|
145
|
+
test('a bare selector naming a real route param passes', () => {
|
|
146
|
+
expect(validateGateParams(['a--view@enquiryId'], { routePath: '/enquiries/:enquiryId' }))
|
|
147
|
+
.toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The bare form searches the query too, and an entrypoint that declares no query filter strips
|
|
152
|
+
* nothing — so an unknown name is only provably wrong when both routes to it are closed.
|
|
153
|
+
*/
|
|
154
|
+
test('a bare selector on an entrypoint with no query filter is left alone', () => {
|
|
155
|
+
expect(validateGateParams(['a--view@whatever'], { routePath: '/enquiries/:enquiryId' }))
|
|
156
|
+
.toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a bare selector is reported once BOTH the params and query filters close the door', () => {
|
|
160
|
+
const issues = validateGateParams(['a--view@whatever'], {
|
|
161
|
+
routePath: '/enquiries/:enquiryId',
|
|
162
|
+
filter: {
|
|
163
|
+
params: { properties: { enquiryId: {} }, additionalProperties: false },
|
|
164
|
+
query: { properties: { page: {} }, additionalProperties: false }
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
expect(issues[0]?.code).toBe(GateParamErrorCode.UnknownRouteParam)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `removeAdditional` strips an undeclared key before the gate runs, so the endpoint denies every
|
|
172
|
+
* request with a clean build and nothing logged. That is the whole reason this check exists.
|
|
173
|
+
*/
|
|
174
|
+
test('a body selector the filter forbids is reported as unreachable', () => {
|
|
175
|
+
const issues = validateGateParams(['a--modify@body:orderId'], {
|
|
176
|
+
routePath: '/orders/transfer',
|
|
177
|
+
filter: { body: { properties: { amount: {} }, additionalProperties: false } }
|
|
178
|
+
})
|
|
179
|
+
expect(issues[0]?.code).toBe(GateParamErrorCode.UnreachableKey)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('a body selector the filter declares passes', () => {
|
|
183
|
+
expect(validateGateParams(['a--modify@body:orderId'], {
|
|
184
|
+
filter: { body: { properties: { orderId: {} }, additionalProperties: false } }
|
|
185
|
+
})).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('an open body schema strips nothing, so nothing is reported', () => {
|
|
189
|
+
expect(validateGateParams(['a--modify@body:orderId'], {
|
|
190
|
+
filter: { body: { properties: { amount: {} } } }
|
|
191
|
+
})).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('an auth selector is never subject to the filter', () => {
|
|
195
|
+
expect(validateGateParams(['a--modify@auth:profileId'], {
|
|
196
|
+
routePath: '/me', filter: { body: { properties: {}, additionalProperties: false } }
|
|
197
|
+
})).toEqual([])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('a malformed param is reported without an audit', () => {
|
|
201
|
+
expect(validateGateParams(['a--view@cookies:sid'])[0]?.code)
|
|
202
|
+
.toBe(GateParamErrorCode.UnknownSource)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('permission names', () => {
|
|
207
|
+
test('splits on TWO hyphens', () => {
|
|
208
|
+
expect(parsePermissionName('enquiry--modify')).toMatchObject({
|
|
209
|
+
resource: 'enquiry', action: 'modify'
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/** A single hyphen is not a separator, and a name already granted cannot be re-read. */
|
|
214
|
+
test('a single-hyphen legacy name stays one resource with no action', () => {
|
|
215
|
+
const parsed = parsePermissionName('article-create')
|
|
216
|
+
expect(parsed.resource).toBe('article-create')
|
|
217
|
+
expect(parsed.action).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('compose round-trips both shapes', () => {
|
|
221
|
+
for (const name of ['enquiry--modify', 'article-create', 'project--admin']) {
|
|
222
|
+
const parsed = parsePermissionName(name)
|
|
223
|
+
expect(composePermissionName(parsed)).toBe(name)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
/** The live corruption this whole change exists to stop. */
|
|
228
|
+
test('a name carrying a gate selector is reported, not quietly split', () => {
|
|
229
|
+
const parsed = parsePermissionName('enquiry--view@enquiryId')
|
|
230
|
+
expect(parsed.problem).toBeDefined()
|
|
231
|
+
expect(isPermissionName('enquiry--view@enquiryId')).toBe(false)
|
|
232
|
+
expect(isPermissionName('enquiry--view')).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
})
|