nucleus-core-ts 0.9.706 → 0.9.708
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/dist/index.js +3 -3
- package/dist/src/Client/Proxy/authz.d.ts +28 -2
- package/dist/src/Client/Proxy/authz.js +57 -5
- package/dist/src/Client/Proxy/authz.test.js +61 -18
- package/dist/src/Client/Proxy/httpProxy.js +24 -2
- package/dist/src/Client/Proxy/types.d.ts +12 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/seed.d.ts +7 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/seed.test.d.ts +1 -0
- package/package.json +1 -1
|
@@ -21,12 +21,18 @@ export interface AccessDecision {
|
|
|
21
21
|
reason: string;
|
|
22
22
|
action: string | null;
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
|
|
26
|
+
* (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
|
|
27
|
+
* this stays mode-agnostic and easy to test.
|
|
28
|
+
*/
|
|
25
29
|
export declare function evaluateAccess(args: {
|
|
26
30
|
manifest: ManifestRoute[];
|
|
27
31
|
method: string;
|
|
28
32
|
path: string;
|
|
29
|
-
|
|
33
|
+
authenticated: boolean;
|
|
34
|
+
roles: string[];
|
|
35
|
+
claims: string[];
|
|
30
36
|
onUnmapped: 'allow' | 'deny';
|
|
31
37
|
publicPaths?: string[];
|
|
32
38
|
godminRole: string;
|
|
@@ -46,3 +52,23 @@ export declare class ManifestCache {
|
|
|
46
52
|
private refresh;
|
|
47
53
|
get(): Promise<ManifestRoute[]>;
|
|
48
54
|
}
|
|
55
|
+
/** Fetches + caches the role→claim-actions map (resolve mode) from the IDP. */
|
|
56
|
+
export declare class RoleClaimsCache {
|
|
57
|
+
private readonly cfg;
|
|
58
|
+
private readonly fetchImpl;
|
|
59
|
+
private readonly logger?;
|
|
60
|
+
private map;
|
|
61
|
+
private fetchedAtMs;
|
|
62
|
+
private inflight;
|
|
63
|
+
constructor(cfg: {
|
|
64
|
+
url: string;
|
|
65
|
+
token?: string;
|
|
66
|
+
refreshSec?: number;
|
|
67
|
+
serviceId: string;
|
|
68
|
+
}, fetchImpl?: typeof fetch, logger?: ProxyLogger | undefined);
|
|
69
|
+
private get ttlMs();
|
|
70
|
+
private refresh;
|
|
71
|
+
get(): Promise<Record<string, string[]>>;
|
|
72
|
+
}
|
|
73
|
+
/** Resolve a user's roles to their claim actions using the role-claims map. */
|
|
74
|
+
export declare function resolveClaimsFromRoles(roles: string[], roleClaims: Record<string, string[]>): string[];
|
|
@@ -66,8 +66,12 @@ function matchesGlob(path, patterns) {
|
|
|
66
66
|
}
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
|
|
71
|
+
* (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
|
|
72
|
+
* this stays mode-agnostic and easy to test.
|
|
73
|
+
*/ export function evaluateAccess(args) {
|
|
74
|
+
const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole } = args;
|
|
71
75
|
if (matchesGlob(path, publicPaths)) return {
|
|
72
76
|
decision: 'allow',
|
|
73
77
|
reason: 'public',
|
|
@@ -81,18 +85,16 @@ function matchesGlob(path, patterns) {
|
|
|
81
85
|
action: null
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
|
-
if (!
|
|
88
|
+
if (!authenticated) return {
|
|
85
89
|
decision: 'deny',
|
|
86
90
|
reason: 'no-valid-token',
|
|
87
91
|
action
|
|
88
92
|
};
|
|
89
|
-
const roles = Array.isArray(payload.roles) ? payload.roles : [];
|
|
90
93
|
if (roles.includes(godminRole)) return {
|
|
91
94
|
decision: 'allow',
|
|
92
95
|
reason: 'godmin',
|
|
93
96
|
action
|
|
94
97
|
};
|
|
95
|
-
const claims = Array.isArray(payload.claims) ? payload.claims : [];
|
|
96
98
|
if (claimSatisfies(claims, action)) return {
|
|
97
99
|
decision: 'allow',
|
|
98
100
|
reason: 'claim',
|
|
@@ -148,3 +150,53 @@ function matchesGlob(path, patterns) {
|
|
|
148
150
|
this.inflight = null;
|
|
149
151
|
}
|
|
150
152
|
}
|
|
153
|
+
/** Fetches + caches the role→claim-actions map (resolve mode) from the IDP. */ export class RoleClaimsCache {
|
|
154
|
+
get ttlMs() {
|
|
155
|
+
return (this.cfg.refreshSec ?? 300) * 1000;
|
|
156
|
+
}
|
|
157
|
+
async refresh() {
|
|
158
|
+
const url = `${this.cfg.url}${this.cfg.url.includes('?') ? '&' : '?'}service=${encodeURIComponent(this.cfg.serviceId)}`;
|
|
159
|
+
const headers = {};
|
|
160
|
+
if (this.cfg.token) headers['x-discovery-token'] = this.cfg.token;
|
|
161
|
+
const res = await this.fetchImpl(url, {
|
|
162
|
+
headers
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) throw new Error(`role-claims manifest fetch ${res.status}`);
|
|
165
|
+
const body = await res.json();
|
|
166
|
+
this.map = body.data?.roleClaims ?? {};
|
|
167
|
+
this.fetchedAtMs = Date.now();
|
|
168
|
+
}
|
|
169
|
+
async get() {
|
|
170
|
+
const stale = Date.now() - this.fetchedAtMs > this.ttlMs;
|
|
171
|
+
if (stale && !this.inflight) {
|
|
172
|
+
this.inflight = this.refresh().catch((err)=>{
|
|
173
|
+
this.logger?.warn?.(`[Proxy authz] role-claims refresh failed for ${this.cfg.serviceId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
}).finally(()=>{
|
|
175
|
+
this.inflight = null;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (this.fetchedAtMs === 0 && this.inflight) await this.inflight;
|
|
179
|
+
return this.map;
|
|
180
|
+
}
|
|
181
|
+
constructor(cfg, fetchImpl = fetch, logger){
|
|
182
|
+
_define_property(this, "cfg", void 0);
|
|
183
|
+
_define_property(this, "fetchImpl", void 0);
|
|
184
|
+
_define_property(this, "logger", void 0);
|
|
185
|
+
_define_property(this, "map", void 0);
|
|
186
|
+
_define_property(this, "fetchedAtMs", void 0);
|
|
187
|
+
_define_property(this, "inflight", void 0);
|
|
188
|
+
this.cfg = cfg;
|
|
189
|
+
this.fetchImpl = fetchImpl;
|
|
190
|
+
this.logger = logger;
|
|
191
|
+
this.map = {};
|
|
192
|
+
this.fetchedAtMs = 0;
|
|
193
|
+
this.inflight = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Resolve a user's roles to their claim actions using the role-claims map. */ export function resolveClaimsFromRoles(roles, roleClaims) {
|
|
197
|
+
const out = new Set();
|
|
198
|
+
for (const r of roles)for (const a of roleClaims[r] ?? [])out.add(a);
|
|
199
|
+
return [
|
|
200
|
+
...out
|
|
201
|
+
];
|
|
202
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { describe, expect, it } from 'bun:test';
|
|
3
|
-
import { claimSatisfies, evaluateAccess, findRequiredAction, verifyJwtHS256 } from './authz';
|
|
3
|
+
import { claimSatisfies, evaluateAccess, findRequiredAction, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
|
|
4
4
|
const b64 = (o)=>Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
5
5
|
function signHS256(payload, secret) {
|
|
6
6
|
const header = b64({
|
|
@@ -98,18 +98,17 @@ describe('evaluateAccess', ()=>{
|
|
|
98
98
|
const base = {
|
|
99
99
|
manifest: MANIFEST,
|
|
100
100
|
onUnmapped: 'deny',
|
|
101
|
-
godminRole: 'godmin'
|
|
101
|
+
godminRole: 'godmin',
|
|
102
|
+
authenticated: true,
|
|
103
|
+
roles: [],
|
|
104
|
+
claims: []
|
|
102
105
|
};
|
|
103
|
-
const payload = (claims, roles = [])=>({
|
|
104
|
-
claims,
|
|
105
|
-
roles
|
|
106
|
-
});
|
|
107
106
|
it('allows a public path without a claim', ()=>{
|
|
108
107
|
const d = evaluateAccess({
|
|
109
108
|
...base,
|
|
110
109
|
method: 'GET',
|
|
111
110
|
path: '/health',
|
|
112
|
-
|
|
111
|
+
authenticated: false,
|
|
113
112
|
publicPaths: [
|
|
114
113
|
'/health'
|
|
115
114
|
]
|
|
@@ -120,8 +119,7 @@ describe('evaluateAccess', ()=>{
|
|
|
120
119
|
const d = evaluateAccess({
|
|
121
120
|
...base,
|
|
122
121
|
method: 'GET',
|
|
123
|
-
path: '/api/v1/unknown'
|
|
124
|
-
payload: payload([])
|
|
122
|
+
path: '/api/v1/unknown'
|
|
125
123
|
});
|
|
126
124
|
expect(d.decision).toBe('deny');
|
|
127
125
|
});
|
|
@@ -130,8 +128,7 @@ describe('evaluateAccess', ()=>{
|
|
|
130
128
|
...base,
|
|
131
129
|
onUnmapped: 'allow',
|
|
132
130
|
method: 'GET',
|
|
133
|
-
path: '/api/v1/unknown'
|
|
134
|
-
payload: payload([])
|
|
131
|
+
path: '/api/v1/unknown'
|
|
135
132
|
});
|
|
136
133
|
expect(d.decision).toBe('allow');
|
|
137
134
|
});
|
|
@@ -140,7 +137,7 @@ describe('evaluateAccess', ()=>{
|
|
|
140
137
|
...base,
|
|
141
138
|
method: 'GET',
|
|
142
139
|
path: '/api/v1/templates',
|
|
143
|
-
|
|
140
|
+
authenticated: false
|
|
144
141
|
});
|
|
145
142
|
expect(d).toMatchObject({
|
|
146
143
|
decision: 'deny',
|
|
@@ -152,9 +149,9 @@ describe('evaluateAccess', ()=>{
|
|
|
152
149
|
...base,
|
|
153
150
|
method: 'DELETE',
|
|
154
151
|
path: '/api/v1/templates/1',
|
|
155
|
-
|
|
152
|
+
roles: [
|
|
156
153
|
'godmin'
|
|
157
|
-
]
|
|
154
|
+
]
|
|
158
155
|
});
|
|
159
156
|
expect(d).toMatchObject({
|
|
160
157
|
decision: 'allow',
|
|
@@ -166,9 +163,9 @@ describe('evaluateAccess', ()=>{
|
|
|
166
163
|
...base,
|
|
167
164
|
method: 'GET',
|
|
168
165
|
path: '/api/v1/templates',
|
|
169
|
-
|
|
166
|
+
claims: [
|
|
170
167
|
'agent.templates'
|
|
171
|
-
]
|
|
168
|
+
]
|
|
172
169
|
});
|
|
173
170
|
expect(d).toMatchObject({
|
|
174
171
|
decision: 'allow',
|
|
@@ -180,9 +177,9 @@ describe('evaluateAccess', ()=>{
|
|
|
180
177
|
...base,
|
|
181
178
|
method: 'DELETE',
|
|
182
179
|
path: '/api/v1/templates/1',
|
|
183
|
-
|
|
180
|
+
claims: [
|
|
184
181
|
'agent.templates.list'
|
|
185
|
-
]
|
|
182
|
+
]
|
|
186
183
|
});
|
|
187
184
|
expect(d).toMatchObject({
|
|
188
185
|
decision: 'deny',
|
|
@@ -190,3 +187,49 @@ describe('evaluateAccess', ()=>{
|
|
|
190
187
|
});
|
|
191
188
|
});
|
|
192
189
|
});
|
|
190
|
+
describe('resolveClaimsFromRoles (resolve mode)', ()=>{
|
|
191
|
+
const roleClaims = {
|
|
192
|
+
'agent-viewer': [
|
|
193
|
+
'agent.templates.list',
|
|
194
|
+
'agent.templates.get'
|
|
195
|
+
],
|
|
196
|
+
'agent-admin': [
|
|
197
|
+
'agent'
|
|
198
|
+
]
|
|
199
|
+
};
|
|
200
|
+
it('unions the claim actions of the caller-held roles', ()=>{
|
|
201
|
+
expect(resolveClaimsFromRoles([
|
|
202
|
+
'agent-viewer'
|
|
203
|
+
], roleClaims).sort()).toEqual([
|
|
204
|
+
'agent.templates.get',
|
|
205
|
+
'agent.templates.list'
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
it('dedupes across roles and ignores unknown roles', ()=>{
|
|
209
|
+
expect(resolveClaimsFromRoles([
|
|
210
|
+
'agent-admin',
|
|
211
|
+
'nope'
|
|
212
|
+
], roleClaims)).toEqual([
|
|
213
|
+
'agent'
|
|
214
|
+
]);
|
|
215
|
+
// resolved claims then feed evaluateAccess: 'agent' prefix grants any agent.* action
|
|
216
|
+
const d = evaluateAccess({
|
|
217
|
+
manifest: MANIFEST,
|
|
218
|
+
method: 'DELETE',
|
|
219
|
+
path: '/api/v1/templates/1',
|
|
220
|
+
authenticated: true,
|
|
221
|
+
roles: [
|
|
222
|
+
'agent-admin'
|
|
223
|
+
],
|
|
224
|
+
claims: resolveClaimsFromRoles([
|
|
225
|
+
'agent-admin'
|
|
226
|
+
], roleClaims),
|
|
227
|
+
onUnmapped: 'deny',
|
|
228
|
+
godminRole: 'godmin'
|
|
229
|
+
});
|
|
230
|
+
expect(d).toMatchObject({
|
|
231
|
+
decision: 'allow',
|
|
232
|
+
reason: 'claim'
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { evaluateAccess, ManifestCache, verifyJwtHS256 } from './authz';
|
|
1
|
+
import { evaluateAccess, ManifestCache, resolveClaimsFromRoles, RoleClaimsCache, verifyJwtHS256 } from './authz';
|
|
2
2
|
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
3
|
const pendingRefreshes = new Map();
|
|
4
4
|
function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
|
|
@@ -135,12 +135,21 @@ export function createHttpProxyHandler(config) {
|
|
|
135
135
|
const logger = createProxyLogger('HTTP Proxy', config.debug ?? false);
|
|
136
136
|
// One manifest cache per authorize-enabled service (shared across requests).
|
|
137
137
|
const manifestCaches = new Map();
|
|
138
|
+
const roleClaimsCaches = new Map();
|
|
138
139
|
for (const t of config.targets){
|
|
139
140
|
if (t.authorize) {
|
|
140
141
|
manifestCaches.set(t.authorize.serviceId, new ManifestCache({
|
|
141
142
|
...t.authorize.manifest,
|
|
142
143
|
serviceId: t.authorize.serviceId
|
|
143
144
|
}, fetch, logger));
|
|
145
|
+
if (t.authorize.claimsMode === 'resolve' && t.authorize.manifest.roleClaimsUrl) {
|
|
146
|
+
roleClaimsCaches.set(t.authorize.serviceId, new RoleClaimsCache({
|
|
147
|
+
url: t.authorize.manifest.roleClaimsUrl,
|
|
148
|
+
token: t.authorize.manifest.token,
|
|
149
|
+
refreshSec: t.authorize.manifest.refreshSec,
|
|
150
|
+
serviceId: t.authorize.serviceId
|
|
151
|
+
}, fetch, logger));
|
|
152
|
+
}
|
|
144
153
|
}
|
|
145
154
|
}
|
|
146
155
|
/**
|
|
@@ -157,11 +166,24 @@ export function createHttpProxyHandler(config) {
|
|
|
157
166
|
const bearer = az.jwt.headerName ? (req.headers.get(az.jwt.headerName) ?? '').replace(/^Bearer\s+/i, '') : '';
|
|
158
167
|
const tokenStr = cookies[az.jwt.cookieName ?? 'access_token'] || bearer || '';
|
|
159
168
|
const payload = tokenStr ? verifyJwtHS256(tokenStr, az.jwt.secret) : null;
|
|
169
|
+
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
|
170
|
+
// embed: claims live in the JWT. resolve: the JWT carries only roles; resolve
|
|
171
|
+
// them to claims via the role-claims manifest (matches IDP jwtClaimsMode).
|
|
172
|
+
let claims;
|
|
173
|
+
if (az.claimsMode === 'resolve') {
|
|
174
|
+
const roleClaimsCache = roleClaimsCaches.get(az.serviceId);
|
|
175
|
+
const roleClaims = roleClaimsCache ? await roleClaimsCache.get() : {};
|
|
176
|
+
claims = resolveClaimsFromRoles(roles, roleClaims);
|
|
177
|
+
} else {
|
|
178
|
+
claims = Array.isArray(payload?.claims) ? payload.claims : [];
|
|
179
|
+
}
|
|
160
180
|
const decision = evaluateAccess({
|
|
161
181
|
manifest,
|
|
162
182
|
method: req.method,
|
|
163
183
|
path: upstreamPath,
|
|
164
|
-
payload,
|
|
184
|
+
authenticated: payload !== null,
|
|
185
|
+
roles,
|
|
186
|
+
claims,
|
|
165
187
|
onUnmapped: az.onUnmapped ?? 'deny',
|
|
166
188
|
publicPaths: az.publicPaths,
|
|
167
189
|
godminRole: az.godminRole ?? 'godmin'
|
|
@@ -46,7 +46,13 @@ export interface ManifestRoute {
|
|
|
46
46
|
export interface ProxyAuthorizeConfig {
|
|
47
47
|
serviceId: string;
|
|
48
48
|
manifest: {
|
|
49
|
+
/** Route→action manifest URL (GET /authorization/route-manifest). */
|
|
49
50
|
url: string;
|
|
51
|
+
/**
|
|
52
|
+
* Role→claim-actions manifest URL (GET /authorization/role-claims-manifest),
|
|
53
|
+
* REQUIRED when claimsMode is 'resolve' (JWT carries roles, not claims).
|
|
54
|
+
*/
|
|
55
|
+
roleClaimsUrl?: string;
|
|
50
56
|
token?: string;
|
|
51
57
|
refreshSec?: number;
|
|
52
58
|
};
|
|
@@ -55,6 +61,12 @@ export interface ProxyAuthorizeConfig {
|
|
|
55
61
|
cookieName?: string;
|
|
56
62
|
headerName?: string;
|
|
57
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* How the caller's claims are obtained. 'embed' (default): read from the JWT's
|
|
66
|
+
* `claims`. 'resolve': the JWT carries only `roles`; resolve them to claims via
|
|
67
|
+
* the role-claims manifest (matches the IDP's jwtClaimsMode: 'resolve').
|
|
68
|
+
*/
|
|
69
|
+
claimsMode?: 'embed' | 'resolve';
|
|
58
70
|
mode?: 'audit' | 'enforce';
|
|
59
71
|
onUnmapped?: 'allow' | 'deny';
|
|
60
72
|
publicPaths?: string[];
|
|
@@ -50,4 +50,11 @@ export declare function getRouteManifest(db: NodePgDatabase, claimsTable: PgTabl
|
|
|
50
50
|
path: string;
|
|
51
51
|
method: string;
|
|
52
52
|
}>>;
|
|
53
|
+
/**
|
|
54
|
+
* Build the role→claim-actions manifest a reverse proxy consumes in RESOLVE mode
|
|
55
|
+
* (where the JWT carries roles but not claims). Returns each role's claim actions
|
|
56
|
+
* scoped to a service (`{serviceId}.` prefix, plus the bare `{serviceId}` grant),
|
|
57
|
+
* so the proxy can resolve a user's roles → claim set locally. Active rows only.
|
|
58
|
+
*/
|
|
59
|
+
export declare function getRoleClaimsManifest(db: NodePgDatabase, schemaTables: Record<string, unknown>, serviceId: string): Promise<Record<string, string[]>>;
|
|
53
60
|
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.708",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|