@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.
Files changed (53) hide show
  1. package/README.md +13 -4
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-iam/SKILL.md +142 -16
  4. package/build/append.d.ts +1 -5
  5. package/build/append.d.ts.map +1 -1
  6. package/build/append.js.map +1 -1
  7. package/build/consts.local.d.ts +3 -0
  8. package/build/consts.local.d.ts.map +1 -0
  9. package/build/consts.local.js +3 -0
  10. package/build/consts.local.js.map +1 -0
  11. package/build/gate.d.ts +13 -14
  12. package/build/gate.d.ts.map +1 -1
  13. package/build/gate.js +128 -35
  14. package/build/gate.js.map +1 -1
  15. package/build/index.d.ts +6 -0
  16. package/build/index.d.ts.map +1 -1
  17. package/build/index.js +3 -0
  18. package/build/index.js.map +1 -1
  19. package/build/organization/types.d.ts +21 -0
  20. package/build/organization/types.d.ts.map +1 -0
  21. package/build/organization/types.js +2 -0
  22. package/build/organization/types.js.map +1 -0
  23. package/build/organization.d.ts +9 -0
  24. package/build/organization.d.ts.map +1 -0
  25. package/build/organization.js +30 -0
  26. package/build/organization.js.map +1 -0
  27. package/build/runtime.d.ts +15 -0
  28. package/build/runtime.d.ts.map +1 -0
  29. package/build/runtime.js +167 -0
  30. package/build/runtime.js.map +1 -0
  31. package/build/types.d.ts +42 -0
  32. package/build/types.d.ts.map +1 -0
  33. package/build/types.js +2 -0
  34. package/build/types.js.map +1 -0
  35. package/build/types.local.d.ts +16 -0
  36. package/build/types.local.d.ts.map +1 -0
  37. package/build/types.local.js +2 -0
  38. package/build/types.local.js.map +1 -0
  39. package/package.json +12 -10
  40. package/src/append.ts +1 -4
  41. package/src/consts.local.ts +2 -0
  42. package/src/gate.ts +157 -45
  43. package/src/index.ts +6 -0
  44. package/src/organization/types.ts +21 -0
  45. package/src/organization.ts +49 -0
  46. package/src/runtime.ts +193 -0
  47. package/src/types.local.ts +18 -0
  48. package/src/types.ts +43 -0
  49. package/tests/context.ts +100 -0
  50. package/tests/gate.spec.ts +132 -4
  51. package/tests/organization.spec.ts +52 -0
  52. package/tests/runtime.spec.ts +169 -0
  53. package/tests/tsconfig.json +12 -0
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 { AuthForbidden } from '@owlmeans/auth'
5
- import type { Auth } from '@owlmeans/auth'
6
- import { createGateModel, extractPermissionSets } from '@owlmeans/server-oidc-rp'
7
- import type { Config, Context } from '@owlmeans/server-oidc-rp'
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
- * Gate-param syntax:
12
- * <permission> — unscoped (project-wide) check, e.g. 'article--modify'
13
- * <permission>@<requestParam> — resource-scoped: the resource id is resolved from
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 const RESOURCE_PARAM_SEPARATOR = '@'
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
- export interface ParsedGateParam {
21
- permission: string
22
- resourceParam?: string
23
- }
24
-
25
- export const parseGateParam = (param: string): ParsedGateParam => {
26
- const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR)
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 resolveResourceId = (req: AbstractRequest, resourceParam: string): string | undefined => {
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
- return value != null ? `${value}` : undefined
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 new AuthForbidden('auth')
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, resourceParam } = parseGateParam(param)
68
- const fixed = permission.replaceAll('{entity}', auth.entityId ?? '-')
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 (resourceParam != null) {
71
- const resourceId = resolveResourceId(req, resourceParam)
72
- if (resourceId == null) {
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
- throw new AuthForbidden('permission')
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
- const stripped = params.map(param => parseGateParam(param).permission)
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 new AuthForbidden('permission')
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
+ }