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