@owlmeans/server-iam 0.1.18-rc.4 → 0.1.18-rc.40

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
@@ -12,7 +12,7 @@ One-call OIDC RP wiring and IAM gate for OwlMeans servers — `appendIam()` and
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/server-iam
15
+ bun add @owlmeans/server-iam@^0.1.18-rc.40
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -20,12 +20,19 @@ 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 { contract, protocol, typed } from '@owlmeans/entrypoint'
24
+ import { route } from '@owlmeans/route'
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 a protocol with a permission check
31
+ protocol(
32
+ route('users', '/users'),
33
+ contract(typed<User[]>()),
34
+ { guards: DEFAULT_GUARD, gate: { alias: IAM_GATE, params: ['manage-users@{entity}'] } },
35
+ )
29
36
  ```
30
37
 
31
38
  Requires `@owlmeans/oidc` OIDC shared config and a running IAM provider configured via `@owlmeans/server-oidc-rp`.
@@ -38,7 +45,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
38
45
  your project's skill store (`.agents/skills/`):
39
46
 
40
47
  ```sh
41
- npx @owlmeans/agent-skills
48
+ npx @owlmeans/agent-skills@^0.1.18-rc.31
42
49
  ```
43
50
 
44
51
  The embedded files are version-matched to this package release. Do not edit them
@@ -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.40",
5
+ "generatedAt": "2026-09-21T21:54:22.951Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: server-iam
3
- description: "How to use @owlmeans/server-iam — one-call OIDC RP wiring (appendIam) and the IAM gate that asserts unscoped and resource-scoped permissions (claims-first, UMA2 fallback). Use when gating server endpoints in IAM consumers (e.g. the viable target template backend). Applies to files matching **/owlmeans.ts, **/gate*.ts, **/auth-guard*.ts."
3
+ description: "How to use @owlmeans/server-iam — one-call OIDC RP wiring (appendIam) and the IAM gate that asserts unscoped and resource-scoped permissions (claims-first, UMA2 fallback), plus the gate-param grammar it re-exports. Use when gating server endpoints, declaring gate params, or diagnosing a permission refusal. Applies to files matching **/owlmeans.ts, **/gate*.ts, **/auth-guard*.ts."
4
4
  metadata:
5
5
  applyTo: "**/owlmeans.ts, **/gate*.ts, **/auth-guard*.ts"
6
6
  ---
@@ -8,6 +8,8 @@ metadata:
8
8
 
9
9
  # Using `@owlmeans/server-iam`
10
10
 
11
+ **Install:** `"@owlmeans/server-iam": "^0.1.18-rc.40"` in `dependencies`
12
+
11
13
  Boilerplate-less server-side consumer of the OwlMeans IAM. One call wires the OIDC RP stack and the
12
14
  IAM gate; the consumer never knows which IAM backend (Keycloak or integrated) is active.
13
15
 
@@ -16,26 +18,74 @@ IAM gate; the consumer never knows which IAM backend (Keycloak or integrated) is
16
18
  | Symbol | Kind | Purpose |
17
19
  |--------|------|---------|
18
20
  | `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 | `'@'` |
21
+ | `makeIamGate(alias?, opts?)` | fn | The IAM `GateService` — claims-first assertion with UMA2 fallback |
22
+ | `IamGateOptions` | type | `{ strictResourceScope?: boolean }` — refuse a scoped param on the fallback path instead of widening it |
23
+ | `parseGateParam` / `parseGateSelector` / `formatGateParam` / `resolveGateResource` / `validateGateParams` | fn | Re-exports from `@owlmeans/iam`, which owns the grammar |
24
+ | `RESOURCE_PARAM_SEPARATOR` / `RESOURCE_SOURCE_SEPARATOR` / `RESOURCE_PATH_SEPARATOR` | const | `'@'`, `':'` and `'.'`, re-exported |
25
+ | `GateParamSource` / `GateParamErrorCode` / `GateResolutionFailure` | enum | Re-exported; the sources a selector may name, and why one failed |
22
26
  | `hasPermission` | fn | Re-export from `@owlmeans/iam` |
