@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/src/gate.ts
CHANGED
|
@@ -1,97 +1,209 @@
|
|
|
1
1
|
import { createLazyService } from '@owlmeans/context'
|
|
2
|
-
import type { GateService, AbstractRequest } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { GateService, AbstractRequest, CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
3
|
+
import { authHelper, AuthForbidden, type Auth } from '@owlmeans/auth'
|
|
3
4
|
import { OIDC_GATE } from '@owlmeans/oidc'
|
|
4
|
-
import {
|
|
5
|
-
import type
|
|
6
|
-
import {
|
|
7
|
-
import type {
|
|
8
|
-
import { hasPermission } from '@owlmeans/iam'
|
|
5
|
+
import { createGateModel, extractPermissionSets, type Config, type Context } from '@owlmeans/server-oidc-rp'
|
|
6
|
+
import { hasPermission, gateParamHelper, gateValidationHelper, resolveGateResource, GateResolutionFailure, type GateParamAudit } from '@owlmeans/iam'
|
|
7
|
+
import { logger, logThrottle } from '@owlmeans/log'
|
|
8
|
+
import type { IamGateOptions } from './types.js'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* req.params[requestParam] ?? req.query[requestParam]
|
|
15
|
-
* and must be listed in the grant's resources[]
|
|
16
|
-
* '{entity}' substitution applies to the permission part in both forms.
|
|
11
|
+
* The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
|
|
12
|
+
* tooling can all read it. Re-exported here because this module was its home and is the documented
|
|
13
|
+
* import path.
|
|
17
14
|
*/
|
|
18
|
-
export
|
|
15
|
+
export {
|
|
16
|
+
RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR,
|
|
17
|
+
GateParamSource, GateParamErrorCode, GateResolutionFailure,
|
|
18
|
+
gateParamHelper, gateValidationHelper, resolveGateResource
|
|
19
|
+
} from '@owlmeans/iam'
|
|
20
|
+
export type { ParsedGateParam, GateResourceSelector, GateParamAudit, GateParamHelper, GateValidationHelper } from '@owlmeans/iam'
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (idx < 0) {
|
|
28
|
-
return { permission: param }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return { permission: param.slice(0, idx), resourceParam: param.slice(idx + 1) }
|
|
32
|
-
}
|
|
22
|
+
/**
|
|
23
|
+
* Structural faults are logged once per alias+param+reason.
|
|
24
|
+
*
|
|
25
|
+
* A gate on a hot endpoint runs on every request, and a misconfiguration is by definition permanent,
|
|
26
|
+
* so an un-deduplicated log turns one typo into the incident.
|
|
27
|
+
*/
|
|
28
|
+
const reported = new Set<string>()
|
|
33
29
|
|
|
34
|
-
const
|
|
35
|
-
const params = req.params as Record<string, unknown> | undefined
|
|
36
|
-
const query = req.query as Record<string, unknown> | undefined
|
|
37
|
-
const value = params?.[resourceParam] ?? query?.[resourceParam]
|
|
30
|
+
const log = logger('server-iam:gate')
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
/** Aliases whose declarations have already been checked. */
|
|
33
|
+
const audited = new Set<string>()
|
|
41
34
|
|
|
42
35
|
/**
|
|
43
36
|
* IAM gate: claims-first with UMA2 fallback. Registered under the OIDC_GATE alias by
|
|
44
37
|
* appendIam() so target code never knows which IAM backend is active.
|
|
45
38
|
*
|
|
46
39
|
* 1. When the Auth carries a valid PermissionSet[] claim (integrated IAM mode),
|
|
47
|
-
* params are asserted locally against it — both unscoped and resource-scoped forms
|
|
40
|
+
* params are asserted locally against it — both unscoped and resource-scoped forms, and a set
|
|
41
|
+
* bound to an organization only for the organization the request acts in.
|
|
48
42
|
* 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
|
|
49
43
|
* @-suffixes are stripped and the check delegates to the UMA2 gate model from
|
|
50
44
|
* @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
|
|
45
|
+
*
|
|
46
|
+
* Params are OR'd: holding any one of them admits the request. That is why a malformed or
|
|
47
|
+
* unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
|
|
48
|
+
* have passed must not be refused because of it.
|
|
51
49
|
*/
|
|
52
|
-
export const makeIamGate = (alias: string = OIDC_GATE): GateService => {
|
|
50
|
+
export const makeIamGate = (alias: string = OIDC_GATE, opts?: IamGateOptions): GateService => {
|
|
51
|
+
const reportOnce = (key: string, message: string, data: Record<string, unknown>): void => {
|
|
52
|
+
if (reported.has(key)) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
reported.add(key)
|
|
56
|
+
log.error(message, data)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A refusal of the gate. Logged once a minute per route, reason and subject — a hot endpoint
|
|
61
|
+
* refuses on every request, and one line per window says the same thing.
|
|
62
|
+
*/
|
|
63
|
+
const forbidden = (req: AbstractRequest, reason: string, params: string[]): AuthForbidden => {
|
|
64
|
+
const auth = req.auth as Auth | undefined
|
|
65
|
+
if (logThrottle(`access.forbidden\0${req.alias ?? ''}\0${reason}\0${auth?.userId ?? ''}`, 60_000)) {
|
|
66
|
+
log.warn('Access forbidden', {
|
|
67
|
+
alias: req.alias, reason, params, userId: auth?.userId, entitySlug: req.entity?.slug ?? (auth != null ? authHelper.entitySlugOf(auth) : undefined),
|
|
68
|
+
}, { event: 'access.forbidden' })
|
|
69
|
+
}
|
|
70
|
+
return new AuthForbidden(reason)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check an entrypoint's gate params against what it declares, once, the first time it is reached.
|
|
75
|
+
*
|
|
76
|
+
* Structurally unable to affect the outcome: it only ever logs. A selector naming a key the
|
|
77
|
+
* entrypoint's filter does not declare is stripped by validation before the gate runs, so the
|
|
78
|
+
* endpoint denies every request with a clean build and nothing else to go on — this is the line that
|
|
79
|
+
* says why.
|
|
80
|
+
*/
|
|
81
|
+
const auditEntrypoint = (ctx: Context, req: AbstractRequest, params: string[]): void => {
|
|
82
|
+
if (req.alias == null || audited.has(req.alias)) {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
audited.add(req.alias)
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const entrypoint = ctx.entrypoint<CommonEntrypoint>(req.alias)
|
|
89
|
+
if (entrypoint == null) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let routePath: string | undefined
|
|
94
|
+
try {
|
|
95
|
+
routePath = entrypoint.mount()
|
|
96
|
+
} catch {
|
|
97
|
+
// The mount needs a service with an address; an entrypoint that has none still has a filter
|
|
98
|
+
// worth auditing.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const audit: GateParamAudit = {
|
|
102
|
+
...(routePath != null ? { routePath } : {}),
|
|
103
|
+
...(entrypoint.filter != null ? { filter: entrypoint.filter as GateParamAudit['filter'] } : {})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const issue of gateValidationHelper.validateGateParams(params, audit)) {
|
|
107
|
+
log.error('Gate param is misconfigured', {
|
|
108
|
+
param: issue.param, alias: req.alias, code: issue.code, detail: issue.detail,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// An entrypoint that cannot be looked up is not this gate's problem to report.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
53
116
|
const service: GateService = createLazyService<GateService>(alias, {
|
|
54
117
|
assert: async (req, _, params) => {
|
|
55
118
|
await service.ready()
|
|
56
119
|
const ctx = service.assertCtx<Config, Context>()
|
|
57
120
|
|
|
58
121
|
if (req.auth == null) {
|
|
59
|
-
throw
|
|
122
|
+
throw forbidden(req, 'auth', params)
|
|
60
123
|
}
|
|
61
124
|
|
|
62
125
|
const auth = req.auth as Auth
|
|
63
126
|
const sets = extractPermissionSets(auth.permissions)
|
|
64
127
|
|
|
65
128
|
if (sets != null) {
|
|
129
|
+
auditEntrypoint(ctx, req, params)
|
|
130
|
+
|
|
131
|
+
// Why a param failed, kept aside so a denial can say whether the gate was misconfigured or
|
|
132
|
+
// the subject simply lacks the grant. The two look identical from the outside and are
|
|
133
|
+
// repaired in completely different places.
|
|
134
|
+
const structural: string[] = []
|
|
135
|
+
|
|
136
|
+
// The organization the request acts in. A relying party of a tenanted client attaches it
|
|
137
|
+
// through the guard; otherwise it is the token's. A set bound to any other organization
|
|
138
|
+
// never satisfies the check.
|
|
139
|
+
const entitySlug = req.entity?.slug ?? authHelper.entitySlugOf(auth)
|
|
140
|
+
|
|
66
141
|
const granted = params.some(param => {
|
|
67
|
-
const { permission,
|
|
68
|
-
|
|
142
|
+
const { permission, resource, error } = gateParamHelper.parseGateParam(param)
|
|
143
|
+
// Grants are stored against the entity's stable id wherever one is resolvable; the
|
|
144
|
+
// slug is only a fallback for deployments with no organization store of their own,
|
|
145
|
+
// where the slug IS the identifier.
|
|
146
|
+
const fixed = permission.replaceAll('{entity}', req.entity?.id ?? authHelper.entitySlugOf(auth) ?? '-')
|
|
147
|
+
|
|
148
|
+
if (error != null) {
|
|
149
|
+
structural.push(`"${param}" does not parse [${error.code}]: ${error.detail}`)
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
69
152
|
|
|
70
|
-
if (
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
153
|
+
if (resource != null) {
|
|
154
|
+
const resolution = resolveGateResource(req, resource)
|
|
155
|
+
if (resolution.id == null) {
|
|
156
|
+
if (resolution.reason !== GateResolutionFailure.NotProvided) {
|
|
157
|
+
structural.push(
|
|
158
|
+
`"${param}" resolved no resource id [${resolution.reason}]`
|
|
159
|
+
+ ` from ${resource.sources.join(', ')}`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
73
162
|
return false
|
|
74
163
|
}
|
|
75
164
|
|
|
76
|
-
return hasPermission(auth, fixed, { resourceId })
|
|
165
|
+
return hasPermission(auth, fixed, { resourceId: resolution.id, entitySlug })
|
|
77
166
|
}
|
|
78
167
|
|
|
79
|
-
return hasPermission(auth, fixed)
|
|
168
|
+
return hasPermission(auth, fixed, { entitySlug })
|
|
80
169
|
})
|
|
81
170
|
|
|
82
171
|
if (!granted) {
|
|
83
|
-
|
|
172
|
+
structural.forEach(detail => reportOnce(
|
|
173
|
+
`${req.alias}\0${detail}`,
|
|
174
|
+
'Gate refused a request it could not evaluate', { alias: req.alias, detail }
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
throw forbidden(req, 'permission', params)
|
|
84
178
|
}
|
|
85
179
|
|
|
86
180
|
return
|
|
87
181
|
}
|
|
88
182
|
|
|
89
|
-
|
|
183
|
+
// UMA2 fallback. Resource scoping is NOT enforced here: a Keycloak-backed deployment cannot
|
|
184
|
+
// store a resource-scoped grant, so a scoped param widens to a project-wide check and the same
|
|
185
|
+
// declaration means two different things depending on which backend is active.
|
|
186
|
+
const scoped = params.filter(param => gateParamHelper.parseGateParam(param).resource != null)
|
|
187
|
+
scoped.forEach(param => reportOnce(
|
|
188
|
+
`uma2\0${req.alias}\0${param}`,
|
|
189
|
+
'Gate param is resource-scoped, but resource scoping is not enforced in UMA2 mode — it is checked as a '
|
|
190
|
+
+ 'project-wide permission', { param, alias: req.alias }
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
const usable = opts?.strictResourceScope === true
|
|
194
|
+
? params.filter(param => gateParamHelper.parseGateParam(param).resource == null)
|
|
195
|
+
: params
|
|
196
|
+
|
|
197
|
+
if (usable.length < 1) {
|
|
198
|
+
throw forbidden(req, 'permission', params)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const stripped = usable.map(param => gateParamHelper.parseGateParam(param).permission)
|
|
90
202
|
const model = createGateModel(ctx)
|
|
91
203
|
const permissions = await model.loadPermissions(auth, stripped)
|
|
92
204
|
|
|
93
205
|
if (permissions.length < 1) {
|
|
94
|
-
throw
|
|
206
|
+
throw forbidden(req, 'permission', params)
|
|
95
207
|
}
|
|
96
208
|
}
|
|
97
209
|
})
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type * from '@owlmeans/iam'
|
|
2
2
|
export { hasPermission } from '@owlmeans/iam'
|
|
3
|
+
export { ORGANIZATION_REFUSAL, ORGANIZATION_OWNER_REFUSAL } from '@owlmeans/oidc'
|
|
3
4
|
export * from './consts.js'
|
|
4
5
|
export * from './append.js'
|
|
5
6
|
export * from './gate.js'
|
|
7
|
+
export type * from './types.js'
|
|
8
|
+
export { makeOrganizationScope, organizationOf, organizationsOf } from './organization.js'
|
|
9
|
+
export type * from './organization/types.js'
|
|
10
|
+
export { makeIamRuntimeClient, iamRuntime } from './runtime.js'
|
|
11
|
+
export type { IamRuntimeClient } from './types.js'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ResolvedEntity } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { OIDCAuthCache } from '@owlmeans/server-oidc-rp'
|
|
3
|
+
|
|
4
|
+
/** The organizations of one request's subject, read from its session — built once per handler. */
|
|
5
|
+
export interface OrganizationScope {
|
|
6
|
+
/** The session record behind the request's wrapped token — the guard has already refreshed it. */
|
|
7
|
+
sessionOf: () => Promise<OIDCAuthCache>
|
|
8
|
+
/**
|
|
9
|
+
* Every organization of the request's subject, as request entities keyed by their frozen IAM key.
|
|
10
|
+
*
|
|
11
|
+
* The session record is the authority — the provider's last claim, re-read on every validation —
|
|
12
|
+
* never the browser: a relying party of a tenanted client has no organization registry of its own.
|
|
13
|
+
*/
|
|
14
|
+
organizationsOf: () => Promise<ResolvedEntity[]>
|
|
15
|
+
/**
|
|
16
|
+
* One organization of the request's subject by its slug — for a handler that acts in an
|
|
17
|
+
* organization the URL names rather than in the session's acting one. One the subject is not in is
|
|
18
|
+
* refused as `AuthForbidden(ORGANIZATION_REFUSAL)`, exactly as the switch refuses it.
|
|
19
|
+
*/
|
|
20
|
+
organizationOf: (entitySlug: string) => Promise<ResolvedEntity>
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
|
|
2
|
+
import type { AbstractRequest, ResolvedEntity } from '@owlmeans/entrypoint'
|
|
3
|
+
import { ORGANIZATION_REFUSAL } from '@owlmeans/oidc'
|
|
4
|
+
import type { OidcOrganizationClaim } from '@owlmeans/oidc'
|
|
5
|
+
import { resolvedEntityOf, sessionRecord } from '@owlmeans/server-oidc-rp'
|
|
6
|
+
import type { Config, Context, OIDCAuthCache } from '@owlmeans/server-oidc-rp'
|
|
7
|
+
import type { OrganizationScope } from './organization/types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The organizations the provider last claimed for the session's subject. A session that acts in
|
|
11
|
+
* none — a client without the organizations scope — has none to offer, whatever its record holds.
|
|
12
|
+
*/
|
|
13
|
+
const claimedOf = (record: OIDCAuthCache): OidcOrganizationClaim[] =>
|
|
14
|
+
record.acting == null ? [] : record.organizations ?? []
|
|
15
|
+
|
|
16
|
+
export const makeOrganizationScope = <C extends Config, T extends Context<C>>(
|
|
17
|
+
context: T, request: AbstractRequest
|
|
18
|
+
): OrganizationScope => {
|
|
19
|
+
const sessionOf = async (): Promise<OIDCAuthCache> => {
|
|
20
|
+
if (request.auth == null) {
|
|
21
|
+
throw new AuthorizationError('auth')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return sessionRecord<C, T>(context, request.auth.token)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const organizationsOf = async (): Promise<ResolvedEntity[]> => claimedOf(await sessionOf()).map(resolvedEntityOf)
|
|
28
|
+
|
|
29
|
+
const organizationOf = async (entitySlug: string): Promise<ResolvedEntity> => {
|
|
30
|
+
const org = claimedOf(await sessionOf()).find(org => org.entitySlug === entitySlug)
|
|
31
|
+
if (org == null) {
|
|
32
|
+
throw new AuthForbidden(ORGANIZATION_REFUSAL)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return resolvedEntityOf(org)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { sessionOf, organizationsOf, organizationOf }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @deprecated compat:factory-refactor — use `makeOrganizationScope(context, request).organizationsOf()` */
|
|
42
|
+
export const organizationsOf = async <C extends Config, T extends Context<C>>(
|
|
43
|
+
context: T, request: AbstractRequest
|
|
44
|
+
): Promise<ResolvedEntity[]> => await makeOrganizationScope<C, T>(context, request).organizationsOf()
|
|
45
|
+
|
|
46
|
+
/** @deprecated compat:factory-refactor — use `makeOrganizationScope(context, request).organizationOf(…)` */
|
|
47
|
+
export const organizationOf = async <C extends Config, T extends Context<C>>(
|
|
48
|
+
context: T, request: AbstractRequest, entitySlug: string
|
|
49
|
+
): Promise<ResolvedEntity> => await makeOrganizationScope<C, T>(context, request).organizationOf(entitySlug)
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { AuthForbidden, AuthorizationError, AuthUnavailable } from '@owlmeans/auth'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import { ResilientError } from '@owlmeans/error'
|
|
4
|
+
import { IAM_RUNTIME_ROUTES, IamClientError, IamError, type IamRuntimeAck, type IamRuntimeGrant, type IamRuntimeGrantList, type IamRuntimeMember, type IamRuntimeMemberList, type IamRuntimeOrganization, type IamRuntimeOrganizationList, type IamRuntimePermissionList } from '@owlmeans/iam'
|
|
5
|
+
import { IAM_API_METADATA, ORGANIZATION_OWNER_REFUSAL, ORGANIZATION_REFUSAL } from '@owlmeans/oidc'
|
|
6
|
+
import { DEFAULT_ALIAS as OIDC_CLIENT_SERVICE, type Config, type Context, type OidcClientService } from '@owlmeans/server-oidc-rp'
|
|
7
|
+
import { makeOrganizationScope } from './organization.js'
|
|
8
|
+
import { INCIDENT_ID_HEADER } from './consts.local.js'
|
|
9
|
+
import type { Access, Call } from './types.local.js'
|
|
10
|
+
import type { IamRuntimeClient } from './types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discovered runtime bases, per context and per client. Discovery is a network round trip the OIDC
|
|
14
|
+
* client service does not memoise, and the base never changes while a provider runs. A failed
|
|
15
|
+
* lookup is forgotten, so the next request asks again rather than inheriting the error.
|
|
16
|
+
*/
|
|
17
|
+
const bases = new WeakMap<object, Map<string, Promise<string>>>()
|
|
18
|
+
|
|
19
|
+
const pathOf = (path: string, params: Record<string, string> = {}): string =>
|
|
20
|
+
path.replace(/:(\w+)/g, (_, key: string) => {
|
|
21
|
+
const value = params[key]
|
|
22
|
+
if (value == null || value === '') {
|
|
23
|
+
throw new IamError(`runtime:param:${key}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return encodeURIComponent(value)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The error a refused call rejects with.
|
|
31
|
+
*
|
|
32
|
+
* A development server sends the marshalled error, which is rebuilt into its own class. A production
|
|
33
|
+
* one sends only an incident id, so the status and the route's access level are all there is to go
|
|
34
|
+
* on: a 403 from an owner-level route is the subject not owning the organization, from any other
|
|
35
|
+
* route the subject not being in it.
|
|
36
|
+
*/
|
|
37
|
+
const failureOf = async (response: Response, access: Access): Promise<Error> => {
|
|
38
|
+
const text = await response.text().catch(() => '')
|
|
39
|
+
const incidentId = response.headers.get(INCIDENT_ID_HEADER) ?? undefined
|
|
40
|
+
|
|
41
|
+
if (text.includes(ResilientError.separator)) {
|
|
42
|
+
try {
|
|
43
|
+
const error = ResilientError.ensure(text, true)
|
|
44
|
+
error.incidentId ??= incidentId
|
|
45
|
+
return error
|
|
46
|
+
} catch {
|
|
47
|
+
// Not a marshalled error after all — fall through to the status.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const error = response.status === 401 ? new AuthorizationError('iam-runtime')
|
|
52
|
+
: response.status === 403 ? new AuthForbidden(access === 'owner' ? ORGANIZATION_OWNER_REFUSAL : ORGANIZATION_REFUSAL)
|
|
53
|
+
: new IamError(`runtime:status:${response.status}`)
|
|
54
|
+
if (incidentId != null) {
|
|
55
|
+
error.incidentId = incidentId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A typed client of the provider's runtime IAM API, bound to one request.
|
|
63
|
+
*
|
|
64
|
+
* It calls as the request's subject: the bearer is the provider access token of that request's
|
|
65
|
+
* session record, read afresh on every call, so a token the guard has just refreshed is the one
|
|
66
|
+
* sent. The base URL is the provider's discovery field `IAM_API_METADATA`. Calls travel over plain
|
|
67
|
+
* `fetch`, because the API is another service's tree and a relying party binds none of it.
|
|
68
|
+
*/
|
|
69
|
+
export const makeIamRuntimeClient = <C extends Config, T extends Context<C>>(
|
|
70
|
+
context: T, request: AbstractRequest
|
|
71
|
+
): IamRuntimeClient => {
|
|
72
|
+
const runtimeBase = (clientId: string): Promise<string> => {
|
|
73
|
+
const known = bases.get(context) ?? new Map<string, Promise<string>>()
|
|
74
|
+
bases.set(context, known)
|
|
75
|
+
const cached = known.get(clientId)
|
|
76
|
+
if (cached != null) {
|
|
77
|
+
return cached
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const attempt = (async () => {
|
|
81
|
+
const client = await context.service<OidcClientService>(OIDC_CLIENT_SERVICE).getClient(clientId)
|
|
82
|
+
const base = client.getMetadata()[IAM_API_METADATA]
|
|
83
|
+
if (typeof base !== 'string' || base === '') {
|
|
84
|
+
// A provider that advertises no runtime API — Keycloak, or an integrated provider that does not
|
|
85
|
+
// serve it — has nothing to call.
|
|
86
|
+
throw new IamClientError(`runtime-api:${clientId}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return base.replace(/\/+$/, '')
|
|
90
|
+
})()
|
|
91
|
+
known.set(clientId, attempt)
|
|
92
|
+
attempt.catch(() => {
|
|
93
|
+
if (known.get(clientId) === attempt) {
|
|
94
|
+
known.delete(clientId)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return attempt
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const scope = makeOrganizationScope<C, T>(context, request)
|
|
102
|
+
|
|
103
|
+
const call = async <R>({ method, path, access, params, query, body }: Call): Promise<R> => {
|
|
104
|
+
const record = await scope.sessionOf()
|
|
105
|
+
const clientId = record.client ?? context.service<OidcClientService>(OIDC_CLIENT_SERVICE).getDefault()
|
|
106
|
+
if (clientId == null) {
|
|
107
|
+
throw new AuthUnavailable('oidc-client')
|
|
108
|
+
}
|
|
109
|
+
const token = record.payload?.access_token
|
|
110
|
+
if (token == null || token === '') {
|
|
111
|
+
throw new AuthForbidden('record')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const url = new URL(`${await runtimeBase(clientId)}${pathOf(path, params)}`)
|
|
115
|
+
Object.entries(query ?? {}).forEach(([key, value]) => {
|
|
116
|
+
if (value != null) {
|
|
117
|
+
url.searchParams.set(key, String(value))
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method,
|
|
123
|
+
headers: {
|
|
124
|
+
authorization: `Bearer ${token}`,
|
|
125
|
+
accept: 'application/json',
|
|
126
|
+
...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
|
127
|
+
},
|
|
128
|
+
// A JSON content type with an empty body is refused by the server, so a POST always sends one.
|
|
129
|
+
...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw await failureOf(response, access)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return await response.json() as R
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const R = IAM_RUNTIME_ROUTES
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
organizations: {
|
|
143
|
+
list: async () => (await call<IamRuntimeOrganizationList>({
|
|
144
|
+
method: 'GET', path: R.organizations, access: 'subject',
|
|
145
|
+
})).items,
|
|
146
|
+
create: async body => call<IamRuntimeOrganization>({
|
|
147
|
+
method: 'POST', path: R.organizations, access: 'subject', body: body ?? {},
|
|
148
|
+
}),
|
|
149
|
+
update: async (entitySlug, body) => call<IamRuntimeOrganization>({
|
|
150
|
+
method: 'POST', path: R.organization, access: 'owner', params: { entitySlug }, body,
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
members: {
|
|
154
|
+
list: async entitySlug => (await call<IamRuntimeMemberList>({
|
|
155
|
+
method: 'GET', path: R.members, access: 'member', params: { entitySlug },
|
|
156
|
+
})).items,
|
|
157
|
+
add: async (entitySlug, invite) => call<IamRuntimeMember>({
|
|
158
|
+
method: 'POST', path: R.members, access: 'owner', params: { entitySlug }, body: invite,
|
|
159
|
+
}),
|
|
160
|
+
update: async (entitySlug, profileId, update) => call<IamRuntimeMember>({
|
|
161
|
+
method: 'POST', path: R.member, access: 'owner', params: { entitySlug, profileId }, body: update,
|
|
162
|
+
}),
|
|
163
|
+
remove: async (entitySlug, profileId) => {
|
|
164
|
+
await call<IamRuntimeAck>({
|
|
165
|
+
method: 'POST', path: R.memberRemove, access: 'owner', params: { entitySlug, profileId },
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
permissions: {
|
|
170
|
+
list: async entitySlug => (await call<IamRuntimePermissionList>({
|
|
171
|
+
method: 'GET', path: R.permissions, access: 'member', params: { entitySlug },
|
|
172
|
+
})).items,
|
|
173
|
+
},
|
|
174
|
+
grants: {
|
|
175
|
+
list: async (entitySlug, query) => (await call<IamRuntimeGrantList>({
|
|
176
|
+
method: 'GET', path: R.grants, access: 'owner', params: { entitySlug }, query,
|
|
177
|
+
})).items,
|
|
178
|
+
assign: async (entitySlug, grant) => call<IamRuntimeGrant>({
|
|
179
|
+
method: 'POST', path: R.grants, access: 'owner', params: { entitySlug }, body: grant,
|
|
180
|
+
}),
|
|
181
|
+
revoke: async (entitySlug, grant) => {
|
|
182
|
+
await call<IamRuntimeAck>({
|
|
183
|
+
method: 'POST', path: R.grantsRevoke, access: 'owner', params: { entitySlug }, body: grant,
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @deprecated compat:factory-refactor — use `makeIamRuntimeClient(context, request)` */
|
|
191
|
+
export const iamRuntime = <C extends Config, T extends Context<C>>(
|
|
192
|
+
context: T, request: AbstractRequest
|
|
193
|
+
): IamRuntimeClient => makeIamRuntimeClient<C, T>(context, request)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
2
|
+
import type { WithSharedConfig } from '@owlmeans/oidc'
|
|
3
|
+
|
|
4
|
+
export interface IamServerConfig extends ServerConfig, WithSharedConfig {}
|
|
5
|
+
|
|
6
|
+
export type IamServerContext<C extends IamServerConfig = IamServerConfig> = ServerContext<C>
|
|
7
|
+
|
|
8
|
+
/** Who a route admits — and therefore which refusal a bare 403 from it means. */
|
|
9
|
+
export type Access = 'subject' | 'member' | 'owner'
|
|
10
|
+
|
|
11
|
+
export interface Call {
|
|
12
|
+
method: 'GET' | 'POST'
|
|
13
|
+
path: string
|
|
14
|
+
access: Access
|
|
15
|
+
params?: Record<string, string>
|
|
16
|
+
query?: object
|
|
17
|
+
body?: object
|
|
18
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IamRuntimeGrant, IamRuntimeGrantQuery, IamRuntimeGrantRequest, IamRuntimeMember, IamRuntimeMemberInvite, IamRuntimeMemberUpdate, IamRuntimeOrganization, IamRuntimeOrganizationCreate, IamRuntimeOrganizationUpdate, IamRuntimePermission } from '@owlmeans/iam'
|
|
2
|
+
|
|
3
|
+
export interface IamGateOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Refuse a resource-scoped param on the UMA2 fallback path instead of widening it to a
|
|
6
|
+
* project-wide check.
|
|
7
|
+
*
|
|
8
|
+
* Off by default, and deliberately so. A Keycloak-backed deployment cannot hold a resource-scoped
|
|
9
|
+
* grant at all — the adapter throws `IamUnsupported('resource-scoped-grant')` — so denying would
|
|
10
|
+
* lock every user out of every scoped endpoint rather than tightening anything. Turn it on only
|
|
11
|
+
* where the resulting refusals are the intended outcome.
|
|
12
|
+
*/
|
|
13
|
+
strictResourceScope?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The runtime IAM API as the signed-in subject of one request: its organizations, their members,
|
|
18
|
+
* the grantable definitions and the grants. Every call acts as that subject — the provider decides
|
|
19
|
+
* owner and member rights from the access token, never from anything sent here.
|
|
20
|
+
*/
|
|
21
|
+
export interface IamRuntimeClient {
|
|
22
|
+
organizations: {
|
|
23
|
+
list: () => Promise<IamRuntimeOrganization[]>
|
|
24
|
+
/** The subject becomes the new organization's owner. */
|
|
25
|
+
create: (body?: IamRuntimeOrganizationCreate) => Promise<IamRuntimeOrganization>
|
|
26
|
+
update: (entitySlug: string, body: IamRuntimeOrganizationUpdate) => Promise<IamRuntimeOrganization>
|
|
27
|
+
}
|
|
28
|
+
members: {
|
|
29
|
+
list: (entitySlug: string) => Promise<IamRuntimeMember[]>
|
|
30
|
+
/** Find-or-create by e-mail: adding an address twice answers the same member. */
|
|
31
|
+
add: (entitySlug: string, invite: IamRuntimeMemberInvite) => Promise<IamRuntimeMember>
|
|
32
|
+
update: (entitySlug: string, profileId: string, update: IamRuntimeMemberUpdate) => Promise<IamRuntimeMember>
|
|
33
|
+
remove: (entitySlug: string, profileId: string) => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
permissions: {
|
|
36
|
+
list: (entitySlug: string) => Promise<IamRuntimePermission[]>
|
|
37
|
+
}
|
|
38
|
+
grants: {
|
|
39
|
+
list: (entitySlug: string, query?: IamRuntimeGrantQuery) => Promise<IamRuntimeGrant[]>
|
|
40
|
+
assign: (entitySlug: string, grant: IamRuntimeGrantRequest) => Promise<IamRuntimeGrant>
|
|
41
|
+
revoke: (entitySlug: string, grant: IamRuntimeGrantRequest) => Promise<void>
|
|
42
|
+
}
|
|
43
|
+
}
|