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.
@@ -21,12 +21,18 @@ export interface AccessDecision {
21
21
  reason: string;
22
22
  action: string | null;
23
23
  }
24
- /** Pure authorization decision (mode-independent). */
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
- payload: Record<string, unknown> | null;
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
- /** Pure authorization decision (mode-independent). */ export function evaluateAccess(args) {
70
- const { manifest, method, path, payload, onUnmapped, publicPaths, godminRole } = args;
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 (!payload) return {
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
- payload: null,
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
- payload: null
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
- payload: payload([], [
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
- payload: payload([
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
- payload: payload([
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.706",
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",