27
+ | `SERVER_IAM_SERVICE` | const | `'server-iam-service'` — exported and referenced nowhere; the gate registers under `OIDC_GATE`, not under this |
28
+ | Every `@owlmeans/iam` type | type | Re-exported wholesale, so a server needs one IAM import |
29
+
30
+ The grammar lives in **`@owlmeans/iam`**, not here — the browser, both IAM adapters and
31
+ code-generation tooling all have to read the same rules. This package re-exports it because it is the
32
+ documented import path for a server, so one IAM import covers the gate and its grammar.
33
+
34
+ `appendIam` reads the provider configuration from `cfg.oidc.providers`, so populate that before
35
+ calling it. It is the IAM-flavoured replacement for wiring `@owlmeans/server-oidc-rp` by hand: the
36
+ one difference is the gate, which is `makeIamGate()` rather than `makeOidcGate()`.
23
37
 
24
38
  ## Gate-param syntax
25
39
 
26
40
  ```
27
- <permission> → unscoped (project-wide): 'article--modify'
28
- <permission>@<requestParam> → resource-scoped: 'department--modify@departmentId'
41
+ <permission> → unscoped (project-wide): 'article--modify'
42
+ <permission>@<bareKey> → bare form: 'department--modify@departmentId'
43
+ <permission>@<source>:<path> → qualified form: 'order--modify@body:order.id'
29
44
  ```
30
45
 
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.
46
+ Sources: `params`, `query`, `body`, `headers`, `auth`.
47
+
48
+ - **The bare form is lenient and flat**; the qualified form is strict and nested. `@a.b` in the bare
49
+ form is the literal key `"a.b"` — a query key may legally contain a dot, and splitting it would
50
+ change what already-deployed selectors mean. Dotted paths exist only after a `source:`.
51
+ - The separator is `:` and not a dot on purpose. With a dotted-only form, a route that gains a param
52
+ named like a source keyword silently changes a selector's meaning: `/report/:query` with
53
+ `@query.id` flips from "the route param named `query.id`" to "the query string's `id`" — an
54
+ authorization lookup redirected to attacker-supplied input by an unrelated route rename.
55
+ - The bare form searches `params` then `query`. That query fallback is legacy and deliberately kept:
56
+ removing it would silently deny deployed apps whose selector names a query key.
57
+ - `headers:` lowercases its first segment and unwraps a single-element array; a repeated header is
58
+ refused rather than silently narrowed to one of its values.
59
+ - `auth:` is the only source that is neither attacker-controlled nor subject to `removeAdditional`.
60
+ Prefer it whenever the rule is "bound to their own profile or entity".
61
+ - A resolved id must satisfy the grant's `resources[]` — **and a grant carrying no `resources` at all
62
+ covers every id**. That is the all-resources form of the same permission, not a separate one.
63
+ - Params are **any-of**: one granted param is enough. Which is why a malformed or unresolvable param
64
+ can only contribute `false` and never throw — otherwise one bad sibling would refuse a request the
65
+ subject is legitimately entitled to.
66
+
67
+ **The `@` belongs to the gate and nowhere else.** It is never part of a permission's stored NAME. A
68
+ definition or grant written as `enquiry--view@enquiryId` is a key nothing looks up, so the permission
69
+ reads as granted in every screen and every request is still refused, with nothing logged.
70
+ `ensurePermission` refuses such a name; `validateGateParams` catches the mirror-image mistake at
71
+ generation time.
72
+
73
+ ### `validateGateParams(params, audit?)`
74
+
75
+ Reports what is provable from an entrypoint's own declaration: a selector naming a route param the
76
+ path does not carry, or a `body`/`query`/`headers` key the `Filter` does not declare **while that
77
+ schema sets `additionalProperties: false`**. That qualifier matters — the server's AJV runs
78
+ `removeAdditional`, which strips only what a schema closes, so without it the check would fire on
79
+ every unfiltered endpoint and be ignored within a week. It never decides a request; the gate runs it
80
+ once per alias and only logs.
81
+
82
+ The route half of the audit comes from `entrypoint.mount()`, which needs a service with a resolvable
83
+ address. An entrypoint that has none is still audited against its filter — the route-param check is
84
+ simply skipped rather than the whole audit being lost.
35
85
 
