@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.
@@ -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
+ });
@@ -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
+ }