nucleus-core-ts 0.9.716 → 0.9.718
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 +5 -5
- package/dist/src/Client/Proxy/authz.d.ts +15 -5
- package/dist/src/Client/Proxy/authz.js +8 -5
- package/dist/src/Client/Proxy/authz.test.js +29 -2
- package/dist/src/Client/Proxy/httpProxy.js +33 -25
- package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +4 -29
- package/dist/src/ElysiaPlugin/routes/shared/adminGuard.d.ts +16 -0
- package/dist/src/Services/Authorization/ClaimGuard/evaluate.d.ts +36 -0
- package/dist/src/Services/Authorization/ClaimGuard/index.d.ts +4 -0
- package/dist/src/Services/Authorization/ClaimGuard/resolve.d.ts +37 -0
- package/dist/src/Services/Authorization/ClaimGuard/seed.d.ts +16 -0
- package/dist/src/Services/Authorization/ClaimGuard/types.d.ts +52 -0
- package/dist/src/types.d.ts +13 -17
- package/package.json +1 -1
- package/dist/src/Services/Authorization/Quota/canonical.test.d.ts +0 -1
- package/dist/src/Services/Authorization/Quota/evaluate.d.ts +0 -15
- package/dist/src/Services/Authorization/Quota/index.d.ts +0 -5
- package/dist/src/Services/Authorization/Quota/quota.test.d.ts +0 -1
- package/dist/src/Services/Authorization/Quota/resolve.d.ts +0 -16
- package/dist/src/Services/Authorization/Quota/suppress.d.ts +0 -15
- package/dist/src/Services/Authorization/Quota/suppress.test.d.ts +0 -1
- package/dist/src/Services/Authorization/Quota/types.d.ts +0 -80
- package/dist/src/Services/Authorization/Quota/windows.d.ts +0 -44
- /package/dist/src/{ElysiaPlugin/routes/authorization/consume.test.d.ts → Services/Authorization/ClaimGuard/claimGuard.test.d.ts} +0 -0
|
@@ -20,6 +20,9 @@ export interface AccessDecision {
|
|
|
20
20
|
decision: 'allow' | 'deny';
|
|
21
21
|
reason: string;
|
|
22
22
|
action: string | null;
|
|
23
|
+
/** When a ClaimGuard caused the denial, the guard's action (e.g. `token_spent_today`) so the
|
|
24
|
+
* client can render a human-readable message. */
|
|
25
|
+
guard?: string;
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
|
|
@@ -37,13 +40,20 @@ export declare function evaluateAccess(args: {
|
|
|
37
40
|
publicPaths?: string[];
|
|
38
41
|
godminRole: string;
|
|
39
42
|
/**
|
|
40
|
-
* Claim actions
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* even while the general claim-gate runs in audit mode. Absent/empty ⇒ classic behaviour.
|
|
43
|
+
* Claim actions currently invalidated for this user by a ClaimGuard whose condition holds
|
|
44
|
+
* (see resolveClaimGuards). They are subtracted from the user's claims before the gate check;
|
|
45
|
+
* if the request would have passed on a claim that is invalidated, the decision is `deny`
|
|
46
|
+
* with reason `claim_guard`, naming the firing guard. Absent/empty ⇒ classic behaviour.
|
|
45
47
|
*/
|
|
46
48
|
suppressedClaims?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Guards that fired for this user (from /suppressed-claims), used to NAME the guard in a
|
|
51
|
+
* denial. Each guard's `invalidates` are the claim actions it removed.
|
|
52
|
+
*/
|
|
53
|
+
firedGuards?: ReadonlyArray<{
|
|
54
|
+
action: string;
|
|
55
|
+
invalidates: string[];
|
|
56
|
+
}>;
|
|
47
57
|
}): AccessDecision;
|
|
48
58
|
/** Fetches + caches a service's route manifest from the IDP. */
|
|
49
59
|
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, suppressedClaims } = args;
|
|
74
|
+
const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole, suppressedClaims, firedGuards } = args;
|
|
75
75
|
if (matchesGlob(path, publicPaths)) return {
|
|
76
76
|
decision: 'allow',
|
|
77
77
|
reason: 'public',
|
|
@@ -102,13 +102,16 @@ function matchesGlob(path, patterns) {
|
|
|
102
102
|
reason: 'claim',
|
|
103
103
|
action
|
|
104
104
|
};
|
|
105
|
-
// Denied. If the user actually holds a claim that grants this action but a
|
|
106
|
-
// it, surface that distinctly
|
|
105
|
+
// Denied. If the user actually holds a claim that grants this action but a ClaimGuard
|
|
106
|
+
// invalidated it, surface that distinctly (a live condition, not a missing grant) and name
|
|
107
|
+
// the firing guard so the client can render a human-readable message.
|
|
107
108
|
if (suppressed && claimSatisfies(claims, action)) {
|
|
109
|
+
const guard = firedGuards?.find((g)=>g.invalidates.some((iv)=>iv === action || action.startsWith(`${iv}.`)))?.action;
|
|
108
110
|
return {
|
|
109
111
|
decision: 'deny',
|
|
110
|
-
reason: '
|
|
111
|
-
action
|
|
112
|
+
reason: 'claim_guard',
|
|
113
|
+
action,
|
|
114
|
+
guard
|
|
112
115
|
};
|
|
113
116
|
}
|
|
114
117
|
return {
|
|
@@ -186,7 +186,33 @@ describe('evaluateAccess', ()=>{
|
|
|
186
186
|
reason: 'missing-claim'
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
-
it('denies with reason
|
|
189
|
+
it('denies with reason claim_guard, naming the firing guard, when a held claim is invalidated', ()=>{
|
|
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
|
+
firedGuards: [
|
|
201
|
+
{
|
|
202
|
+
action: 'token_spent_today',
|
|
203
|
+
invalidates: [
|
|
204
|
+
'agent.templates.list'
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
});
|
|
209
|
+
expect(d).toMatchObject({
|
|
210
|
+
decision: 'deny',
|
|
211
|
+
reason: 'claim_guard',
|
|
212
|
+
guard: 'token_spent_today'
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
it('claim_guard denial still works (guard undefined) when firedGuards is not supplied', ()=>{
|
|
190
216
|
const d = evaluateAccess({
|
|
191
217
|
...base,
|
|
192
218
|
method: 'GET',
|
|
@@ -200,8 +226,9 @@ describe('evaluateAccess', ()=>{
|
|
|
200
226
|
});
|
|
201
227
|
expect(d).toMatchObject({
|
|
202
228
|
decision: 'deny',
|
|
203
|
-
reason: '
|
|
229
|
+
reason: 'claim_guard'
|
|
204
230
|
});
|
|
231
|
+
expect(d.guard).toBeUndefined();
|
|
205
232
|
});
|
|
206
233
|
it('still allows when a NON-suppressed claim also satisfies the action', ()=>{
|
|
207
234
|
// broad claim grants the action and is not in the suppressed set → allowed
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
|
|
2
2
|
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (!cookieHeader) return [];
|
|
3
|
+
async function fetchSuppressedClaims(url, cookieHeader, logger) {
|
|
4
|
+
const empty = {
|
|
5
|
+
suppressedClaims: [],
|
|
6
|
+
firedGuards: []
|
|
7
|
+
};
|
|
8
|
+
if (!cookieHeader) return empty;
|
|
10
9
|
try {
|
|
11
10
|
const res = await fetch(url, {
|
|
12
11
|
headers: {
|
|
13
12
|
cookie: cookieHeader
|
|
14
13
|
}
|
|
15
14
|
});
|
|
16
|
-
if (!res.ok) return
|
|
15
|
+
if (!res.ok) return empty;
|
|
17
16
|
const body = await res.json();
|
|
18
|
-
return
|
|
17
|
+
return {
|
|
18
|
+
suppressedClaims: Array.isArray(body?.data?.suppressedClaims) ? body.data.suppressedClaims : [],
|
|
19
|
+
firedGuards: Array.isArray(body?.data?.firedGuards) ? body.data.firedGuards.map((g)=>({
|
|
20
|
+
action: String(g?.action ?? ''),
|
|
21
|
+
invalidates: Array.isArray(g?.invalidates) ? g.invalidates : []
|
|
22
|
+
})) : []
|
|
23
|
+
};
|
|
19
24
|
} catch (err) {
|
|
20
25
|
logger.warn(`[authz] suppressed-claims fetch failed (fail-open): ${err instanceof Error ? err.message : String(err)}`);
|
|
21
|
-
return
|
|
26
|
+
return empty;
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
const pendingRefreshes = new Map();
|
|
@@ -198,11 +203,11 @@ export function createHttpProxyHandler(config) {
|
|
|
198
203
|
} else {
|
|
199
204
|
claims = Array.isArray(payload?.claims) ? payload.claims : [];
|
|
200
205
|
}
|
|
201
|
-
// Generic
|
|
202
|
-
// Skipped for godmin (bypasses by role) and anonymous callers (nothing to
|
|
203
|
-
let
|
|
206
|
+
// Generic ClaimGuard enforcement (opt-in): subtract the caller's guard-invalidated claims.
|
|
207
|
+
// Skipped for godmin (bypasses by role) and anonymous callers (nothing to invalidate).
|
|
208
|
+
let suppression;
|
|
204
209
|
if (az.quotaSuppression?.url && payload !== null && !roles.includes(az.godminRole ?? 'godmin')) {
|
|
205
|
-
|
|
210
|
+
suppression = await fetchSuppressedClaims(az.quotaSuppression.url, req.headers.get('cookie'), logger);
|
|
206
211
|
}
|
|
207
212
|
const decision = evaluateAccess({
|
|
208
213
|
manifest,
|
|
@@ -214,24 +219,27 @@ export function createHttpProxyHandler(config) {
|
|
|
214
219
|
onUnmapped: az.onUnmapped ?? 'deny',
|
|
215
220
|
publicPaths: az.publicPaths,
|
|
216
221
|
godminRole: az.godminRole ?? 'godmin',
|
|
217
|
-
suppressedClaims
|
|
222
|
+
suppressedClaims: suppression?.suppressedClaims,
|
|
223
|
+
firedGuards: suppression?.firedGuards
|
|
218
224
|
});
|
|
219
225
|
if (decision.decision === 'allow') return null;
|
|
220
|
-
const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
|
|
221
|
-
// A
|
|
222
|
-
// Every other would-block (missing-claim, unmapped) still only logs in audit mode.
|
|
223
|
-
const
|
|
224
|
-
if ((az.mode ?? 'enforce') === 'audit' && !
|
|
226
|
+
const detail = `${req.method} ${path} (${decision.reason}${decision.guard ? ` guard=${decision.guard}` : ''}${decision.action ? ` need ${decision.action}` : ''})`;
|
|
227
|
+
// A ClaimGuard block is a HARD, live condition: enforce it even while the general gate is
|
|
228
|
+
// auditing. Every other would-block (missing-claim, unmapped) still only logs in audit mode.
|
|
229
|
+
const isGuard = decision.reason === 'claim_guard';
|
|
230
|
+
if ((az.mode ?? 'enforce') === 'audit' && !isGuard) {
|
|
225
231
|
logger.warn(`[authz audit] WOULD block ${detail}`);
|
|
226
232
|
return null;
|
|
227
233
|
}
|
|
228
|
-
if (
|
|
229
|
-
logger.warn(`[authz] 429
|
|
234
|
+
if (isGuard) {
|
|
235
|
+
logger.warn(`[authz] 429 claim_guard ${detail}`);
|
|
230
236
|
return new Response(JSON.stringify({
|
|
231
237
|
success: false,
|
|
232
|
-
error: '
|
|
233
|
-
reason: '
|
|
234
|
-
|
|
238
|
+
error: 'claim_guard',
|
|
239
|
+
reason: 'claim_guard',
|
|
240
|
+
// Name the firing guard so the client can render a human-readable message.
|
|
241
|
+
guard: decision.guard ?? null,
|
|
242
|
+
message: decision.guard ? `Blocked by guard: ${decision.guard}` : 'Blocked by a usage guard',
|
|
235
243
|
action: decision.action
|
|
236
244
|
}), {
|
|
237
245
|
status: 429,
|
|
@@ -11,36 +11,11 @@ export interface AuthorizationRoutesConfig {
|
|
|
11
11
|
token?: string;
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
-
* Reads
|
|
15
|
-
* RedisManager.
|
|
16
|
-
*
|
|
14
|
+
* Reads one raw value from the shared state store (the app writes ClaimGuard keys there).
|
|
15
|
+
* Wired to RedisManager.read (Dapr state) when configured; when absent, guards can't be
|
|
16
|
+
* evaluated so NOTHING is suppressed (fail-open, never over-block).
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Increments usage counters for the quota `/consume` (metering) endpoint. Wired to
|
|
21
|
-
* RedisManager.incrementCounter; when absent, `/consume` returns 503. Each item carries an
|
|
22
|
-
* optional TTL (rolling daily buckets self-expire; the `total` counter is permanent).
|
|
23
|
-
*/
|
|
24
|
-
incrementCounters?: (items: Array<{
|
|
25
|
-
key: string;
|
|
26
|
-
delta: number;
|
|
27
|
-
ttlSeconds?: number;
|
|
28
|
-
}>) => Promise<Array<{
|
|
29
|
-
key: string;
|
|
30
|
-
value: number;
|
|
31
|
-
}>>;
|
|
32
|
-
/**
|
|
33
|
-
* Prefix for canonical (metric-keyed) usage counters written by `/consume` and read by a
|
|
34
|
-
* quota that omits `keyTemplate`. Default `'quota'`. Must match the spender's prefix so
|
|
35
|
-
* metering keys and read keys agree across the deployment.
|
|
36
|
-
*/
|
|
37
|
-
canonicalPrefix?: string;
|
|
38
|
-
/**
|
|
39
|
-
* Optional allowlist of metric names `/consume` will record. When set, any metric NOT in
|
|
40
|
-
* the list is ignored (bounds Redis key cardinality against arbitrary metric names).
|
|
41
|
-
* Undefined = meter every metric present in the usage payload (default, fully generic).
|
|
42
|
-
*/
|
|
43
|
-
allowedMetrics?: string[];
|
|
18
|
+
readState?: (key: string) => Promise<unknown>;
|
|
44
19
|
}
|
|
45
20
|
/**
|
|
46
21
|
* Endpoint-discovery admin + manifest routes.
|
|
@@ -20,6 +20,22 @@ export interface AdminGuardDeps {
|
|
|
20
20
|
* fallback) — e.g. the payment plugin.
|
|
21
21
|
*/
|
|
22
22
|
export declare function hasGodminRole(request: Request): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Authoritative godmin check via the DB `user_roles`→`roles` join.
|
|
25
|
+
*
|
|
26
|
+
* Unlike the header-only {@link hasGodminRole}, this does not depend on the
|
|
27
|
+
* `x-user-roles` header being fresh. The header is populated from the JWT
|
|
28
|
+
* `roles` claim, which can lag the DB after a token refresh — a header-only
|
|
29
|
+
* gate would then lock a REAL godmin out of RBAC management (assigning,
|
|
30
|
+
* patching, or removing role_claims) with a spurious 403, even though the DB
|
|
31
|
+
* still records the godmin role. Use as a fallback when the header check fails.
|
|
32
|
+
*
|
|
33
|
+
* Best-effort: returns false (never throws) when the DB/tables are missing or
|
|
34
|
+
* the query fails, so it can only ever GRANT access to a genuine godmin — never
|
|
35
|
+
* deny a real request on infrastructure hiccups beyond the header path already
|
|
36
|
+
* having failed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function userHasGodminRoleInDb(db: NodePgDatabase | null | undefined, schemaTables: Record<string, unknown>, userId: string | null | undefined, logger: Logger): Promise<boolean>;
|
|
23
39
|
/**
|
|
24
40
|
* Returns true when the request is made by a godmin user.
|
|
25
41
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ClaimGuardCatalogEntry, type GuardCondition, type GuardPolicy, type GuardValueType } from './types';
|
|
2
|
+
/** The claim action a catalog entry seeds: the `name` up to the first `{`, trailing `:` stripped. */
|
|
3
|
+
export declare function guardActionFromName(name: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Parse one config `claimGuards` entry into the claim action + the `policy` jsonb to store on the
|
|
6
|
+
* seeded claim. Returns null for a malformed entry (missing name or unknown value type).
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseGuardCatalogEntry(entry: unknown): {
|
|
9
|
+
action: string;
|
|
10
|
+
policy: GuardPolicy;
|
|
11
|
+
} | null;
|
|
12
|
+
/** Parse a claim's `policy` jsonb into a GuardPolicy, or null if it isn't a guard. */
|
|
13
|
+
export declare function parseGuardPolicy(raw: unknown): GuardPolicy | null;
|
|
14
|
+
/** Substitute `{userId}` / `{tenant}` in a key template. Tenant defaults to `default`. */
|
|
15
|
+
export declare function resolveGuardKey(keyTemplate: string, ctx: {
|
|
16
|
+
userId: string;
|
|
17
|
+
tenant?: string;
|
|
18
|
+
}): string;
|
|
19
|
+
/**
|
|
20
|
+
* Parse the assignment condition (JSON stored in `role_claims.scope`) for a guard-claim of the
|
|
21
|
+
* given value type. Validates the operator is allowed for the type and the threshold fits the
|
|
22
|
+
* type. Returns null when the scope isn't a valid guard condition (so a plain scope string on a
|
|
23
|
+
* non-guard claim is simply ignored by the guard resolver).
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseGuardCondition(scope: unknown, valueType: GuardValueType): GuardCondition | null;
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate a guard: does the condition HOLD for the current state value? `true` ⇒ invalidate the
|
|
28
|
+
* condition's claims. Absent/unparseable state ⇒ `false` (fail-open — never invalidate on missing
|
|
29
|
+
* data). Comparison is done in the declared type.
|
|
30
|
+
*/
|
|
31
|
+
export declare function guardConditionHolds(rawValue: unknown, valueType: GuardValueType, condition: GuardCondition): boolean;
|
|
32
|
+
/** Convenience: parse a config `claimGuards` array into catalog entries (invalid entries dropped). */
|
|
33
|
+
export declare function parseClaimGuardCatalog(entries: readonly ClaimGuardCatalogEntry[] | undefined): Array<{
|
|
34
|
+
action: string;
|
|
35
|
+
policy: GuardPolicy;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { guardActionFromName, guardConditionHolds, parseClaimGuardCatalog, parseGuardCatalogEntry, parseGuardCondition, parseGuardPolicy, resolveGuardKey, } from './evaluate';
|
|
2
|
+
export { type GuardResolution, resolveClaimGuards } from './resolve';
|
|
3
|
+
export { seedClaimGuards } from './seed';
|
|
4
|
+
export { type ClaimGuardCatalogEntry, type FiredGuard, type GuardCondition, type GuardOp, type GuardPolicy, type GuardValueType, OPS_BY_TYPE, type StateReader, } from './types';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
|
+
import type { FiredGuard, GuardOp, GuardValueType, StateReader } from './types';
|
|
3
|
+
/** One fully-evaluated guard assignment for a user — powers both enforcement and display. */
|
|
4
|
+
export interface EvaluatedGuard {
|
|
5
|
+
/** Guard claim action, e.g. `token_spent_today`. */
|
|
6
|
+
action: string;
|
|
7
|
+
/** Resolved state key that was read. */
|
|
8
|
+
key: string;
|
|
9
|
+
valueType: GuardValueType;
|
|
10
|
+
op: GuardOp;
|
|
11
|
+
/** The threshold from the assignment condition. */
|
|
12
|
+
threshold: number | boolean | string;
|
|
13
|
+
/** Current value read from state (null when absent — fail-open). */
|
|
14
|
+
current: number | boolean | string | null;
|
|
15
|
+
/** Claim actions this guard invalidates when it holds. */
|
|
16
|
+
invalidates: string[];
|
|
17
|
+
/** Whether the condition currently holds (⇒ the claims are invalidated). */
|
|
18
|
+
holds: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface GuardResolution {
|
|
21
|
+
/** Every evaluated guard assignment (for the display endpoint). */
|
|
22
|
+
guards: EvaluatedGuard[];
|
|
23
|
+
/** Claim actions to remove from the user's effective claims right now (union over holding guards). */
|
|
24
|
+
suppressedClaims: string[];
|
|
25
|
+
/** The guards that fired (for a denial that names the guard). */
|
|
26
|
+
firedGuards: FiredGuard[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve which of a user's claims are invalidated RIGHT NOW by ClaimGuards. For every
|
|
30
|
+
* guard-claim the user holds (via any active role), read its state key from the shared store,
|
|
31
|
+
* evaluate the assignment's condition, and if it holds, invalidate that assignment's claim list.
|
|
32
|
+
* Reads whole small authz tables and filters in-memory, mirroring the manifest/quota resolvers.
|
|
33
|
+
* The state reader is injected so this stays testable and store-agnostic.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveClaimGuards(db: NodePgDatabase, schemaTables: Record<string, unknown>, userId: string, read: StateReader, ctx?: {
|
|
36
|
+
tenant?: string;
|
|
37
|
+
}): Promise<GuardResolution>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
|
+
import type { Logger } from 'src/Services/Logger';
|
|
3
|
+
import type { ClaimGuardCatalogEntry } from './types';
|
|
4
|
+
interface SeedGuardResult {
|
|
5
|
+
created: number;
|
|
6
|
+
updated: number;
|
|
7
|
+
deactivated: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Seed a claim per config `claimGuards` entry (action = name up to the first `{`, with the guard
|
|
11
|
+
* `policy` jsonb `{kind:'guard',keyTemplate,valueType}`). Idempotent + reconciling: an entry
|
|
12
|
+
* whose policy drifted is updated; a guard-claim no longer in config is deactivated (never
|
|
13
|
+
* deleted, mirroring endpoint-discovery reconcile). Non-guard claims are left untouched.
|
|
14
|
+
*/
|
|
15
|
+
export declare function seedClaimGuards(db: NodePgDatabase, schemaTables: Record<string, unknown>, claimGuards: readonly ClaimGuardCatalogEntry[] | undefined, logger?: Logger): Promise<SeedGuardResult>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaimGuard — a generic, config-driven claim-invalidation primitive. A project defines a
|
|
3
|
+
* catalog of comparable keys (config `authorization.claimGuards`); godmin attaches a condition
|
|
4
|
+
* to a guard-claim and a list of claims to invalidate when the condition holds; at runtime
|
|
5
|
+
* nucleus reads the key from the shared Redis/Dapr state, evaluates the condition, and removes
|
|
6
|
+
* the listed claims from the user's effective set. nucleus names no metric/endpoint/window —
|
|
7
|
+
* quota is just one instantiation (see docs/specs/2026-07-04-claim-guards-design.md).
|
|
8
|
+
*/
|
|
9
|
+
/** The value type of a guard key, declared in config; drives parsing + which operators are valid. */
|
|
10
|
+
export type GuardValueType = 'int' | 'boolean' | 'string';
|
|
11
|
+
export type GuardOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne';
|
|
12
|
+
/** Which operators are valid for each declared value type (int is ordered; bool/string are equality only). */
|
|
13
|
+
export declare const OPS_BY_TYPE: Record<GuardValueType, readonly GuardOp[]>;
|
|
14
|
+
/**
|
|
15
|
+
* The policy stored on a guard CLAIM (seeded from a config `claimGuards` entry). `keyTemplate`
|
|
16
|
+
* is the full config `name` (e.g. `token_spent_today:{userId}`); `{userId}`/`{tenant}` are
|
|
17
|
+
* resolved per request. `valueType` flows from config so nothing is hand-typed per claim.
|
|
18
|
+
*/
|
|
19
|
+
export interface GuardPolicy {
|
|
20
|
+
kind: 'guard';
|
|
21
|
+
keyTemplate: string;
|
|
22
|
+
valueType: GuardValueType;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The condition stored on a role_claim ASSIGNMENT (as JSON in `role_claims.scope`). Set when a
|
|
26
|
+
* guard-claim is attached to a role, e.g. `token_spent_today ?gt: 100000` + selected claims.
|
|
27
|
+
* `invalidates` are the claim actions removed from the user's effective claims when the
|
|
28
|
+
* condition HOLDS — regardless of which role granted them.
|
|
29
|
+
*/
|
|
30
|
+
export interface GuardCondition {
|
|
31
|
+
op: GuardOp;
|
|
32
|
+
value: number | boolean | string;
|
|
33
|
+
invalidates: string[];
|
|
34
|
+
}
|
|
35
|
+
/** One catalog entry as it appears in config `authorization.claimGuards`. */
|
|
36
|
+
export interface ClaimGuardCatalogEntry {
|
|
37
|
+
/** Full key template, e.g. `token_spent_today:{userId}` or a global `example_other_key`. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Declared value type. Accepts the string form used in config. */
|
|
40
|
+
value: GuardValueType;
|
|
41
|
+
}
|
|
42
|
+
/** Reads a raw value from the shared state store (Dapr/Redis). Injected so the resolver stays testable. */
|
|
43
|
+
export type StateReader = (key: string) => Promise<unknown>;
|
|
44
|
+
/** A guard that fired for a user this request — surfaced so a denial can name the guard. */
|
|
45
|
+
export interface FiredGuard {
|
|
46
|
+
/** The guard claim action, e.g. `token_spent_today`. */
|
|
47
|
+
action: string;
|
|
48
|
+
/** The resolved key that was read. */
|
|
49
|
+
key: string;
|
|
50
|
+
/** The claim actions this guard invalidated. */
|
|
51
|
+
invalidates: string[];
|
|
52
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -750,24 +750,20 @@ export interface NucleusConfigOptions {
|
|
|
750
750
|
token?: string;
|
|
751
751
|
};
|
|
752
752
|
/**
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
753
|
+
* ClaimGuards — generic, config-driven claim invalidation. Each entry defines a comparable
|
|
754
|
+
* key in the shared Redis/Dapr state and seeds a claim (action = the name up to the first
|
|
755
|
+
* `{`). At assignment time godmin attaches a condition to the guard-claim and selects the
|
|
756
|
+
* claims to invalidate when it holds (`role_claims.scope` JSON `{op,value,invalidates}`); at
|
|
757
|
+
* runtime nucleus reads the key, evaluates the condition, and drops the listed claims. The
|
|
758
|
+
* APP writes the key values. nucleus names no metric/endpoint/window — in Vorion this
|
|
759
|
+
* surfaces as a quota. See docs/specs/2026-07-04-claim-guards-design.md.
|
|
758
760
|
*/
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Optional allowlist of metric names `/consume` will record. When set, metrics not in
|
|
767
|
-
* the list are ignored (bounds Redis key cardinality). Undefined = meter every metric.
|
|
768
|
-
*/
|
|
769
|
-
allowedMetrics?: string[];
|
|
770
|
-
};
|
|
761
|
+
claimGuards?: Array<{
|
|
762
|
+
/** Key template, e.g. `token_spent_today:{userId}` or a global `feature_flag_x`. */
|
|
763
|
+
name: string;
|
|
764
|
+
/** Declared value type: drives parsing, valid operators, and threshold parsing. */
|
|
765
|
+
value: 'int' | 'boolean' | 'string';
|
|
766
|
+
}>;
|
|
771
767
|
};
|
|
772
768
|
audit?: {
|
|
773
769
|
enabled?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.718",
|
|
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",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaPolicy } from './types';
|
|
2
|
-
/** Parse+validate a raw `claims.policy` jsonb value into a QuotaPolicy, or null if it isn't one. */
|
|
3
|
-
export declare function parseQuotaPolicy(raw: unknown): QuotaPolicy | null;
|
|
4
|
-
/**
|
|
5
|
-
* Combine the per-role limits a user holds for one quota claim into a single effective limit.
|
|
6
|
-
* `max` (default) = tier model (best role wins); `min` = strictest; `sum` = additive.
|
|
7
|
-
* Non-numeric scopes are ignored. Returns null when no numeric limit is present.
|
|
8
|
-
*/
|
|
9
|
-
export declare function aggregateLimit(scopes: readonly string[], mode: QuotaAggregate): number | null;
|
|
10
|
-
/**
|
|
11
|
-
* Read the current usage for a quota policy and compare it to the effective limit. Pure:
|
|
12
|
-
* the counter reader is injected. Enforcement decision is the caller's — for a spend of
|
|
13
|
-
* cost C, a HARD cap allows it iff `current + C <= limit` (lte) / `< limit` (lt).
|
|
14
|
-
*/
|
|
15
|
-
export declare function evaluateQuota(policy: QuotaPolicy, ctx: QuotaContext, limit: number, read: CounterReader): Promise<QuotaEvaluation>;
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { aggregateLimit, evaluateQuota, parseQuotaPolicy } from './evaluate';
|
|
2
|
-
export { type ResolvedQuota, resolveUserQuotas } from './resolve';
|
|
3
|
-
export { resolveSuppressedClaims } from './suppress';
|
|
4
|
-
export type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaOp, QuotaPolicy, QuotaWindow, } from './types';
|
|
5
|
-
export { bucketDatesForWindow, buildCanonicalIncrementKeys, buildIncrementKeys, buildQuotaKeys, canonicalKeyTemplate, DEFAULT_CANONICAL_PREFIX, sanitizeMetric, } from './windows';
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
|
-
import type { QuotaPolicy } from './types';
|
|
3
|
-
export interface ResolvedQuota {
|
|
4
|
-
action: string;
|
|
5
|
-
policy: QuotaPolicy;
|
|
6
|
-
/** effective limit after aggregating the per-role scopes for this claim */
|
|
7
|
-
limit: number;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Resolve a user's effective quota limits. For every QUOTA claim (`claims.policy.kind==='quota'`)
|
|
11
|
-
* granted through any of the user's roles, aggregate the per-role limits — each carried in that
|
|
12
|
-
* assignment's `role_claims.scope` — using the policy's `aggregate` rule (default `max`, the tier
|
|
13
|
-
* model). Active rows only. Reads whole small authz tables and filters in-memory, mirroring the
|
|
14
|
-
* existing manifest resolver (no dynamic-column WHERE).
|
|
15
|
-
*/
|
|
16
|
-
export declare function resolveUserQuotas(db: NodePgDatabase, schemaTables: Record<string, unknown>, userId: string): Promise<ResolvedQuota[]>;
|
|
@@ -1,15 +0,0 @@
|
|
|
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[]>;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic usage-quota primitive. A quota is expressed as a CLAIM whose `policy` jsonb
|
|
3
|
-
* column carries this shape; the per-role LIMIT lives in the `role_claims.scope` of each
|
|
4
|
-
* assignment (so the same claim can be granted at different limits to different roles, and
|
|
5
|
-
* a user's effective limit is an aggregation across their roles). The counter itself is
|
|
6
|
-
* maintained by whoever does the spending (e.g. the LLM service) under the key described
|
|
7
|
-
* here — nucleus only READS it to gate. Deliberately not spend-specific: `metric` is
|
|
8
|
-
* free-form, so the same engine covers tokens, dollars, request counts, storage, etc.
|
|
9
|
-
*/
|
|
10
|
-
export type QuotaOp = 'lte' | 'lt';
|
|
11
|
-
/** How a user's per-role limits combine when they hold the claim via multiple roles. */
|
|
12
|
-
export type QuotaAggregate = 'max' | 'min' | 'sum';
|
|
13
|
-
export type QuotaWindow = {
|
|
14
|
-
type: 'rolling';
|
|
15
|
-
days: number;
|
|
16
|
-
} | {
|
|
17
|
-
type: 'calendar_day';
|
|
18
|
-
} | {
|
|
19
|
-
type: 'total';
|
|
20
|
-
};
|
|
21
|
-
export interface QuotaPolicy {
|
|
22
|
-
kind: 'quota';
|
|
23
|
-
source: 'redis';
|
|
24
|
-
/** Free-form metric label, e.g. 'tokens' | 'cost_usd' | 'requests'. */
|
|
25
|
-
metric: string;
|
|
26
|
-
window: QuotaWindow;
|
|
27
|
-
/**
|
|
28
|
-
* Redis counter key template. Placeholders: `{userId}`, `{tenant}`, `{date}`.
|
|
29
|
-
* Bucketed windows (rolling / calendar_day) substitute `{date}` (yyyy-mm-dd, UTC) once
|
|
30
|
-
* per bucket. `total` uses the template as-is (must not contain `{date}`).
|
|
31
|
-
* e.g. `usage:{tenant}:{userId}:tokens:{date}` or `usage:{tenant}:{userId}:tokens:total`.
|
|
32
|
-
*
|
|
33
|
-
* OPTIONAL: omit it to INHERIT the canonical per-metric template
|
|
34
|
-
* `<prefix>:{tenant}:{userId}:<metric>:{date}` (see canonicalKeyTemplate). Omitting is
|
|
35
|
-
* preferred for new quotas — it makes the keys this quota READS identical to the keys the
|
|
36
|
-
* `/consume` metering endpoint WRITES for the same metric, so a quota assigned mid-window
|
|
37
|
-
* already reflects the usage recorded before it existed.
|
|
38
|
-
*/
|
|
39
|
-
keyTemplate?: string;
|
|
40
|
-
/** Comparison for a HARD cap. `lte`: usage may reach the limit; `lt`: must stay below. Default `lte`. */
|
|
41
|
-
op?: QuotaOp;
|
|
42
|
-
/** Multi-role limit resolution. Default `max` (tier model: the most generous role wins). */
|
|
43
|
-
aggregate?: QuotaAggregate;
|
|
44
|
-
/** Optional display unit, e.g. 'tokens' | 'usd'. */
|
|
45
|
-
unit?: string;
|
|
46
|
-
/**
|
|
47
|
-
* Claim actions this quota SUPPRESSES while it is exceeded. When a user is over this
|
|
48
|
-
* quota, these claim actions are removed from their effective claim set for the rest of
|
|
49
|
-
* the window, so any capability guarded by them is denied through the normal claim-gate.
|
|
50
|
-
* This is what makes enforcement generic: nucleus never names an endpoint or a metric —
|
|
51
|
-
* a project decides what a quota gates purely by listing claim actions here (data, not
|
|
52
|
-
* code). Absent/empty = a metering-only quota (reported for status, gates nothing).
|
|
53
|
-
*/
|
|
54
|
-
gates?: string[];
|
|
55
|
-
}
|
|
56
|
-
export interface QuotaContext {
|
|
57
|
-
userId: string;
|
|
58
|
-
tenant?: string;
|
|
59
|
-
now: Date;
|
|
60
|
-
/**
|
|
61
|
-
* Prefix used to derive the canonical counter template when a policy omits `keyTemplate`.
|
|
62
|
-
* Must match the prefix the spender meters under (default `'quota'`). Set from
|
|
63
|
-
* `authorization.quota.canonicalPrefix` so read keys == metering keys across the deployment.
|
|
64
|
-
*/
|
|
65
|
-
canonicalPrefix?: string;
|
|
66
|
-
}
|
|
67
|
-
export interface QuotaEvaluation {
|
|
68
|
-
metric: string;
|
|
69
|
-
window: QuotaWindow;
|
|
70
|
-
limit: number;
|
|
71
|
-
current: number;
|
|
72
|
-
/** limit − current; negative when already over. */
|
|
73
|
-
remaining: number;
|
|
74
|
-
/** true when the cap is already reached (no room for another unit under `op`). */
|
|
75
|
-
over: boolean;
|
|
76
|
-
/** the concrete counter keys that were summed (for debugging / the status endpoint). */
|
|
77
|
-
keys: string[];
|
|
78
|
-
}
|
|
79
|
-
/** Reads integer counter values for a set of keys (missing → 0). Injected so the evaluator stays pure. */
|
|
80
|
-
export type CounterReader = (keys: string[]) => Promise<number[]>;
|