@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 +5 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-iam/SKILL.md +57 -9
- package/build/append.d.ts.map +1 -1
- package/build/gate.d.ts +21 -12
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +99 -26
- package/build/gate.js.map +1 -1
- package/package.json +9 -9
- package/src/gate.ts +139 -31
- package/tests/gate.spec.ts +55 -3
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
|
|
28
|
-
|
|
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`.
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-iam",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
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
|
|
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>
|
|
28
|
-
<permission>@<
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
package/build/append.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
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
|
package/build/gate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
+
reported.add(key);
|
|
25
|
+
console.error(message);
|
|
21
26
|
};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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":"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,
|
|
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.
|
|
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.
|
|
26
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
27
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
28
|
-
"@owlmeans/iam": "^0.1.18-rc.
|
|
29
|
-
"@owlmeans/oidc": "^0.1.18-rc.
|
|
30
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
31
|
-
"@owlmeans/server-oidc-rp": "^0.1.18-rc.
|
|
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.
|
|
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 {
|
|
9
|
+
import {
|
|
10
|
+
hasPermission, parseGateParam, resolveGateResource, validateGateParams, GateResolutionFailure
|
|
11
|
+
} from '@owlmeans/iam'
|
|
12
|
+
import type { GateParamAudit } from '@owlmeans/iam'
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* req.params[requestParam] ?? req.query[requestParam]
|
|
15
|
-
* and must be listed in the grant's resources[]
|
|
16
|
-
* '{entity}' substitution applies to the permission part in both forms.
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
72
|
+
try {
|
|
73
|
+
const entrypoint = ctx.entrypoint<CommonEntrypoint>(req.alias)
|
|
74
|
+
if (entrypoint == null) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
33
77
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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,
|
|
68
|
-
|
|
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 (
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
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
|
-
|
|
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
|
|
package/tests/gate.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { AppType,
|
|
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 = (
|
|
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
|
})
|