@intx/authz 0.1.2
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/README.md +49 -0
- package/package.json +15 -0
- package/src/conditions.test.ts +158 -0
- package/src/conditions.ts +36 -0
- package/src/evaluate.test.ts +936 -0
- package/src/evaluate.ts +122 -0
- package/src/index.ts +16 -0
- package/src/memory-store.test.ts +93 -0
- package/src/memory-store.ts +28 -0
- package/src/patterns.test.ts +89 -0
- package/src/patterns.ts +42 -0
- package/src/specificity.test.ts +91 -0
- package/src/specificity.ts +29 -0
- package/src/time-window.test.ts +187 -0
- package/src/time-window.ts +123 -0
- package/src/types.ts +28 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthzResult,
|
|
3
|
+
ConditionRegistry,
|
|
4
|
+
Effect,
|
|
5
|
+
GrantRule,
|
|
6
|
+
GrantStore,
|
|
7
|
+
MatchedGrant,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { matchPattern } from "./patterns";
|
|
10
|
+
import { grantSpecificity } from "./specificity";
|
|
11
|
+
import { evaluateConditions } from "./conditions";
|
|
12
|
+
|
|
13
|
+
const EFFECT_PRIORITY: Record<Effect, number> = {
|
|
14
|
+
allow: 0,
|
|
15
|
+
ask: 1,
|
|
16
|
+
deny: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type EvalOptions = {
|
|
20
|
+
registry?: ConditionRegistry;
|
|
21
|
+
principalId?: string;
|
|
22
|
+
tenantId?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Evaluate grants against a resource/action query.
|
|
27
|
+
*
|
|
28
|
+
* This is the core evaluation logic, separated from grant collection
|
|
29
|
+
* so it can be tested with synthetic grant lists.
|
|
30
|
+
*
|
|
31
|
+
* When a condition registry is provided, grants with non-null
|
|
32
|
+
* conditions are evaluated against it. Unknown condition keys
|
|
33
|
+
* cause an error. When no registry is provided, grants with
|
|
34
|
+
* non-null conditions are skipped (fail-closed).
|
|
35
|
+
*/
|
|
36
|
+
export async function evaluateGrants(
|
|
37
|
+
grants: GrantRule[],
|
|
38
|
+
resource: string,
|
|
39
|
+
action: string,
|
|
40
|
+
opts?: EvalOptions,
|
|
41
|
+
): Promise<AuthzResult> {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const registry = opts?.registry;
|
|
44
|
+
const ctx = {
|
|
45
|
+
now,
|
|
46
|
+
resource,
|
|
47
|
+
action,
|
|
48
|
+
principalId: opts?.principalId ?? "",
|
|
49
|
+
tenantId: opts?.tenantId ?? "",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const matching: MatchedGrant[] = [];
|
|
53
|
+
|
|
54
|
+
for (const g of grants) {
|
|
55
|
+
if (g.expiresAt !== null && g.expiresAt < now) continue;
|
|
56
|
+
if (!matchPattern(g.resource, resource)) continue;
|
|
57
|
+
if (!matchPattern(g.action, action)) continue;
|
|
58
|
+
|
|
59
|
+
if (g.conditions && Object.keys(g.conditions).length > 0) {
|
|
60
|
+
if (!registry) continue;
|
|
61
|
+
if (!(await evaluateConditions(g.conditions, ctx, registry))) continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
matching.push({
|
|
65
|
+
id: g.id,
|
|
66
|
+
resource: g.resource,
|
|
67
|
+
action: g.action,
|
|
68
|
+
effect: g.effect,
|
|
69
|
+
origin: g.origin,
|
|
70
|
+
specificity: grantSpecificity(g.resource, g.action),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (matching.length === 0) {
|
|
75
|
+
return { effect: null, matchingGrants: [], resolvedBy: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sort ascending by specificity, then by effect priority.
|
|
79
|
+
// Last element wins -- which is the most specific, and at equal
|
|
80
|
+
// specificity the strongest effect (deny > ask > allow).
|
|
81
|
+
matching.sort((a, b) => {
|
|
82
|
+
const specDiff = a.specificity - b.specificity;
|
|
83
|
+
if (specDiff !== 0) return specDiff;
|
|
84
|
+
return (EFFECT_PRIORITY[a.effect] ?? 0) - (EFFECT_PRIORITY[b.effect] ?? 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const resolvedBy = matching[matching.length - 1];
|
|
88
|
+
if (!resolvedBy) {
|
|
89
|
+
return { effect: null, matchingGrants: [], resolvedBy: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
effect: resolvedBy.effect,
|
|
94
|
+
matchingGrants: matching,
|
|
95
|
+
resolvedBy,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Authorize a principal for a resource/action within a tenant.
|
|
101
|
+
*
|
|
102
|
+
* Collects all relevant grants via the provided store, matches them
|
|
103
|
+
* against the requested resource and action, and returns the resolved
|
|
104
|
+
* effect.
|
|
105
|
+
*
|
|
106
|
+
* Returns null when no grants match (fail-closed). The caller
|
|
107
|
+
* interprets the result in context: HTTP routes return 403, the
|
|
108
|
+
* agent runtime blocks the tool call.
|
|
109
|
+
*/
|
|
110
|
+
export async function authorize(
|
|
111
|
+
store: GrantStore,
|
|
112
|
+
principalId: string,
|
|
113
|
+
tenantId: string,
|
|
114
|
+
resource: string,
|
|
115
|
+
action: string,
|
|
116
|
+
registry?: ConditionRegistry,
|
|
117
|
+
): Promise<AuthzResult> {
|
|
118
|
+
const grants = await store.collectGrants(principalId, tenantId);
|
|
119
|
+
const opts: EvalOptions = { principalId, tenantId };
|
|
120
|
+
if (registry) opts.registry = registry;
|
|
121
|
+
return evaluateGrants(grants, resource, action, opts);
|
|
122
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { authorize, evaluateGrants } from "./evaluate";
|
|
2
|
+
export { matchPattern } from "./patterns";
|
|
3
|
+
export { patternSpecificity, grantSpecificity } from "./specificity";
|
|
4
|
+
export { evaluateConditions } from "./conditions";
|
|
5
|
+
export { timeWindowEvaluator } from "./time-window";
|
|
6
|
+
export { createInMemoryGrantStore } from "./memory-store";
|
|
7
|
+
export type {
|
|
8
|
+
AuthzResult,
|
|
9
|
+
ConditionContext,
|
|
10
|
+
ConditionEvaluator,
|
|
11
|
+
ConditionRegistry,
|
|
12
|
+
Effect,
|
|
13
|
+
GrantRule,
|
|
14
|
+
GrantStore,
|
|
15
|
+
MatchedGrant,
|
|
16
|
+
} from "./types";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createInMemoryGrantStore } from "./memory-store";
|
|
4
|
+
import type { GrantRule } from "./types";
|
|
5
|
+
|
|
6
|
+
function makeGrant(overrides: Partial<GrantRule> = {}): GrantRule {
|
|
7
|
+
return {
|
|
8
|
+
id: "grant-1",
|
|
9
|
+
resource: "tool:bash",
|
|
10
|
+
action: "invoke",
|
|
11
|
+
effect: "allow",
|
|
12
|
+
origin: "creator",
|
|
13
|
+
conditions: null,
|
|
14
|
+
expiresAt: null,
|
|
15
|
+
roleId: null,
|
|
16
|
+
principalId: "p-1",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("createInMemoryGrantStore", () => {
|
|
22
|
+
test("returns grants matching the principalId", async () => {
|
|
23
|
+
const store = createInMemoryGrantStore([
|
|
24
|
+
makeGrant({ id: "g1", principalId: "p-1" }),
|
|
25
|
+
makeGrant({ id: "g2", principalId: "p-2" }),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
29
|
+
expect(results.length).toBe(1);
|
|
30
|
+
const first = results[0];
|
|
31
|
+
if (first === undefined) throw new Error("expected a grant");
|
|
32
|
+
expect(first.id).toBe("g1");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns empty array when no grants match", async () => {
|
|
36
|
+
const store = createInMemoryGrantStore([
|
|
37
|
+
makeGrant({ principalId: "p-other" }),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
41
|
+
expect(results.length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("filters out expired grants", async () => {
|
|
45
|
+
const past = new Date(Date.now() - 60_000);
|
|
46
|
+
const store = createInMemoryGrantStore([
|
|
47
|
+
makeGrant({ id: "expired", principalId: "p-1", expiresAt: past }),
|
|
48
|
+
makeGrant({ id: "valid", principalId: "p-1" }),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
52
|
+
expect(results.length).toBe(1);
|
|
53
|
+
const first = results[0];
|
|
54
|
+
if (first === undefined) throw new Error("expected a grant");
|
|
55
|
+
expect(first.id).toBe("valid");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("includes grants with future expiry", async () => {
|
|
59
|
+
const future = new Date(Date.now() + 60_000);
|
|
60
|
+
const store = createInMemoryGrantStore([
|
|
61
|
+
makeGrant({ principalId: "p-1", expiresAt: future }),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
65
|
+
expect(results.length).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("tenantId does not affect filtering", async () => {
|
|
69
|
+
const store = createInMemoryGrantStore([makeGrant({ principalId: "p-1" })]);
|
|
70
|
+
|
|
71
|
+
const a = await store.collectGrants("p-1", "tenant-a");
|
|
72
|
+
const b = await store.collectGrants("p-1", "tenant-b");
|
|
73
|
+
expect(a.length).toBe(1);
|
|
74
|
+
expect(b.length).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns multiple matching grants", async () => {
|
|
78
|
+
const store = createInMemoryGrantStore([
|
|
79
|
+
makeGrant({ id: "g1", principalId: "p-1", resource: "tool:bash" }),
|
|
80
|
+
makeGrant({ id: "g2", principalId: "p-1", resource: "tool:curl" }),
|
|
81
|
+
makeGrant({ id: "g3", principalId: "p-1", resource: "tool:node" }),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
85
|
+
expect(results.length).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("empty store returns empty array", async () => {
|
|
89
|
+
const store = createInMemoryGrantStore([]);
|
|
90
|
+
const results = await store.collectGrants("p-1", "t-1");
|
|
91
|
+
expect(results.length).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// In-memory grant store for testing and local demos.
|
|
2
|
+
//
|
|
3
|
+
// The caller provides a pre-scoped array of grants. The store filters by
|
|
4
|
+
// principalId on each collectGrants call, matching the DB store's behavior.
|
|
5
|
+
//
|
|
6
|
+
// Limitations vs. the DB store:
|
|
7
|
+
// - tenantId is accepted by the interface but is a no-op here. The caller
|
|
8
|
+
// scopes grants to the correct tenant when constructing the store.
|
|
9
|
+
// - Role-based grants (principalId: null, roleId set) are not resolved.
|
|
10
|
+
// The DB store performs a principalRole join to find role memberships;
|
|
11
|
+
// this store has no role data. Grants with principalId: null are never
|
|
12
|
+
// returned. If you need role-based grants in tests, set principalId
|
|
13
|
+
// directly on each grant.
|
|
14
|
+
|
|
15
|
+
import type { GrantRule, GrantStore } from "./types";
|
|
16
|
+
|
|
17
|
+
export function createInMemoryGrantStore(grants: GrantRule[]): GrantStore {
|
|
18
|
+
return {
|
|
19
|
+
async collectGrants(principalId: string): Promise<GrantRule[]> {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
return grants.filter((g) => {
|
|
22
|
+
if (g.principalId !== principalId) return false;
|
|
23
|
+
if (g.expiresAt !== null && g.expiresAt <= now) return false;
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { matchPattern } from "./patterns";
|
|
4
|
+
|
|
5
|
+
describe("matchPattern", () => {
|
|
6
|
+
test("bare wildcard matches anything", () => {
|
|
7
|
+
expect(matchPattern("*", "agent:agt_abc")).toBe(true);
|
|
8
|
+
expect(matchPattern("*", "")).toBe(true);
|
|
9
|
+
expect(matchPattern("*", "anything")).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("exact match", () => {
|
|
13
|
+
expect(matchPattern("agent:agt_abc", "agent:agt_abc")).toBe(true);
|
|
14
|
+
expect(matchPattern("agent:agt_abc", "agent:agt_xyz")).toBe(false);
|
|
15
|
+
expect(matchPattern("read", "read")).toBe(true);
|
|
16
|
+
expect(matchPattern("read", "write")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("type-level wildcard matches any identifier", () => {
|
|
20
|
+
expect(matchPattern("agent:*", "agent:agt_abc")).toBe(true);
|
|
21
|
+
expect(matchPattern("agent:*", "agent:agt_xyz")).toBe(true);
|
|
22
|
+
expect(matchPattern("agent:*", "wallet:wal_123")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("prefix wildcard", () => {
|
|
26
|
+
expect(matchPattern("wallet:wal_*", "wallet:wal_123")).toBe(true);
|
|
27
|
+
expect(matchPattern("wallet:wal_*", "wallet:xyz")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("no wildcard requires exact match", () => {
|
|
31
|
+
expect(matchPattern("agent:agt_abc", "agent:agt_abc")).toBe(true);
|
|
32
|
+
expect(matchPattern("documents", "documents:doc_1")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("wildcard at start", () => {
|
|
36
|
+
expect(matchPattern("*:read", "agent:read")).toBe(true);
|
|
37
|
+
expect(matchPattern("*:read", "wallet:read")).toBe(true);
|
|
38
|
+
expect(matchPattern("*:read", "agent:write")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("multiple wildcards", () => {
|
|
42
|
+
expect(matchPattern("*:*", "agent:agt_abc")).toBe(true);
|
|
43
|
+
expect(matchPattern("a*b*c", "abc")).toBe(true);
|
|
44
|
+
expect(matchPattern("a*b*c", "aXXbYYc")).toBe(true);
|
|
45
|
+
expect(matchPattern("a*b*c", "aXXbYY")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("pattern with no wildcards and different lengths", () => {
|
|
49
|
+
expect(matchPattern("ab", "abc")).toBe(false);
|
|
50
|
+
expect(matchPattern("abc", "ab")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("empty string pattern against empty string target", () => {
|
|
54
|
+
expect(matchPattern("", "")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("empty string pattern does not match non-empty target", () => {
|
|
58
|
+
expect(matchPattern("", "agent:agt_abc")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("consecutive wildcards behave like a single wildcard", () => {
|
|
62
|
+
expect(matchPattern("a**b", "aXb")).toBe(true);
|
|
63
|
+
expect(matchPattern("a**b", "ab")).toBe(true);
|
|
64
|
+
expect(matchPattern("***", "anything")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("nested colon resource patterns (api:stripe:*)", () => {
|
|
68
|
+
expect(matchPattern("api:stripe:*", "api:stripe:charges")).toBe(true);
|
|
69
|
+
expect(matchPattern("api:stripe:*", "api:stripe:refunds")).toBe(true);
|
|
70
|
+
expect(matchPattern("api:stripe:*", "api:plaid:accounts")).toBe(false);
|
|
71
|
+
expect(matchPattern("api:*", "api:stripe:charges")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("trailing wildcard matches zero characters", () => {
|
|
75
|
+
expect(matchPattern("abc*", "abc")).toBe(true);
|
|
76
|
+
expect(matchPattern("agent:*", "agent:")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("patterns are case-sensitive", () => {
|
|
80
|
+
expect(matchPattern("Agent:*", "agent:agt_abc")).toBe(false);
|
|
81
|
+
expect(matchPattern("agent:*", "Agent:agt_abc")).toBe(false);
|
|
82
|
+
expect(matchPattern("READ", "read")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("wildcard-only variants", () => {
|
|
86
|
+
expect(matchPattern("**", "anything")).toBe(true);
|
|
87
|
+
expect(matchPattern("**", "")).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match a target string against a glob pattern.
|
|
3
|
+
*
|
|
4
|
+
* Supports `*` as a wildcard that matches any sequence of characters.
|
|
5
|
+
* No other glob syntax is supported (no `?`, `**`, or character classes).
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* matchPattern("*", "agent:agt_abc") => true
|
|
9
|
+
* matchPattern("agent:*", "agent:agt_abc") => true
|
|
10
|
+
* matchPattern("agent:agt_abc", "agent:agt_abc") => true
|
|
11
|
+
* matchPattern("agent:agt_abc", "agent:agt_xyz") => false
|
|
12
|
+
* matchPattern("wallet:wal_*", "wallet:wal_123") => true
|
|
13
|
+
* matchPattern("wallet:wal_*", "wallet:xyz") => false
|
|
14
|
+
*/
|
|
15
|
+
export function matchPattern(pattern: string, target: string): boolean {
|
|
16
|
+
if (pattern === "*") return true;
|
|
17
|
+
if (pattern === target) return true;
|
|
18
|
+
|
|
19
|
+
if (!pattern.includes("*")) return false;
|
|
20
|
+
|
|
21
|
+
const parts = pattern.split("*");
|
|
22
|
+
let pos = 0;
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < parts.length; i++) {
|
|
25
|
+
const segment = parts[i] ?? "";
|
|
26
|
+
if (segment.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
const idx = target.indexOf(segment, pos);
|
|
29
|
+
if (idx === -1) return false;
|
|
30
|
+
|
|
31
|
+
// First segment must anchor to the start
|
|
32
|
+
if (i === 0 && idx !== 0) return false;
|
|
33
|
+
|
|
34
|
+
pos = idx + segment.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Last segment must anchor to the end
|
|
38
|
+
const lastSegment = parts[parts.length - 1] ?? "";
|
|
39
|
+
if (lastSegment.length > 0 && !target.endsWith(lastSegment)) return false;
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { patternSpecificity, grantSpecificity } from "./specificity";
|
|
4
|
+
|
|
5
|
+
describe("patternSpecificity", () => {
|
|
6
|
+
test("bare wildcard has zero specificity", () => {
|
|
7
|
+
expect(patternSpecificity("*")).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("type-level wildcard scores by literal length", () => {
|
|
11
|
+
// "agent:" has 6 literal chars
|
|
12
|
+
expect(patternSpecificity("agent:*")).toBe(6);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("prefix wildcard scores by literal length", () => {
|
|
16
|
+
// "wallet:wal_" has 11 literal chars
|
|
17
|
+
expect(patternSpecificity("wallet:wal_*")).toBe(11);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("exact match gets bonus of 1000", () => {
|
|
21
|
+
// "agent:agt_abc" has 13 chars + 1000 bonus
|
|
22
|
+
expect(patternSpecificity("agent:agt_abc")).toBe(1013);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("more specific patterns score higher", () => {
|
|
26
|
+
const scores = [
|
|
27
|
+
patternSpecificity("*"),
|
|
28
|
+
patternSpecificity("agent:*"),
|
|
29
|
+
patternSpecificity("agent:agt_*"),
|
|
30
|
+
patternSpecificity("agent:agt_abc"),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (let i = 1; i < scores.length; i++) {
|
|
34
|
+
const prev = scores[i - 1] ?? 0;
|
|
35
|
+
expect(scores[i]).toBeGreaterThan(prev);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("action patterns follow same rules", () => {
|
|
40
|
+
expect(patternSpecificity("*")).toBe(0);
|
|
41
|
+
expect(patternSpecificity("read")).toBe(1004); // 4 chars + 1000
|
|
42
|
+
expect(patternSpecificity("manage")).toBe(1006); // 6 chars + 1000
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("grantSpecificity", () => {
|
|
47
|
+
test("combines resource and action specificity", () => {
|
|
48
|
+
expect(grantSpecificity("*", "*")).toBe(0);
|
|
49
|
+
expect(grantSpecificity("agent:*", "read")).toBe(6 + 1004);
|
|
50
|
+
expect(grantSpecificity("agent:agt_abc", "manage")).toBe(1013 + 1006);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("more specific grant beats less specific", () => {
|
|
54
|
+
// Wildcard everything
|
|
55
|
+
const s1 = grantSpecificity("*", "*");
|
|
56
|
+
// Type-level wildcard with specific action
|
|
57
|
+
const s2 = grantSpecificity("agent:*", "read");
|
|
58
|
+
// Exact resource and action
|
|
59
|
+
const s3 = grantSpecificity("agent:agt_abc", "manage");
|
|
60
|
+
|
|
61
|
+
expect(s2).toBeGreaterThan(s1);
|
|
62
|
+
expect(s3).toBeGreaterThan(s2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("patternSpecificity edge cases", () => {
|
|
67
|
+
test("empty string gets exact match bonus", () => {
|
|
68
|
+
// Empty string has no wildcard, so it gets the 1000 bonus + 0 literal chars
|
|
69
|
+
expect(patternSpecificity("")).toBe(1000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("multi-wildcard pattern scores only literal characters", () => {
|
|
73
|
+
// "*:*" has 1 literal char (':') and contains wildcards
|
|
74
|
+
expect(patternSpecificity("*:*")).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("nested colon pattern scores all literal characters", () => {
|
|
78
|
+
// "api:stripe:*" has 11 literal chars ("api:stripe:") and contains a wildcard
|
|
79
|
+
expect(patternSpecificity("api:stripe:*")).toBe(11);
|
|
80
|
+
// "api:stripe:charges" has 18 chars and no wildcard -> 18 + 1000
|
|
81
|
+
expect(patternSpecificity("api:stripe:charges")).toBe(1018);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("specificity is character-count based, not segment-aware", () => {
|
|
85
|
+
// Two patterns with same literal length but different structure
|
|
86
|
+
// score identically, proving specificity is purely character-based
|
|
87
|
+
const a = patternSpecificity("abcdef:*"); // 7 literal chars
|
|
88
|
+
const b = patternSpecificity("ab:cd:e*"); // 7 literal chars
|
|
89
|
+
expect(a).toBe(b);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute a specificity score for a pattern.
|
|
3
|
+
*
|
|
4
|
+
* More specific patterns get higher scores:
|
|
5
|
+
* "*" => 0 (matches everything)
|
|
6
|
+
* "agent:*" => 6 (type-level wildcard, 6 literal chars)
|
|
7
|
+
* "wallet:wal_*" => 11 (prefix match, 11 literal chars)
|
|
8
|
+
* "agent:agt_abc" => 13 (exact match, 13 literal chars, no wildcards)
|
|
9
|
+
*
|
|
10
|
+
* The score is the count of non-wildcard characters, plus a bonus
|
|
11
|
+
* for patterns with no wildcards at all (exact matches).
|
|
12
|
+
*/
|
|
13
|
+
export function patternSpecificity(pattern: string): number {
|
|
14
|
+
if (pattern === "*") return 0;
|
|
15
|
+
|
|
16
|
+
const literalLength = pattern.replace(/\*/g, "").length;
|
|
17
|
+
const hasWildcard = pattern.includes("*");
|
|
18
|
+
|
|
19
|
+
// Exact matches get a bonus to ensure they always beat prefix globs
|
|
20
|
+
// of similar length
|
|
21
|
+
return hasWildcard ? literalLength : literalLength + 1000;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Combined specificity of a resource + action pair.
|
|
26
|
+
*/
|
|
27
|
+
export function grantSpecificity(resource: string, action: string): number {
|
|
28
|
+
return patternSpecificity(resource) + patternSpecificity(action);
|
|
29
|
+
}
|