@owlmeans/server-iam 0.1.18-rc.2 → 0.1.18-rc.20

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 CHANGED
@@ -20,12 +20,15 @@ bun add @owlmeans/server-iam
20
20
  ```typescript
21
21
  import { appendIam, makeIamGate } from '@owlmeans/server-iam'
22
22
  import { IAM_GATE } from '@owlmeans/server-iam'
23
+ import { entrypoint, guard, route } from '@owlmeans/server-app'
24
+ import { gate } from '@owlmeans/entrypoint'
25
+ import { DEFAULT_GUARD } from '@owlmeans/auth-common'
23
26
 
24
27
  // Wire IAM into a server context
25
28
  appendIam(context)
26
29
 
27
- // Gate a module with a permission check
28
- module.gate(IAM_GATE, { permission: 'manage-users' })
30
+ // Gate an entrypoint with a permission check
31
+ entrypoint(route('users', '/users'), guard(DEFAULT_GUARD, gate(IAM_GATE, ['manage-users@{entity}'])))
29
32
  ```
30
33
 
31
34
  Requires `@owlmeans/oidc` OIDC shared config and a running IAM provider configured via `@owlmeans/server-oidc-rp`.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-iam",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.500Z",
4
+ "version": "0.1.18-rc.20",
5
+ "generatedAt": "2026-09-01T17:39:40.760Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -16,22 +16,58 @@ IAM gate; the consumer never knows which IAM backend (Keycloak or integrated) is
16
16
  | Symbol | Kind | Purpose |
17
17
  |--------|------|---------|
18
18
  | `appendIam(context)` | fn | Registers `makeOidcClientService()`, `makeOidcWrappingService()`, `makeIamGate()` (under `OIDC_GATE`), and the OIDC guard |
19
- | `makeIamGate(alias?)` | fn | The IAM `GateService` — claims-first assertion with UMA2 fallback |
20
- | `parseGateParam(param)` | fn | Splits `'perm@requestParam'` into `{ permission, resourceParam? }` |
21
- | `RESOURCE_PARAM_SEPARATOR` | const | `'@'` |
19
+ | `makeIamGate(alias?, opts?)` | fn | The IAM `GateService` — claims-first assertion with UMA2 fallback. `opts.strictResourceScope` refuses a scoped param on the fallback path instead of widening it |
20
+ | `parseGateParam` / `formatGateParam` / `resolveGateResource` / `validateGateParams` | fn | Re-exports from `@owlmeans/iam`, which owns the grammar |
21
+ | `RESOURCE_PARAM_SEPARATOR` | const | `'@'`, re-exported |
22
22
  | `hasPermission` | fn | Re-export from `@owlmeans/iam` |
23
23
 
24
+ The grammar lives in **`@owlmeans/iam`**, not here — the browser, both IAM adapters and
25
+ code-generation tooling all have to read the same rules. This package re-exports it because this was
26
+ its original home.
27
+
24
28
  ## Gate-param syntax
25
29
 
26
30
  ```
27
- <permission> → unscoped (project-wide): 'article--modify'
28
- <permission>@<requestParam> → resource-scoped: 'department--modify@departmentId'
31
+ <permission> → unscoped (project-wide): 'article--modify'
32
+ <permission>@<bareKey> → bare form: 'department--modify@departmentId'
33
+ <permission>@<source>:<path> → qualified form: 'order--modify@body:order.id'
29
34
  ```
30
35
 