36
86
  ```ts
37
87
  // module declaration
38
- gate(OIDC_GATE, ['department--modify@depId'])
88
+ { gate: { alias: OIDC_GATE, params: ['department--modify@depId'] } }
39
89
 
40
90
  // or imperative
41
91
  await ctx.service<GateService>(OIDC_GATE).assert(req, res, ['article--modify'])
@@ -48,8 +98,35 @@ await ctx.service<GateService>(OIDC_GATE).assert(req, res, ['article--modify'])
48
98
  by the integrated IAM provider and mapped into `Auth` by `@owlmeans/server-oidc-rp`), params are
49
99
  asserted locally via `hasPermission`.
50
100
  3. **Fallback** — otherwise `@…` suffixes are stripped and the check delegates to the UMA2 gate model
51
- from `@owlmeans/server-oidc-rp` (`createGateModel().loadPermissions`) — byte-equivalent to the old
52
- `makeOidcGate`, which keeps Keycloak-backed deployments unchanged.
101
+ from `@owlmeans/server-oidc-rp` (`createGateModel().loadPermissions`) — exactly the check
102
+ `makeOidcGate` performs, which is what a Keycloak-backed deployment gets.
103
+
104
+ Which branch runs is decided per request by the token alone: a conforming `permissions` claim selects
105
+ claims mode, its absence selects the fallback. Nothing configures it, and there is no environment
106
+ variable behind it.
107
+
108
+ **The fallback path does not enforce resource scoping**, so one declaration means two different
109
+ things depending on which backend answered. It cannot be fixed by denying — a Keycloak-backed adapter
110
+ throws `IamUnsupported('resource-scoped-grant')`, so a scoped grant cannot exist there and denying
111
+ would lock every user out of every scoped endpoint. The gate logs each such param once per alias;
112
+ `makeIamGate(alias, { strictResourceScope: true })` opts into denial where the refusals are the
113
+ intended outcome.
114
+
115
+ A denial distinguishes its reasons in the log: a param that failed **structurally** (unparseable,
116
+ source absent, value not a scalar) is reported separately from one that resolved fine and simply was
117
+ not granted. The HTTP answer is identical — `AuthForbidden('permission')` either way — because the
118
+ caller must never learn which.
119
+
120
+ ## `{entity}` in a permission name
121
+
122
+ A gate param may carry the literal `{entity}`, which is substituted at assertion time with the
123
+ organization entity's **stable id** — `req.entity.id`, resolved once at the server boundary — falling
124
+ back to the token's `entitySlug` and then to `-`. So `my-service-account-{entity}` asserts a
125
+ per-organization permission without the declaration knowing which organization it is for.
126
+
127
+ Grants are stored against the id wherever one is resolvable. The slug fallback exists for deployments
128
+ with no organization store of their own, where the slug *is* the identifier; a deployment that has a
129
+ store must not grant against slugs, because a rename would then orphan every grant.
53
130
 
54
131
  ## Rules
55
132
 
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,WAAW,MAAM,SAAqB,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.4",
3
+ "version": "0.1.18-rc.40",
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.4",
26
- "@owlmeans/context": "^0.1.18-rc.4",
27
- "@owlmeans/entrypoint": "^0.1.18-rc.4",
28
- "@owlmeans/iam": "^0.1.18-rc.4",
29
- "@owlmeans/oidc": "^0.1.18-rc.4",
30
- "@owlmeans/server-context": "^0.1.18-rc.4",
31
- "@owlmeans/server-oidc-rp": "^0.1.18-rc.4"
25
+ "@owlmeans/auth": "^0.1.18-rc.29",
26
+ "@owlmeans/context": "^0.1.18-rc.27",
27
+ "@owlmeans/entrypoint": "^0.1.18-rc.30",
28
+ "@owlmeans/iam": "^0.1.18-rc.34",
29
+ "@owlmeans/oidc": "^0.1.18-rc.39",
30
+ "@owlmeans/server-context": "^0.1.18-rc.30",
31
+ "@owlmeans/server-oidc-rp": "^0.1.18-rc.38"
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
  })