nucleus-core-ts 0.9.712 → 0.9.714
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 +2 -2
- package/dist/src/Client/Proxy/authz.d.ts +8 -0
- package/dist/src/Client/Proxy/authz.js +13 -2
- package/dist/src/Client/Proxy/authz.test.js +89 -0
- package/dist/src/Client/Proxy/httpProxy.js +49 -2
- package/dist/src/Client/Proxy/types.d.ts +15 -0
- package/dist/src/Services/Authorization/Quota/index.d.ts +1 -0
- package/dist/src/Services/Authorization/Quota/suppress.d.ts +15 -0
- package/dist/src/Services/Authorization/Quota/suppress.test.d.ts +1 -0
- package/dist/src/Services/Authorization/Quota/types.d.ts +9 -0
- package/dist/src/Services/Authorization/SeedRunner/expandClaimPatterns.test.d.ts +1 -0
- package/dist/src/Services/Authorization/SeedRunner/index.d.ts +7 -0
- package/package.json +1 -1
|
@@ -36,6 +36,14 @@ export declare function evaluateAccess(args: {
|
|
|
36
36
|
onUnmapped: 'allow' | 'deny';
|
|
37
37
|
publicPaths?: string[];
|
|
38
38
|
godminRole: string;
|
|
39
|
+
/**
|
|
40
|
+
* Claim actions the caller has determined are currently suppressed for this user by an
|
|
41
|
+
* exceeded quota (see resolveSuppressedClaims). They are subtracted from the user's claims
|
|
42
|
+
* before the gate check; if the request would have passed on a claim that is suppressed,
|
|
43
|
+
* the decision is `deny` with reason `quota_exceeded` so a caller can enforce the usage cap
|
|
44
|
+
* even while the general claim-gate runs in audit mode. Absent/empty ⇒ classic behaviour.
|
|
45
|
+
*/
|
|
46
|
+
suppressedClaims?: string[];
|
|
39
47
|
}): AccessDecision;
|
|
40
48
|
/** Fetches + caches a service's route manifest from the IDP. */
|
|
41
49
|
export declare class ManifestCache {
|
|
@@ -71,7 +71,7 @@ function matchesGlob(path, patterns) {
|
|
|
71
71
|
* (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
|
|
72
72
|
* this stays mode-agnostic and easy to test.
|
|
73
73
|
*/ export function evaluateAccess(args) {
|
|
74
|
-
const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole } = args;
|
|
74
|
+
const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole, suppressedClaims } = args;
|
|
75
75
|
if (matchesGlob(path, publicPaths)) return {
|
|
76
76
|
decision: 'allow',
|
|
77
77
|
reason: 'public',
|
|
@@ -95,11 +95,22 @@ function matchesGlob(path, patterns) {
|
|
|
95
95
|
reason: 'godmin',
|
|
96
96
|
action
|
|
97
97
|
};
|
|
98
|
-
|
|
98
|
+
const suppressed = suppressedClaims && suppressedClaims.length ? new Set(suppressedClaims) : null;
|
|
99
|
+
const effectiveClaims = suppressed ? claims.filter((c)=>!suppressed.has(c)) : claims;
|
|
100
|
+
if (claimSatisfies(effectiveClaims, action)) return {
|
|
99
101
|
decision: 'allow',
|
|
100
102
|
reason: 'claim',
|
|
101
103
|
action
|
|
102
104
|
};
|
|
105
|
+
// Denied. If the user actually holds a claim that grants this action but a quota suppressed
|
|
106
|
+
// it, surface that distinctly — this is a usage cap, not a missing grant.
|
|
107
|
+
if (suppressed && claimSatisfies(claims, action)) {
|
|
108
|
+
return {
|
|
109
|
+
decision: 'deny',
|
|
110
|
+
reason: 'quota_exceeded',
|
|
111
|
+
action
|
|
112
|
+
};
|
|
113
|
+
}
|
|
103
114
|
return {
|
|
104
115
|
decision: 'deny',
|
|
105
116
|
reason: 'missing-claim',
|
|
@@ -186,6 +186,95 @@ describe('evaluateAccess', ()=>{
|
|
|
186
186
|
reason: 'missing-claim'
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
it('denies with reason quota_exceeded when a held claim is suppressed by a quota', ()=>{
|
|
190
|
+
const d = evaluateAccess({
|
|
191
|
+
...base,
|
|
192
|
+
method: 'GET',
|
|
193
|
+
path: '/api/v1/templates',
|
|
194
|
+
claims: [
|
|
195
|
+
'agent.templates.list'
|
|
196
|
+
],
|
|
197
|
+
suppressedClaims: [
|
|
198
|
+
'agent.templates.list'
|
|
199
|
+
]
|
|
200
|
+
});
|
|
201
|
+
expect(d).toMatchObject({
|
|
202
|
+
decision: 'deny',
|
|
203
|
+
reason: 'quota_exceeded'
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it('still allows when a NON-suppressed claim also satisfies the action', ()=>{
|
|
207
|
+
// broad claim grants the action and is not in the suppressed set → allowed
|
|
208
|
+
const d = evaluateAccess({
|
|
209
|
+
...base,
|
|
210
|
+
method: 'GET',
|
|
211
|
+
path: '/api/v1/templates',
|
|
212
|
+
claims: [
|
|
213
|
+
'agent.templates.list',
|
|
214
|
+
'agent'
|
|
215
|
+
],
|
|
216
|
+
suppressedClaims: [
|
|
217
|
+
'agent.templates.list'
|
|
218
|
+
]
|
|
219
|
+
});
|
|
220
|
+
expect(d).toMatchObject({
|
|
221
|
+
decision: 'allow',
|
|
222
|
+
reason: 'claim'
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
it('suppression never elevates godmin denial (godmin bypasses by role)', ()=>{
|
|
226
|
+
const d = evaluateAccess({
|
|
227
|
+
...base,
|
|
228
|
+
method: 'GET',
|
|
229
|
+
path: '/api/v1/templates',
|
|
230
|
+
roles: [
|
|
231
|
+
'godmin'
|
|
232
|
+
],
|
|
233
|
+
claims: [
|
|
234
|
+
'agent.templates.list'
|
|
235
|
+
],
|
|
236
|
+
suppressedClaims: [
|
|
237
|
+
'agent.templates.list'
|
|
238
|
+
]
|
|
239
|
+
});
|
|
240
|
+
expect(d).toMatchObject({
|
|
241
|
+
decision: 'allow',
|
|
242
|
+
reason: 'godmin'
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
it('a missing claim stays missing-claim (not quota_exceeded) under suppression', ()=>{
|
|
246
|
+
// the user never held the claim; an unrelated suppression must not relabel it
|
|
247
|
+
const d = evaluateAccess({
|
|
248
|
+
...base,
|
|
249
|
+
method: 'DELETE',
|
|
250
|
+
path: '/api/v1/templates/1',
|
|
251
|
+
claims: [
|
|
252
|
+
'agent.templates.list'
|
|
253
|
+
],
|
|
254
|
+
suppressedClaims: [
|
|
255
|
+
'something.else'
|
|
256
|
+
]
|
|
257
|
+
});
|
|
258
|
+
expect(d).toMatchObject({
|
|
259
|
+
decision: 'deny',
|
|
260
|
+
reason: 'missing-claim'
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
it('empty suppressedClaims is identical to classic behaviour', ()=>{
|
|
264
|
+
const d = evaluateAccess({
|
|
265
|
+
...base,
|
|
266
|
+
method: 'GET',
|
|
267
|
+
path: '/api/v1/templates',
|
|
268
|
+
claims: [
|
|
269
|
+
'agent.templates.list'
|
|
270
|
+
],
|
|
271
|
+
suppressedClaims: []
|
|
272
|
+
});
|
|
273
|
+
expect(d).toMatchObject({
|
|
274
|
+
decision: 'allow',
|
|
275
|
+
reason: 'claim'
|
|
276
|
+
});
|
|
277
|
+
});
|
|
189
278
|
});
|
|
190
279
|
describe('resolveClaimsFromRoles (resolve mode)', ()=>{
|
|
191
280
|
const roleClaims = {
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
|
|
2
2
|
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
|
+
/**
|
|
4
|
+
* Ask the IDP which claim actions are currently suppressed for the caller (an exceeded
|
|
5
|
+
* quota that gates them). Uses the caller's OWN cookies — no service secret. Fail-open: any
|
|
6
|
+
* error yields an empty set, so a transient IDP blip never blocks traffic (the cap is
|
|
7
|
+
* re-checked on the next request).
|
|
8
|
+
*/ async function fetchSuppressedClaims(url, cookieHeader, logger) {
|
|
9
|
+
if (!cookieHeader) return [];
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
headers: {
|
|
13
|
+
cookie: cookieHeader
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) return [];
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
return Array.isArray(body?.data?.suppressedClaims) ? body.data.suppressedClaims : [];
|
|
19
|
+
} catch (err) {
|
|
20
|
+
logger.warn(`[authz] suppressed-claims fetch failed (fail-open): ${err instanceof Error ? err.message : String(err)}`);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
3
24
|
const pendingRefreshes = new Map();
|
|
4
25
|
function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
|
|
5
26
|
for (const header of setCookieHeaders){
|
|
@@ -177,6 +198,12 @@ export function createHttpProxyHandler(config) {
|
|
|
177
198
|
} else {
|
|
178
199
|
claims = Array.isArray(payload?.claims) ? payload.claims : [];
|
|
179
200
|
}
|
|
201
|
+
// Generic quota enforcement (opt-in): subtract the caller's quota-suppressed claims.
|
|
202
|
+
// Skipped for godmin (bypasses by role) and anonymous callers (nothing to suppress).
|
|
203
|
+
let suppressedClaims;
|
|
204
|
+
if (az.quotaSuppression?.url && payload !== null && !roles.includes(az.godminRole ?? 'godmin')) {
|
|
205
|
+
suppressedClaims = await fetchSuppressedClaims(az.quotaSuppression.url, req.headers.get('cookie'), logger);
|
|
206
|
+
}
|
|
180
207
|
const decision = evaluateAccess({
|
|
181
208
|
manifest,
|
|
182
209
|
method: req.method,
|
|
@@ -186,14 +213,34 @@ export function createHttpProxyHandler(config) {
|
|
|
186
213
|
claims,
|
|
187
214
|
onUnmapped: az.onUnmapped ?? 'deny',
|
|
188
215
|
publicPaths: az.publicPaths,
|
|
189
|
-
godminRole: az.godminRole ?? 'godmin'
|
|
216
|
+
godminRole: az.godminRole ?? 'godmin',
|
|
217
|
+
suppressedClaims
|
|
190
218
|
});
|
|
191
219
|
if (decision.decision === 'allow') return null;
|
|
192
220
|
const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
|
|
193
|
-
|
|
221
|
+
// A quota breach is a HARD usage cap: enforce it even while the general gate is auditing.
|
|
222
|
+
// Every other would-block (missing-claim, unmapped) still only logs in audit mode.
|
|
223
|
+
const isQuota = decision.reason === 'quota_exceeded';
|
|
224
|
+
if ((az.mode ?? 'enforce') === 'audit' && !isQuota) {
|
|
194
225
|
logger.warn(`[authz audit] WOULD block ${detail}`);
|
|
195
226
|
return null;
|
|
196
227
|
}
|
|
228
|
+
if (isQuota) {
|
|
229
|
+
logger.warn(`[authz] 429 quota ${detail}`);
|
|
230
|
+
return new Response(JSON.stringify({
|
|
231
|
+
success: false,
|
|
232
|
+
error: 'quota_exceeded',
|
|
233
|
+
reason: 'quota_exceeded',
|
|
234
|
+
message: 'Quota exceeded',
|
|
235
|
+
action: decision.action
|
|
236
|
+
}), {
|
|
237
|
+
status: 429,
|
|
238
|
+
headers: {
|
|
239
|
+
'content-type': 'application/json',
|
|
240
|
+
'retry-after': '3600'
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
197
244
|
logger.warn(`[authz] 403 ${detail}`);
|
|
198
245
|
return new Response(JSON.stringify({
|
|
199
246
|
success: false,
|
|
@@ -81,6 +81,21 @@ export interface ProxyAuthorizeConfig {
|
|
|
81
81
|
onUnmapped?: 'allow' | 'deny';
|
|
82
82
|
publicPaths?: string[];
|
|
83
83
|
godminRole?: string;
|
|
84
|
+
/**
|
|
85
|
+
* OPTIONAL generic quota enforcement. When set, before deciding the proxy asks the IDP
|
|
86
|
+
* which claim actions are currently SUPPRESSED for the caller by an exceeded quota (GETs
|
|
87
|
+
* this URL with the caller's own cookies) and subtracts them from the caller's claims. A
|
|
88
|
+
* request that would have passed on a now-suppressed claim is denied `quota_exceeded` —
|
|
89
|
+
* and that specific denial is enforced EVEN IN `audit` mode, because a usage cap is a hard
|
|
90
|
+
* limit (all other would-blocks still just log while auditing). Fail-open on any fetch
|
|
91
|
+
* error so a transient IDP blip never breaks traffic. Nothing here names an endpoint or a
|
|
92
|
+
* metric: what a quota disables is pure data (the quota's `gates`), so the same wiring
|
|
93
|
+
* serves any project.
|
|
94
|
+
*/
|
|
95
|
+
quotaSuppression?: {
|
|
96
|
+
/** GET endpoint returning `{ data: { suppressedClaims: string[] } }` for the caller. */
|
|
97
|
+
url: string;
|
|
98
|
+
};
|
|
84
99
|
}
|
|
85
100
|
export interface HttpProxyTarget {
|
|
86
101
|
url: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { aggregateLimit, evaluateQuota, parseQuotaPolicy } from './evaluate';
|
|
2
2
|
export { type ResolvedQuota, resolveUserQuotas } from './resolve';
|
|
3
|
+
export { resolveSuppressedClaims } from './suppress';
|
|
3
4
|
export type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaOp, QuotaPolicy, QuotaWindow, } from './types';
|
|
4
5
|
export { bucketDatesForWindow, buildIncrementKeys, buildQuotaKeys } from './windows';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedQuota } from './resolve';
|
|
2
|
+
import type { CounterReader, QuotaContext } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* The set of claim actions a user has LOST right now because a quota that gates them is
|
|
5
|
+
* exceeded. This is the one generic hinge of quota enforcement: for every resolved quota
|
|
6
|
+
* that carries `gates`, read its live counter (reader injected — stays pure/testable) and,
|
|
7
|
+
* if it is over the limit, add its gated claim actions to the suppressed set. Callers then
|
|
8
|
+
* subtract this set from the user's effective claims, so the ordinary claim-gate denies the
|
|
9
|
+
* gated capabilities. nucleus never learns what is being metered or which endpoint is hit —
|
|
10
|
+
* a quota decides what it disables purely by the claim actions listed in its policy.
|
|
11
|
+
*
|
|
12
|
+
* Metering-only quotas (no `gates`) never suppress anything; they exist just to be reported.
|
|
13
|
+
* Returns a de-duplicated list (order not significant).
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveSuppressedClaims(quotas: readonly ResolvedQuota[], read: CounterReader, ctx: QuotaContext): Promise<string[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -37,6 +37,15 @@ export interface QuotaPolicy {
|
|
|
37
37
|
aggregate?: QuotaAggregate;
|
|
38
38
|
/** Optional display unit, e.g. 'tokens' | 'usd'. */
|
|
39
39
|
unit?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Claim actions this quota SUPPRESSES while it is exceeded. When a user is over this
|
|
42
|
+
* quota, these claim actions are removed from their effective claim set for the rest of
|
|
43
|
+
* the window, so any capability guarded by them is denied through the normal claim-gate.
|
|
44
|
+
* This is what makes enforcement generic: nucleus never names an endpoint or a metric —
|
|
45
|
+
* a project decides what a quota gates purely by listing claim actions here (data, not
|
|
46
|
+
* code). Absent/empty = a metering-only quota (reported for status, gates nothing).
|
|
47
|
+
*/
|
|
48
|
+
gates?: string[];
|
|
40
49
|
}
|
|
41
50
|
export interface QuotaContext {
|
|
42
51
|
userId: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
2
|
import type { Logger } from '../../Logger';
|
|
3
|
+
/**
|
|
4
|
+
* Expand claim-assignment patterns into concrete claim actions. A trailing `.*` is a PREFIX
|
|
5
|
+
* grant: it matches the prefix itself plus every action beneath it (`llm.conversations.*` →
|
|
6
|
+
* `llm.conversations.create…`, `…list…`, …). Everything else is taken literally. De-duplicated.
|
|
7
|
+
* Pure so it is unit-testable; the caller supplies the known claim actions.
|
|
8
|
+
*/
|
|
9
|
+
export declare function expandClaimPatterns(patterns: string[], allActions: readonly string[]): string[];
|
|
3
10
|
type SeedConfig = {
|
|
4
11
|
roles?: Array<{
|
|
5
12
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.714",
|
|
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",
|