31
- - `{entity}` substitution applies to the permission part in both forms.
32
- - For the resource-scoped form the gate resolves `req.params[requestParam] ?? req.query[requestParam]`
33
- at assert time and requires the grant's `resources[]` to contain it (an unscoped grant always passes).
34
- - Params are **any-of**: one granted param is enough.
36
+ Sources: `params`, `query`, `body`, `headers`, `auth`.
37
+
38
+ - **The bare form is lenient and flat**; the qualified form is strict and nested. `@a.b` in the bare
39
+ form is the literal key `"a.b"` — a query key may legally contain a dot, and splitting it would
40
+ change what already-deployed selectors mean. Dotted paths exist only after a `source:`.
41
+ - The separator is `:` and not a dot on purpose. With a dotted-only form, a route that gains a param
42
+ named like a source keyword silently changes a selector's meaning: `/report/:query` with
43
+ `@query.id` flips from "the route param named `query.id`" to "the query string's `id`" — an
44
+ authorization lookup redirected to attacker-supplied input by an unrelated route rename.
45
+ - The bare form searches `params` then `query`. That query fallback is legacy and deliberately kept:
46
+ removing it would silently deny deployed apps whose selector names a query key.
47
+ - `headers:` lowercases its first segment and unwraps a single-element array; a repeated header is
48
+ refused rather than silently narrowed to one of its values.
49
+ - `auth:` is the only source that is neither attacker-controlled nor subject to `removeAdditional`.
50
+ Prefer it whenever the rule is "bound to their own profile or entity".
51
+ - A resolved id must satisfy the grant's `resources[]` — **and a grant carrying no `resources` at all
52
+ covers every id**. That is the all-resources form of the same permission, not a separate one.
53
+ - Params are **any-of**: one granted param is enough. Which is why a malformed or unresolvable param
54
+ can only contribute `false` and never throw — otherwise one bad sibling would refuse a request the
55
+ subject is legitimately entitled to.
56
+
57
+ **The `@` belongs to the gate and nowhere else.** It is never part of a permission's stored NAME. A
58
+ definition or grant written as `enquiry--view@enquiryId` is a key nothing looks up, so the permission
59
+ reads as granted in every screen and every request is still refused, with nothing logged.
60
+ `ensurePermission` refuses such a name; `validateGateParams` catches the mirror-image mistake at
61
+ generation time.
62
+
63
+ ### `validateGateParams(params, audit?)`
64
+
65
+ Reports what is provable from an entrypoint's own declaration: a selector naming a route param the
66
+ path does not carry, or a `body`/`query`/`headers` key the `Filter` does not declare **while that
67
+ schema sets `additionalProperties: false`**. That qualifier matters — the server's AJV runs
68
+ `removeAdditional`, which strips only what a schema closes, so without it the check would fire on
69
+ every unfiltered endpoint and be ignored within a week. It never decides a request; the gate runs it
70
+ once per alias and only logs.
35
71
 
