@owlmeans/server-iam 0.1.18-rc.13 → 0.1.18-rc.15

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-iam",
4
- "version": "0.1.18-rc.12",
5
- "generatedAt": "2026-08-24T21:14:00.106Z",
4
+ "version": "0.1.18-rc.13",
5
+ "generatedAt": "2026-08-27T16:32:18.467Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
package/build/gate.d.ts CHANGED
@@ -1,18 +1,23 @@
1
1
  import type { GateService } from '@owlmeans/entrypoint';
2
2
  /**
3
- * Gate-param syntax:
4
- * <permission> — unscoped (project-wide) check, e.g. 'article--modify'
5
- * <permission>@<requestParam> — resource-scoped: the resource id is resolved from
6
- * req.params[requestParam] ?? req.query[requestParam]
7
- * and must be listed in the grant's resources[]
8
- * '{entity}' substitution applies to the permission part in both forms.
3
+ * The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
4
+ * tooling can all read it. Re-exported here because this module was its home and is the documented
5
+ * import path.
9
6
  */
10
- export declare const RESOURCE_PARAM_SEPARATOR = "@";
11
- export interface ParsedGateParam {
12
- permission: string;
13
- resourceParam?: string;
7
+ export { RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR, GateParamSource, GateParamErrorCode, GateResolutionFailure, parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams } from '@owlmeans/iam';
8
+ export type { ParsedGateParam, GateResourceSelector, GateParamAudit } from '@owlmeans/iam';
9
+ export interface IamGateOptions {
10
+ /**
11
+ * Refuse a resource-scoped param on the UMA2 fallback path instead of widening it to a
12
+ * project-wide check.
13
+ *
14
+ * Off by default, and deliberately so. A Keycloak-backed deployment cannot hold a resource-scoped
15
+ * grant at all — the adapter throws `IamUnsupported('resource-scoped-grant')` — so denying would
16
+ * lock every user out of every scoped endpoint rather than tightening anything. Turn it on only
17
+ * where the resulting refusals are the intended outcome.
18
+ */
19
+ strictResourceScope?: boolean;
14
20
  }
15
- export declare const parseGateParam: (param: string) => ParsedGateParam;
16
21
  /**
17
22
  * IAM gate: claims-first with UMA2 fallback. Registered under the OIDC_GATE alias by
18
23
  * appendIam() so target code never knows which IAM backend is active.
@@ -22,6 +27,10 @@ export declare const parseGateParam: (param: string) => ParsedGateParam;
22
27
  * 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
23
28
  * @-suffixes are stripped and the check delegates to the UMA2 gate model from
24
29
  * @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
30
+ *
31
+ * Params are OR'd: holding any one of them admits the request. That is why a malformed or
32
+ * unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
33
+ * have passed must not be refused because of it.
25
34
  */
26
- export declare const makeIamGate: (alias?: string) => GateService;
35
+ export declare const makeIamGate: (alias?: string, opts?: IamGateOptions) => GateService;
27
36
  //# sourceMappingURL=gate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAmB,MAAM,sBAAsB,CAAA;AAQxE;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAE3C,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,eAAO,MAAM,cAAc,UAAW,MAAM,KAAG,eAO9C,CAAA;AAUD;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,WAAW,MAAM,KAAe,WAgDvD,CAAA"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAqC,MAAM,sBAAsB,CAAA;AAW1F;;;;GAIG;AACH,OAAO,EACL,wBAAwB,EAAE,yBAAyB,EAAE,uBAAuB,EAC5E,eAAe,EAAE,kBAAkB,EAAE,qBAAqB,EAC1D,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAAE,mBAAmB,EAAE,kBAAkB,EAC5F,MAAM,eAAe,CAAA;AACtB,YAAY,EAAE,eAAe,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAE1F,MAAM,WAAW,cAAc;IAC7B;;;;;;;;OAQG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AA2DD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,WAAW,WAAW,MAAM,SAAqB,cAAc,KAAG,WAyF9E,CAAA"}
package/build/gate.js CHANGED
@@ -2,28 +2,61 @@ import { createLazyService } from '@owlmeans/context';
2
2
  import { OIDC_GATE } from '@owlmeans/oidc';
3
3
  import { AuthForbidden } from '@owlmeans/auth';
4
4
  import { createGateModel, extractPermissionSets } from '@owlmeans/server-oidc-rp';
5
- import { hasPermission } from '@owlmeans/iam';
5
+ import { hasPermission, parseGateParam, resolveGateResource, validateGateParams, GateResolutionFailure } from '@owlmeans/iam';
6
6
  /**
7
- * Gate-param syntax:
8
- * <permission> — unscoped (project-wide) check, e.g. 'article--modify'
9
- * <permission>@<requestParam> — resource-scoped: the resource id is resolved from
10
- * req.params[requestParam] ?? req.query[requestParam]
11
- * and must be listed in the grant's resources[]
12
- * '{entity}' substitution applies to the permission part in both forms.
7
+ * The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
8
+ * tooling can all read it. Re-exported here because this module was its home and is the documented
9
+ * import path.
13
10
  */
14
- export const RESOURCE_PARAM_SEPARATOR = '@';
15
- export const parseGateParam = (param) => {
16
- const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR);
17
- if (idx < 0) {
18
- return { permission: param };
11
+ export { RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR, GateParamSource, GateParamErrorCode, GateResolutionFailure, parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams } from '@owlmeans/iam';
12
+ /**
13
+ * Structural faults are logged once per alias+param+reason.
14
+ *
15
+ * A gate on a hot endpoint runs on every request, and a misconfiguration is by definition permanent,
16
+ * so an un-deduplicated log turns one typo into the incident.
17
+ */
18
+ const reported = new Set();
19
+ const reportOnce = (key, message) => {
20
+ if (reported.has(key)) {
21
+ return;
19
22
  }
20
- return { permission: param.slice(0, idx), resourceParam: param.slice(idx + 1) };
23
+ reported.add(key);
24
+ console.error(message);
21
25
  };
22
- const resolveResourceId = (req, resourceParam) => {
23
- const params = req.params;
24
- const query = req.query;
25
- const value = params?.[resourceParam] ?? query?.[resourceParam];
26
- return value != null ? `${value}` : undefined;
26
+ /** Aliases whose declarations have already been checked. */
27
+ const audited = new Set();
28
+ /**
29
+ * Check an entrypoint's gate params against what it declares, once, the first time it is reached.
30
+ *
31
+ * Structurally unable to affect the outcome: it only ever logs. A selector naming a key the
32
+ * entrypoint's filter does not declare is stripped by validation before the gate runs, so the
33
+ * endpoint denies every request with a clean build and nothing else to go on — this is the line that
34
+ * says why.
35
+ */
36
+ const auditEntrypoint = (ctx, req, params) => {
37
+ if (req.alias == null || audited.has(req.alias)) {
38
+ return;
39
+ }
40
+ audited.add(req.alias);
41
+ try {
42
+ const entrypoint = ctx.entrypoint(req.alias);
43
+ if (entrypoint == null) {
44
+ return;
45
+ }
46
+ const audit = {
47
+ ...(typeof entrypoint.getPath === 'function'
48
+ ? { routePath: entrypoint.getPath() }
49
+ : {}),
50
+ ...(entrypoint.filter != null ? { filter: entrypoint.filter } : {})
51
+ };
52
+ for (const issue of validateGateParams(params, audit)) {
53
+ console.error(`Gate param "${issue.param}" on entrypoint "${req.alias}" is misconfigured`
54
+ + ` [${issue.code}]: ${issue.detail}`);
55
+ }
56
+ }
57
+ catch {
58
+ // An entrypoint that cannot be resolved is not this gate's problem to report.
59
+ }
27
60
  };
28
61
  /**
29
62
  * IAM gate: claims-first with UMA2 fallback. Registered under the OIDC_GATE alias by
@@ -34,8 +67,12 @@ const resolveResourceId = (req, resourceParam) => {
34
67
  * 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
35
68
  * @-suffixes are stripped and the check delegates to the UMA2 gate model from
36
69
  * @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
70
+ *
71
+ * Params are OR'd: holding any one of them admits the request. That is why a malformed or
72
+ * unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
73
+ * have passed must not be refused because of it.
37
74
  */
38
- export const makeIamGate = (alias = OIDC_GATE) => {
75
+ export const makeIamGate = (alias = OIDC_GATE, opts) => {
39
76
  const service = createLazyService(alias, {
40
77
  assert: async (req, _, params) => {
41
78
  await service.ready();
@@ -46,24 +83,50 @@ export const makeIamGate = (alias = OIDC_GATE) => {
46
83
  const auth = req.auth;
47
84
  const sets = extractPermissionSets(auth.permissions);
48
85
  if (sets != null) {
86
+ auditEntrypoint(ctx, req, params);
87
+ // Why a param failed, kept aside so a denial can say whether the gate was misconfigured or
88
+ // the subject simply lacks the grant. The two look identical from the outside and are
89
+ // repaired in completely different places.
90
+ const structural = [];
49
91
  const granted = params.some(param => {
50
- const { permission, resourceParam } = parseGateParam(param);
92
+ const { permission, resource, error } = parseGateParam(param);
51
93
  const fixed = permission.replaceAll('{entity}', auth.entityId ?? '-');
52
- if (resourceParam != null) {
53
- const resourceId = resolveResourceId(req, resourceParam);
54
- if (resourceId == null) {
94
+ if (error != null) {
95
+ structural.push(`"${param}" does not parse [${error.code}]: ${error.detail}`);
96
+ return false;
97
+ }
98
+ if (resource != null) {
99
+ const resolution = resolveGateResource(req, resource);
100
+ if (resolution.id == null) {
101
+ if (resolution.reason !== GateResolutionFailure.NotProvided) {
102
+ structural.push(`"${param}" resolved no resource id [${resolution.reason}]`
103
+ + ` from ${resource.sources.join(', ')}`);
104
+ }
55
105
  return false;
56
106
  }
57
- return hasPermission(auth, fixed, { resourceId });
107
+ return hasPermission(auth, fixed, { resourceId: resolution.id });
58
108
  }
59
109
  return hasPermission(auth, fixed);
60
110
  });
61
111
  if (!granted) {
112
+ structural.forEach(detail => reportOnce(`${req.alias}\0${detail}`, `Gate on entrypoint "${req.alias}" refused a request it could not evaluate: ${detail}`));
62
113
  throw new AuthForbidden('permission');
63
114
  }
64
115
  return;
65
116
  }
66
- const stripped = params.map(param => parseGateParam(param).permission);
117
+ // UMA2 fallback. Resource scoping is NOT enforced here: a Keycloak-backed deployment cannot
118
+ // store a resource-scoped grant, so a scoped param widens to a project-wide check and the same
119
+ // declaration means two different things depending on which backend is active.
120
+ const scoped = params.filter(param => parseGateParam(param).resource != null);
121
+ scoped.forEach(param => reportOnce(`uma2\0${req.alias}\0${param}`, `Gate param "${param}" on entrypoint "${req.alias}" is resource-scoped, but resource scoping`
122
+ + ' is not enforced in UMA2 mode — it is checked as a project-wide permission'));
123
+ const usable = opts?.strictResourceScope === true
124
+ ? params.filter(param => parseGateParam(param).resource == null)
125
+ : params;
126
+ if (usable.length < 1) {
127
+ throw new AuthForbidden('permission');
128
+ }
129
+ const stripped = usable.map(param => parseGateParam(param).permission);
67
130
  const model = createGateModel(ctx);
68
131
  const permissions = await model.loadPermissions(auth, stripped);
69
132
  if (permissions.length < 1) {
package/build/gate.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAA;AAEjF,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAE7C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAA;AAO3C,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,KAAa,EAAmB,EAAE;IAC/D,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,wBAAwB,CAAC,CAAA;IACnD,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;QACZ,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,CAAA;IAC9B,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,CAAA;AACjF,CAAC,CAAA;AAED,MAAM,iBAAiB,GAAG,CAAC,GAAoB,EAAE,aAAqB,EAAsB,EAAE;IAC5F,MAAM,MAAM,GAAG,GAAG,CAAC,MAA6C,CAAA;IAChE,MAAM,KAAK,GAAG,GAAG,CAAC,KAA4C,CAAA;IAC9D,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,aAAa,CAAC,IAAI,KAAK,EAAE,CAAC,aAAa,CAAC,CAAA;IAE/D,OAAO,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;AAC/C,CAAC,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAK,GAAW,SAAS,EAAe,EAAE;IACpE,MAAM,OAAO,GAAgB,iBAAiB,CAAc,KAAK,EAAE;QACjE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE;YAC/B,MAAM,OAAO,CAAC,KAAK,EAAE,CAAA;YACrB,MAAM,GAAG,GAAG,OAAO,CAAC,SAAS,EAAmB,CAAA;YAEhD,IAAI,GAAG,CAAC,IAAI,IAAI,IAAI,EAAE,CAAC;gBACrB,MAAM,IAAI,aAAa,CAAC,MAAM,CAAC,CAAA;YACjC,CAAC;YAED,MAAM,IAAI,GAAG,GAAG,CAAC,IAAY,CAAA;YAC7B,MAAM,IAAI,GAAG,qBAAqB,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;YAEpD,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;gBACjB,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE;oBAClC,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,GAAG,cAAc,CAAC,KAAK,CAAC,CAAA;oBAC3D,MAAM,KAAK,GAAG,UAAU,CAAC,UAAU,CAAC,UAAU,EAAE,IAAI,CAAC,QAAQ,IAAI,GAAG,CAAC,CAAA;oBAErE,IAAI,aAAa,IAAI,IAAI,EAAE,CAAC;wBAC1B,MAAM,UAAU,GAAG,iBAAiB,CAAC,GAAG,EAAE,aAAa,CAAC,CAAA;wBACxD,IAAI,UAAU,IAAI,IAAI,EAAE,CAAC;4BACvB,OAAO,KAAK,CAAA;wBACd,CAAC;wBAED,OAAO,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC,CAAA;oBACnD,CAAC;oBAED,OAAO,aAAa,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;gBACnC,CAAC,CAAC,CAAA;gBAEF,IAAI,CAAC,OAAO,EAAE,CAAC;oBACb,MAAM,IAAI,aAAa,CAAC,YAAY,CAAC,CAAA;gBACvC,CAAC;gBAED,OAAM;YACR,CAAC;YAED,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAA;YACtE,MAAM,KAAK,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;YAClC,MAAM,WAAW,GAAG,MAAM,KAAK,CAAC,eAAe,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;YAE/D,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC3B,MAAM,IAAI,aAAa,CAAC,YAAY,CAAC,CAAA;YACvC,CAAC;QACH,CAAC;KACF,CAAC,CAAA;IAEF,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA"}
1
+ {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-iam",
3
- "version": "0.1.18-rc.13",
3
+ "version": "0.1.18-rc.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -25,7 +25,7 @@
25
25
  "@owlmeans/auth": "^0.1.18-rc.6",
26
26
  "@owlmeans/context": "^0.1.18-rc.6",
27
27
  "@owlmeans/entrypoint": "^0.1.18-rc.7",
28
- "@owlmeans/iam": "^0.1.18-rc.9",
28
+ "@owlmeans/iam": "^0.1.18-rc.11",
29
29
  "@owlmeans/oidc": "^0.1.18-rc.8",
30
30
  "@owlmeans/server-context": "^0.1.18-rc.7",
31
31
  "@owlmeans/server-oidc-rp": "^0.1.18-rc.12"
package/src/gate.ts CHANGED
@@ -1,42 +1,95 @@
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
3
  import { OIDC_GATE } from '@owlmeans/oidc'
4
4
  import { AuthForbidden } from '@owlmeans/auth'
5
5
  import type { Auth } from '@owlmeans/auth'
6
6
  import { createGateModel, extractPermissionSets } from '@owlmeans/server-oidc-rp'
7
7
  import type { Config, Context } from '@owlmeans/server-oidc-rp'
8
- import { hasPermission } from '@owlmeans/iam'
8
+ import {
9
+ hasPermission, parseGateParam, resolveGateResource, validateGateParams, GateResolutionFailure
10
+ } from '@owlmeans/iam'
11
+ import type { GateParamAudit } from '@owlmeans/iam'
9
12
 
10
13
  /**
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.
14
+ * The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
15
+ * tooling can all read it. Re-exported here because this module was its home and is the documented
16
+ * import path.
17
17
  */
18
- export const RESOURCE_PARAM_SEPARATOR = '@'
18
+ export {
19
+ RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR,
20
+ GateParamSource, GateParamErrorCode, GateResolutionFailure,
21
+ parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams
22
+ } from '@owlmeans/iam'
23
+ export type { ParsedGateParam, GateResourceSelector, GateParamAudit } from '@owlmeans/iam'
24
+
25
+ export interface IamGateOptions {
26
+ /**
27
+ * Refuse a resource-scoped param on the UMA2 fallback path instead of widening it to a
28
+ * project-wide check.
29
+ *
30
+ * Off by default, and deliberately so. A Keycloak-backed deployment cannot hold a resource-scoped
31
+ * grant at all — the adapter throws `IamUnsupported('resource-scoped-grant')` — so denying would
32
+ * lock every user out of every scoped endpoint rather than tightening anything. Turn it on only
33
+ * where the resulting refusals are the intended outcome.
34
+ */
35
+ strictResourceScope?: boolean
36
+ }
37
+
38
+ /**
39
+ * Structural faults are logged once per alias+param+reason.
40
+ *
41
+ * A gate on a hot endpoint runs on every request, and a misconfiguration is by definition permanent,
42
+ * so an un-deduplicated log turns one typo into the incident.
43
+ */
44
+ const reported = new Set<string>()
19
45
 
20
- export interface ParsedGateParam {
21
- permission: string
22
- resourceParam?: string
46
+ const reportOnce = (key: string, message: string): void => {
47
+ if (reported.has(key)) {
48
+ return
49
+ }
50
+ reported.add(key)
51
+ console.error(message)
23
52
  }
24
53
 
25
- export const parseGateParam = (param: string): ParsedGateParam => {
26
- const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR)
27
- if (idx < 0) {
28
- return { permission: param }
54
+ /** Aliases whose declarations have already been checked. */
55
+ const audited = new Set<string>()
56
+
57
+ /**
58
+ * Check an entrypoint's gate params against what it declares, once, the first time it is reached.
59
+ *
60
+ * Structurally unable to affect the outcome: it only ever logs. A selector naming a key the
61
+ * entrypoint's filter does not declare is stripped by validation before the gate runs, so the
62
+ * endpoint denies every request with a clean build and nothing else to go on — this is the line that
63
+ * says why.
64
+ */
65
+ const auditEntrypoint = (ctx: Context, req: AbstractRequest, params: string[]): void => {
66
+ if (req.alias == null || audited.has(req.alias)) {
67
+ return
29
68
  }
69
+ audited.add(req.alias)
30
70
 
31
- return { permission: param.slice(0, idx), resourceParam: param.slice(idx + 1) }
32
- }
71
+ try {
72
+ const entrypoint = ctx.entrypoint<CommonEntrypoint>(req.alias)
73
+ if (entrypoint == null) {
74
+ return
75
+ }
33
76
 
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]
77
+ const audit: GateParamAudit = {
78
+ ...(typeof (entrypoint as any).getPath === 'function'
79
+ ? { routePath: (entrypoint as any).getPath() as string }
80
+ : {}),
81
+ ...(entrypoint.filter != null ? { filter: entrypoint.filter as GateParamAudit['filter'] } : {})
82
+ }
38
83
 
39
- return value != null ? `${value}` : undefined
84
+ for (const issue of validateGateParams(params, audit)) {
85
+ console.error(
86
+ `Gate param "${issue.param}" on entrypoint "${req.alias}" is misconfigured`
87
+ + ` [${issue.code}]: ${issue.detail}`
88
+ )
89
+ }
90
+ } catch {
91
+ // An entrypoint that cannot be resolved is not this gate's problem to report.
92
+ }
40
93
  }
41
94
 
42
95
  /**
@@ -48,8 +101,12 @@ const resolveResourceId = (req: AbstractRequest, resourceParam: string): string
48
101
  * 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
49
102
  * @-suffixes are stripped and the check delegates to the UMA2 gate model from
50
103
  * @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
104
+ *
105
+ * Params are OR'd: holding any one of them admits the request. That is why a malformed or
106
+ * unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
107
+ * have passed must not be refused because of it.
51
108
  */
52
- export const makeIamGate = (alias: string = OIDC_GATE): GateService => {
109
+ export const makeIamGate = (alias: string = OIDC_GATE, opts?: IamGateOptions): GateService => {
53
110
  const service: GateService = createLazyService<GateService>(alias, {
54
111
  assert: async (req, _, params) => {
55
112
  await service.ready()
@@ -63,30 +120,71 @@ export const makeIamGate = (alias: string = OIDC_GATE): GateService => {
63
120
  const sets = extractPermissionSets(auth.permissions)
64
121
 
65
122
  if (sets != null) {
123
+ auditEntrypoint(ctx, req, params)
124
+
125
+ // Why a param failed, kept aside so a denial can say whether the gate was misconfigured or
126
+ // the subject simply lacks the grant. The two look identical from the outside and are
127
+ // repaired in completely different places.
128
+ const structural: string[] = []
129
+
66
130
  const granted = params.some(param => {
67
- const { permission, resourceParam } = parseGateParam(param)
131
+ const { permission, resource, error } = parseGateParam(param)
68
132
  const fixed = permission.replaceAll('{entity}', auth.entityId ?? '-')
69
133
 
70
- if (resourceParam != null) {
71
- const resourceId = resolveResourceId(req, resourceParam)
72
- if (resourceId == null) {
134
+ if (error != null) {
135
+ structural.push(`"${param}" does not parse [${error.code}]: ${error.detail}`)
136
+ return false
137
+ }
138
+
139
+ if (resource != null) {
140
+ const resolution = resolveGateResource(req, resource)
141
+ if (resolution.id == null) {
142
+ if (resolution.reason !== GateResolutionFailure.NotProvided) {
143
+ structural.push(
144
+ `"${param}" resolved no resource id [${resolution.reason}]`
145
+ + ` from ${resource.sources.join(', ')}`
146
+ )
147
+ }
73
148
  return false
74
149
  }
75
150
 
76
- return hasPermission(auth, fixed, { resourceId })
151
+ return hasPermission(auth, fixed, { resourceId: resolution.id })
77
152
  }
78
153
 
79
154
  return hasPermission(auth, fixed)
80
155
  })
81
156
 
82
157
  if (!granted) {
158
+ structural.forEach(detail => reportOnce(
159
+ `${req.alias}\0${detail}`,
160
+ `Gate on entrypoint "${req.alias}" refused a request it could not evaluate: ${detail}`
161
+ ))
162
+
83
163
  throw new AuthForbidden('permission')
84
164
  }
85
165
 
86
166
  return
87
167
  }
88
168
 
89
- const stripped = params.map(param => parseGateParam(param).permission)
169
+ // UMA2 fallback. Resource scoping is NOT enforced here: a Keycloak-backed deployment cannot
170
+ // store a resource-scoped grant, so a scoped param widens to a project-wide check and the same
171
+ // declaration means two different things depending on which backend is active.
172
+ const scoped = params.filter(param => parseGateParam(param).resource != null)
173
+ scoped.forEach(param => reportOnce(
174
+ `uma2\0${req.alias}\0${param}`,
175
+ `Gate param "${param}" on entrypoint "${req.alias}" is resource-scoped, but resource scoping`
176
+ + ' is not enforced in UMA2 mode — it is checked as a project-wide permission'
177
+ ))
178
+
179
+ const usable = opts?.strictResourceScope === true
180
+ ? params.filter(param => parseGateParam(param).resource == null)
181
+ : params
182
+
183
+ if (usable.length < 1) {
184
+ throw new AuthForbidden('permission')
185
+ }
186
+
187
+ const stripped = usable.map(param => parseGateParam(param).permission)
90
188
  const model = createGateModel(ctx)
91
189
  const permissions = await model.loadPermissions(auth, stripped)
92
190
 
@@ -21,12 +21,16 @@ const makeGate = async (): Promise<GateService> => {
21
21
  return ctx.service<GateService>(OIDC_GATE)
22
22
  }
23
23
 
24
- const makeRequest = (params: Record<string, string> = {}): AbstractRequest => ({
24
+ const makeRequest = (
25
+ params: Record<string, string> = {},
26
+ rest: Partial<Pick<AbstractRequest, 'query' | 'body' | 'headers'>> = {}
27
+ ): AbstractRequest => ({
25
28
  alias: 'test',
26
29
  headers: {},
27
30
  params,
28
31
  query: {},
29
32
  body: {},
33
+ ...rest,
30
34
  path: '/',
31
35
  auth: {
32
36
  type: 'oidc-wrapped-token',
@@ -72,4 +76,53 @@ describe('@owlmeans/server-iam — makeIamGate (claims mode)', () => {
72
76
  gate.assert(makeRequest({ depId: 'dep-87654321' }), res, ['department--modify@depId'])
73
77
  ).rejects.toBeInstanceOf(AuthForbidden)
74
78
  })
79
+
80
+ test('resolves a resource id from an explicitly named source', async () => {
81
+ const gate = await makeGate()
82
+ await gate.assert(
83
+ makeRequest({}, { body: { department: { id: 'dep-12345678' } } }), res,
84
+ ['department--modify@body:department.id']
85
+ )
86
+ })
87
+
88
+ test('resolves a resource id from the auth object', async () => {
89
+ const gate = await makeGate()
90
+ // `entity-1` is this subject's own entityId, and the unscoped grant covers every resource.
91
+ await gate.assert(makeRequest(), res, ['article--modify@auth:entityId'])
92
+ })
93
+
94
+ /**
95
+ * Params are OR'd, so a param the gate cannot even evaluate must contribute nothing. Letting one
96
+ * throw would turn a typo in an unrelated sibling into a denial of a grant the subject really has.
97
+ */
98
+ test('a malformed param does not refuse a sibling that passes', async () => {
99
+ const gate = await makeGate()
100
+ await gate.assert(makeRequest(), res, ['article--modify@cookies:sid', 'article--modify'])
101
+ })
102
+
103
+ test('an unresolvable selector refuses without breaking the OR', async () => {
104
+ const gate = await makeGate()
105
+ await gate.assert(makeRequest(), res, ['department--modify@missingParam', 'article--modify'])
106
+
107
+ expect(
108
+ gate.assert(makeRequest(), res, ['department--modify@missingParam'])
109
+ ).rejects.toBeInstanceOf(AuthForbidden)
110
+ })
111
+
112
+ /**
113
+ * The stored grant is keyed `department--modify`; the gate splits the selector off before looking
114
+ * it up. A grant written under the whole string — the corruption this grammar exists to stop —
115
+ * is a key nothing reads.
116
+ */
117
+ test('a permission granted WITH the selector in its name never satisfies the gate', async () => {
118
+ const gate = await makeGate()
119
+ const req = makeRequest({ depId: 'dep-1' })
120
+ ;(req.auth as unknown as Auth).permissions = [
121
+ { scope: 'my-project', permissions: { 'department--modify@depId': true } }
122
+ ]
123
+
124
+ expect(
125
+ gate.assert(req, res, ['department--modify@depId'])
126
+ ).rejects.toBeInstanceOf(AuthForbidden)
127
+ })
75
128
  })