36
72
  ```ts
37
73
  // module declaration
@@ -51,6 +87,18 @@ await ctx.service<GateService>(OIDC_GATE).assert(req, res, ['article--modify'])
51
87
  from `@owlmeans/server-oidc-rp` (`createGateModel().loadPermissions`) — byte-equivalent to the old
52
88
  `makeOidcGate`, which keeps Keycloak-backed deployments unchanged.
53
89
 
90
+ **Known limitation of `IAM_MODE=keycloak`:** resource scoping is *not enforced* on the fallback path,
91
+ so one declaration means two different things depending on the active backend. It cannot be fixed by
92
+ denying — `iam-keycloak` throws `IamUnsupported('resource-scoped-grant')`, so a scoped grant cannot
93
+ exist there and denying would lock every user out of every scoped endpoint. The gate logs each such
94
+ param once per alias; `makeIamGate(alias, { strictResourceScope: true })` opts into denial where the
95
+ refusals are the intended outcome.
96
+
97
+ A denial distinguishes its reasons in the log: a param that failed **structurally** (unparseable,
98
+ source absent, value not a scalar) is reported separately from one that resolved fine and simply was
99
+ not granted. The HTTP answer is identical — `AuthForbidden('permission')` either way — because the
100
+ caller must never learn which.
101
+
54
102
  ## Rules
55
103
 
56
104
  - Register the gate via `appendIam()`; do not also register `makeOidcGate()` — both use the
@@ -1 +1 @@
1
- {"version":3,"file":"append.d.ts","sourceRoot":"","sources":["../src/append.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC3E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAItD,KAAK,eAAe,GAAG,YAAY,GAAG,gBAAgB,CAAA;AACtD,KAAK,gBAAgB,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe,IAAI,aAAa,CAAC,CAAC,CAAC,CAAA;AAErF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,eAAe,EAAE,CAAC,SAAS,gBAAgB,CAAC,CAAC,CAAC,WACvE,CAAC,KACT,CAQF,CAAA"}
1
+ {"version":3,"file":"append.d.ts","sourceRoot":"","sources":["../src/append.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC3E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAItD,KAAK,eAAe,GAAG,YAAY,GAAG,gBAAgB,CAAA;AACtD,KAAK,gBAAgB,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe,IAAI,aAAa,CAAC,CAAC,CAAC,CAAA;AAErF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,eAAe,EAAE,CAAC,SAAS,gBAAgB,CAAC,CAAC,CAAC,EAChF,SAAS,CAAC,KACT,CAQF,CAAA"}
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;AAY1F;;;;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;AAiED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,WAAW,GAAI,QAAO,MAAkB,EAAE,OAAO,cAAc,KAAG,WA4F9E,CAAA"}
package/build/gate.js CHANGED
@@ -1,29 +1,69 @@
1
1
  import { createLazyService } from '@owlmeans/context';
2
+ import { entitySlugOf } from '@owlmeans/auth';
2
3
  import { OIDC_GATE } from '@owlmeans/oidc';
3
4
  import { AuthForbidden } from '@owlmeans/auth';
4
5
  import { createGateModel, extractPermissionSets } from '@owlmeans/server-oidc-rp';
5
- import { hasPermission } from '@owlmeans/iam';
6
+ import { hasPermission, parseGateParam, resolveGateResource, validateGateParams, GateResolutionFailure } from '@owlmeans/iam';
6
7
  /**
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.
8
+ * The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
9
+ * tooling can all read it. Re-exported here because this module was its home and is the documented
10
+ * import path.
13
11
  */
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 };
12
+ export { RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR, GateParamSource, GateParamErrorCode, GateResolutionFailure, parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams } from '@owlmeans/iam';
13
+ /**
14
+ * Structural faults are logged once per alias+param+reason.
15
+ *
16
+ * A gate on a hot endpoint runs on every request, and a misconfiguration is by definition permanent,
17
+ * so an un-deduplicated log turns one typo into the incident.
18
+ */
19
+ const reported = new Set();
20
+ const reportOnce = (key, message) => {
21
+ if (reported.has(key)) {
22
+ return;
19
23
  }
20
- return { permission: param.slice(0, idx), resourceParam: param.slice(idx + 1) };
24
+ reported.add(key);
25
+ console.error(message);
21
26
  };
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;
27
+ /** Aliases whose declarations have already been checked. */
28
+ const audited = new Set();
29
+ /**
30
+ * Check an entrypoint's gate params against what it declares, once, the first time it is reached.
31
+ *
32
+ * Structurally unable to affect the outcome: it only ever logs. A selector naming a key the
33
+ * entrypoint's filter does not declare is stripped by validation before the gate runs, so the
34
+ * endpoint denies every request with a clean build and nothing else to go on — this is the line that
35
+ * says why.
36
+ */
37
+ const auditEntrypoint = (ctx, req, params) => {
38
+ if (req.alias == null || audited.has(req.alias)) {
39
+ return;
40
+ }
41
+ audited.add(req.alias);
42
+ try {
43
+ const entrypoint = ctx.entrypoint(req.alias);
44
+ if (entrypoint == null) {
45
+ return;
46
+ }
47
+ let routePath;
48
+ try {
49
+ routePath = entrypoint.mount();
50
+ }
51
+ catch {
52
+ // The mount needs a service with an address; an entrypoint that has none still has a filter
53
+ // worth auditing.
54
+ }
55
+ const audit = {
56
+ ...(routePath != null ? { routePath } : {}),
57
+ ...(entrypoint.filter != null ? { filter: entrypoint.filter } : {})
58
+ };
59
+ for (const issue of validateGateParams(params, audit)) {
60
+ console.error(`Gate param "${issue.param}" on entrypoint "${req.alias}" is misconfigured`
61
+ + ` [${issue.code}]: ${issue.detail}`);
62
+ }
63
+ }
64
+ catch {
65
+ // An entrypoint that cannot be looked up is not this gate's problem to report.
66
+ }
27
67
  };
28
68
  /**
29
69
  * IAM gate: claims-first with UMA2 fallback. Registered under the OIDC_GATE alias by
@@ -34,8 +74,12 @@ const resolveResourceId = (req, resourceParam) => {
34
74
  * 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
35
75
  * @-suffixes are stripped and the check delegates to the UMA2 gate model from
36
76
  * @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
77
+ *
78
+ * Params are OR'd: holding any one of them admits the request. That is why a malformed or
79
+ * unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
80
+ * have passed must not be refused because of it.
37
81
  */
38
- export const makeIamGate = (alias = OIDC_GATE) => {
82
+ export const makeIamGate = (alias = OIDC_GATE, opts) => {
39
83
  const service = createLazyService(alias, {
40
84
  assert: async (req, _, params) => {
41
85
  await service.ready();
@@ -46,24 +90,53 @@ export const makeIamGate = (alias = OIDC_GATE) => {
46
90
  const auth = req.auth;
47
91
  const sets = extractPermissionSets(auth.permissions);
48
92
  if (sets != null) {
93
+ auditEntrypoint(ctx, req, params);
94
+ // Why a param failed, kept aside so a denial can say whether the gate was misconfigured or
95
+ // the subject simply lacks the grant. The two look identical from the outside and are
96
+ // repaired in completely different places.
97
+ const structural = [];
49
98
  const granted = params.some(param => {
50
- const { permission, resourceParam } = parseGateParam(param);
51
- const fixed = permission.replaceAll('{entity}', auth.entityId ?? '-');
52
- if (resourceParam != null) {
53
- const resourceId = resolveResourceId(req, resourceParam);
54
- if (resourceId == null) {
99
+ const { permission, resource, error } = parseGateParam(param);
100
+ // Grants are stored against the entity's stable id wherever one is resolvable; the
101
+ // slug is only a fallback for deployments with no organization store of their own,
102
+ // where the slug IS the identifier.
103
+ const fixed = permission.replaceAll('{entity}', req.entity?.id ?? entitySlugOf(auth) ?? '-');
104
+ if (error != null) {
105
+ structural.push(`"${param}" does not parse [${error.code}]: ${error.detail}`);
106
+ return false;
107
+ }
108
+ if (resource != null) {
109
+ const resolution = resolveGateResource(req, resource);
110
+ if (resolution.id == null) {
111
+ if (resolution.reason !== GateResolutionFailure.NotProvided) {
112
+ structural.push(`"${param}" resolved no resource id [${resolution.reason}]`
113
+ + ` from ${resource.sources.join(', ')}`);
114
+ }
55
115
  return false;
56
116
  }
57
- return hasPermission(auth, fixed, { resourceId });
117
+ return hasPermission(auth, fixed, { resourceId: resolution.id });
58
118
  }
59
119
  return hasPermission(auth, fixed);
60
120
  });
61
121
  if (!granted) {
122
+ structural.forEach(detail => reportOnce(`${req.alias}\0${detail}`, `Gate on entrypoint "${req.alias}" refused a request it could not evaluate: ${detail}`));
62
123
  throw new AuthForbidden('permission');
63
124
  }
64
125
  return;
65
126
  }
66
- const stripped = params.map(param => parseGateParam(param).permission);
127
+ // UMA2 fallback. Resource scoping is NOT enforced here: a Keycloak-backed deployment cannot
128
+ // store a resource-scoped grant, so a scoped param widens to a project-wide check and the same
129
+ // declaration means two different things depending on which backend is active.
130
+ const scoped = params.filter(param => parseGateParam(param).resource != null);
131
+ scoped.forEach(param => reportOnce(`uma2\0${req.alias}\0${param}`, `Gate param "${param}" on entrypoint "${req.alias}" is resource-scoped, but resource scoping`
132
+ + ' is not enforced in UMA2 mode — it is checked as a project-wide permission'));
133
+ const usable = opts?.strictResourceScope === true
134
+ ? params.filter(param => parseGateParam(param).resource == null)
135
+ : params;
136
+ if (usable.length < 1) {
137
+ throw new AuthForbidden('permission');
138
+ }
139
+ const stripped = usable.map(param => parseGateParam(param).permission);
67
140
  const model = createGateModel(ctx);
68
141
  const permissions = await model.loadPermissions(auth, stripped);
69
142
  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":"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"}
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.2",
3
+ "version": "0.1.18-rc.20",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,17 +22,17 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/auth": "^0.1.18-rc.2",
26
- "@owlmeans/context": "^0.1.18-rc.2",
27
- "@owlmeans/entrypoint": "^0.1.18-rc.2",
28
- "@owlmeans/iam": "^0.1.18-rc.2",
29
- "@owlmeans/oidc": "^0.1.18-rc.2",
30
- "@owlmeans/server-context": "^0.1.18-rc.2",
31
- "@owlmeans/server-oidc-rp": "^0.1.18-rc.2"
25
+ "@owlmeans/auth": "^0.1.18-rc.8",
26
+ "@owlmeans/context": "^0.1.18-rc.7",
27
+ "@owlmeans/entrypoint": "^0.1.18-rc.10",
28
+ "@owlmeans/iam": "^0.1.18-rc.15",
29
+ "@owlmeans/oidc": "^0.1.18-rc.12",
30
+ "@owlmeans/server-context": "^0.1.18-rc.11",
31
+ "@owlmeans/server-oidc-rp": "^0.1.18-rc.17"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@owlmeans/dep-config": "workspace:*",
35
- "@types/bun": "^1.3.14",
35
+ "@types/bun": "^1.4.0",
36
36
  "nodemon": "^3.1.14",
37
37
  "typescript": "^7.0.2"
38
38
  },
package/src/gate.ts CHANGED
@@ -1,42 +1,102 @@
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 { entitySlugOf } from '@owlmeans/auth'
3
4
  import { OIDC_GATE } from '@owlmeans/oidc'
4
5
  import { AuthForbidden } from '@owlmeans/auth'
5
6
  import type { Auth } from '@owlmeans/auth'
6
7
  import { createGateModel, extractPermissionSets } from '@owlmeans/server-oidc-rp'
7
8
  import type { Config, Context } from '@owlmeans/server-oidc-rp'
8
- import { hasPermission } from '@owlmeans/iam'
9
+ import {
10
+ hasPermission, parseGateParam, resolveGateResource, validateGateParams, GateResolutionFailure
11
+ } from '@owlmeans/iam'
12
+ import type { GateParamAudit } from '@owlmeans/iam'
9
13
 
10
14
  /**
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.
15
+ * The gate-param grammar lives in `@owlmeans/iam` so the browser, the adapters and code-generation
16
+ * tooling can all read it. Re-exported here because this module was its home and is the documented
17
+ * import path.
17
18
  */
18
- export const RESOURCE_PARAM_SEPARATOR = '@'
19
+ export {
20
+ RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR,
21
+ GateParamSource, GateParamErrorCode, GateResolutionFailure,
22
+ parseGateParam, parseGateSelector, formatGateParam, resolveGateResource, validateGateParams
23
+ } from '@owlmeans/iam'
24
+ export type { ParsedGateParam, GateResourceSelector, GateParamAudit } from '@owlmeans/iam'
25
+
26
+ export interface IamGateOptions {
27
+ /**
28
+ * Refuse a resource-scoped param on the UMA2 fallback path instead of widening it to a
29
+ * project-wide check.
30
+ *
31
+ * Off by default, and deliberately so. A Keycloak-backed deployment cannot hold a resource-scoped
32
+ * grant at all — the adapter throws `IamUnsupported('resource-scoped-grant')` — so denying would
33
+ * lock every user out of every scoped endpoint rather than tightening anything. Turn it on only
34
+ * where the resulting refusals are the intended outcome.
35
+ */
36
+ strictResourceScope?: boolean
37
+ }
19
38
 
20
- export interface ParsedGateParam {
21
- permission: string
22
- resourceParam?: string
39
+ /**
40
+ * Structural faults are logged once per alias+param+reason.
41
+ *
42
+ * A gate on a hot endpoint runs on every request, and a misconfiguration is by definition permanent,
43
+ * so an un-deduplicated log turns one typo into the incident.
44
+ */
45
+ const reported = new Set<string>()
46
+
47
+ const reportOnce = (key: string, message: string): void => {
48
+ if (reported.has(key)) {
49
+ return
50
+ }
51
+ reported.add(key)
52
+ console.error(message)
23
53
  }
24
54
 
25
- export const parseGateParam = (param: string): ParsedGateParam => {
26
- const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR)
27
- if (idx < 0) {
28
- return { permission: param }
55
+ /** Aliases whose declarations have already been checked. */
56
+ const audited = new Set<string>()
57
+
58
+ /**
59
+ * Check an entrypoint's gate params against what it declares, once, the first time it is reached.
60
+ *
61
+ * Structurally unable to affect the outcome: it only ever logs. A selector naming a key the
62
+ * entrypoint's filter does not declare is stripped by validation before the gate runs, so the
63
+ * endpoint denies every request with a clean build and nothing else to go on — this is the line that
64
+ * says why.
65
+ */
66
+ const auditEntrypoint = (ctx: Context, req: AbstractRequest, params: string[]): void => {
67
+ if (req.alias == null || audited.has(req.alias)) {
68
+ return
29
69
  }
70
+ audited.add(req.alias)
30
71
 
31
- return { permission: param.slice(0, idx), resourceParam: param.slice(idx + 1) }
32
- }
72
+ try {
73
+ const entrypoint = ctx.entrypoint<CommonEntrypoint>(req.alias)
74
+ if (entrypoint == null) {
75
+ return
76
+ }
33
77
 
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]
78
+ let routePath: string | undefined
79
+ try {
80
+ routePath = entrypoint.mount()
81
+ } catch {
82
+ // The mount needs a service with an address; an entrypoint that has none still has a filter
83
+ // worth auditing.
84
+ }
38
85
 
39
- return value != null ? `${value}` : undefined
86
+ const audit: GateParamAudit = {
87
+ ...(routePath != null ? { routePath } : {}),
88
+ ...(entrypoint.filter != null ? { filter: entrypoint.filter as GateParamAudit['filter'] } : {})
89
+ }
90
+
91
+ for (const issue of validateGateParams(params, audit)) {
92
+ console.error(
93
+ `Gate param "${issue.param}" on entrypoint "${req.alias}" is misconfigured`
94
+ + ` [${issue.code}]: ${issue.detail}`
95
+ )
96
+ }
97
+ } catch {
98
+ // An entrypoint that cannot be looked up is not this gate's problem to report.
99
+ }
40
100
  }
41
101
 
42
102
  /**
@@ -48,8 +108,12 @@ const resolveResourceId = (req: AbstractRequest, resourceParam: string): string
48
108
  * 2. Otherwise (Keycloak mode — its tokens never produce a conforming claim) the
49
109
  * @-suffixes are stripped and the check delegates to the UMA2 gate model from
50
110
  * @owlmeans/server-oidc-rp, byte-equivalent to makeOidcGate.
111
+ *
112
+ * Params are OR'd: holding any one of them admits the request. That is why a malformed or
113
+ * unresolvable param can only contribute `false` and never throw — a sibling that would otherwise
114
+ * have passed must not be refused because of it.
51
115
  */
52
- export const makeIamGate = (alias: string = OIDC_GATE): GateService => {
116
+ export const makeIamGate = (alias: string = OIDC_GATE, opts?: IamGateOptions): GateService => {
53
117
  const service: GateService = createLazyService<GateService>(alias, {
54
118
  assert: async (req, _, params) => {
55
119
  await service.ready()
@@ -63,30 +127,74 @@ export const makeIamGate = (alias: string = OIDC_GATE): GateService => {
63
127
  const sets = extractPermissionSets(auth.permissions)
64
128
 
65
129
  if (sets != null) {
130
+ auditEntrypoint(ctx, req, params)
131
+
132
+ // Why a param failed, kept aside so a denial can say whether the gate was misconfigured or
133
+ // the subject simply lacks the grant. The two look identical from the outside and are
134
+ // repaired in completely different places.
135
+ const structural: string[] = []
136
+
66
137
  const granted = params.some(param => {
67
- const { permission, resourceParam } = parseGateParam(param)
68
- const fixed = permission.replaceAll('{entity}', auth.entityId ?? '-')
138
+ const { permission, resource, error } = parseGateParam(param)
139
+ // Grants are stored against the entity's stable id wherever one is resolvable; the
140
+ // slug is only a fallback for deployments with no organization store of their own,
141
+ // where the slug IS the identifier.
142
+ const fixed = permission.replaceAll('{entity}', req.entity?.id ?? entitySlugOf(auth) ?? '-')
143
+
144
+ if (error != null) {
145
+ structural.push(`"${param}" does not parse [${error.code}]: ${error.detail}`)
146
+ return false
147
+ }
69
148
 
70
- if (resourceParam != null) {
71
- const resourceId = resolveResourceId(req, resourceParam)
72
- if (resourceId == null) {
149
+ if (resource != null) {
150
+ const resolution = resolveGateResource(req, resource)
151
+ if (resolution.id == null) {
152
+ if (resolution.reason !== GateResolutionFailure.NotProvided) {
153
+ structural.push(
154
+ `"${param}" resolved no resource id [${resolution.reason}]`
155
+ + ` from ${resource.sources.join(', ')}`
156
+ )
157
+ }
73
158
  return false
74
159
  }
75
160
 
76
- return hasPermission(auth, fixed, { resourceId })
161
+ return hasPermission(auth, fixed, { resourceId: resolution.id })
77
162
  }
78
163
 
79
164
  return hasPermission(auth, fixed)
80
165
  })
81
166
 
82
167
  if (!granted) {
168
+ structural.forEach(detail => reportOnce(
169
+ `${req.alias}\0${detail}`,
170
+ `Gate on entrypoint "${req.alias}" refused a request it could not evaluate: ${detail}`
171
+ ))
172
+
83
173
  throw new AuthForbidden('permission')
84
174
  }
85
175
 
86
176
  return
87
177
  }
88
178
 
89
- const stripped = params.map(param => parseGateParam(param).permission)
179
+ // UMA2 fallback. Resource scoping is NOT enforced here: a Keycloak-backed deployment cannot
180
+ // store a resource-scoped grant, so a scoped param widens to a project-wide check and the same
181
+ // declaration means two different things depending on which backend is active.
182
+ const scoped = params.filter(param => parseGateParam(param).resource != null)
183
+ scoped.forEach(param => reportOnce(
184
+ `uma2\0${req.alias}\0${param}`,
185
+ `Gate param "${param}" on entrypoint "${req.alias}" is resource-scoped, but resource scoping`
186
+ + ' is not enforced in UMA2 mode — it is checked as a project-wide permission'
187
+ ))
188
+
189
+ const usable = opts?.strictResourceScope === true
190
+ ? params.filter(param => parseGateParam(param).resource == null)
191
+ : params
192
+
193
+ if (usable.length < 1) {
194
+ throw new AuthForbidden('permission')
195
+ }
196
+
197
+ const stripped = usable.map(param => parseGateParam(param).permission)
90
198
  const model = createGateModel(ctx)
91
199
  const permissions = await model.loadPermissions(auth, stripped)
92
200
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { AppType, Layer, makeBasicContext } from '@owlmeans/context'
2
+ import { AppType, makeBasicContext } from '@owlmeans/context'
3
3
  import type { BasicConfig } from '@owlmeans/context'
4
4
  import type { AbstractRequest, AbstractResponse, GateService } from '@owlmeans/entrypoint'
5
5
  import type { Auth } from '@owlmeans/auth'
@@ -11,7 +11,6 @@ const makeGate = async (): Promise<GateService> => {
11
11
  const ctx = makeBasicContext<BasicConfig>({
12
12
  ready: false,
13
13
  service: 'server-iam-tests',
14
- layer: Layer.Service,
15
14
  type: AppType.Backend,
16
15
  })
17
16
  ctx.registerService(makeIamGate())
@@ -21,12 +20,16 @@ const makeGate = async (): Promise<GateService> => {
21
20
  return ctx.service<GateService>(OIDC_GATE)
22
21
  }
23
22
 
24
- const makeRequest = (params: Record<string, string> = {}): AbstractRequest => ({
23
+ const makeRequest = (
24
+ params: Record<string, string> = {},
25
+ rest: Partial<Pick<AbstractRequest, 'query' | 'body' | 'headers'>> = {}
26
+ ): AbstractRequest => ({
25
27
  alias: 'test',
26
28
  headers: {},
27
29
  params,
28
30
  query: {},
29
31
  body: {},
32
+ ...rest,
30
33
  path: '/',
31
34
  auth: {
32
35
  type: 'oidc-wrapped-token',
@@ -72,4 +75,53 @@ describe('@owlmeans/server-iam — makeIamGate (claims mode)', () => {
72
75
  gate.assert(makeRequest({ depId: 'dep-87654321' }), res, ['department--modify@depId'])
73
76
  ).rejects.toBeInstanceOf(AuthForbidden)
74
77
  })
78
+
79
+ test('resolves a resource id from an explicitly named source', async () => {
80
+ const gate = await makeGate()
81
+ await gate.assert(
82
+ makeRequest({}, { body: { department: { id: 'dep-12345678' } } }), res,
83
+ ['department--modify@body:department.id']
84
+ )
85
+ })
86
+
87
+ test('resolves a resource id from the auth object', async () => {
88
+ const gate = await makeGate()
89
+ // `entity-1` is this subject's own entityId, and the unscoped grant covers every resource.
90
+ await gate.assert(makeRequest(), res, ['article--modify@auth:entityId'])
91
+ })
92
+
93
+ /**
94
+ * Params are OR'd, so a param the gate cannot even evaluate must contribute nothing. Letting one
95
+ * throw would turn a typo in an unrelated sibling into a denial of a grant the subject really has.
96
+ */
97
+ test('a malformed param does not refuse a sibling that passes', async () => {
98
+ const gate = await makeGate()
99
+ await gate.assert(makeRequest(), res, ['article--modify@cookies:sid', 'article--modify'])
100
+ })
101
+
102
+ test('an unresolvable selector refuses without breaking the OR', async () => {
103
+ const gate = await makeGate()
104
+ await gate.assert(makeRequest(), res, ['department--modify@missingParam', 'article--modify'])
105
+
106
+ expect(
107
+ gate.assert(makeRequest(), res, ['department--modify@missingParam'])
108
+ ).rejects.toBeInstanceOf(AuthForbidden)
109
+ })
110
+
111
+ /**
112
+ * The stored grant is keyed `department--modify`; the gate splits the selector off before looking
113
+ * it up. A grant written under the whole string — the corruption this grammar exists to stop —
114
+ * is a key nothing reads.
115
+ */
116
+ test('a permission granted WITH the selector in its name never satisfies the gate', async () => {
117
+ const gate = await makeGate()
118
+ const req = makeRequest({ depId: 'dep-1' })
119
+ ;(req.auth as unknown as Auth).permissions = [
120
+ { scope: 'my-project', permissions: { 'department--modify@depId': true } }
121
+ ]
122
+
123
+ expect(
124
+ gate.assert(req, res, ['department--modify@depId'])
125
+ ).rejects.toBeInstanceOf(AuthForbidden)
126
+ })
75
127
  })