@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 ADDED
@@ -0,0 +1,49 @@
1
+ # @intx/authz
2
+
3
+ Grant evaluation engine. Given a principal, a tenant, a resource,
4
+ and an action, `authorize` collects the relevant grants from a
5
+ `GrantStore`, picks the most specific match, applies condition
6
+ evaluators, and returns the resolved effect.
7
+
8
+ Pattern matching uses colon-segmented patterns with `*` and `**`
9
+ wildcards; specificity scoring picks the most specific matching
10
+ grant; condition evaluators gate matches on runtime facts such as
11
+ time windows. An in-memory grant store is included for tests and
12
+ single-process deployments; `@intx/db` provides the database-backed
13
+ implementation.
14
+
15
+ Consumed by `@intx/hub-api` for HTTP request authorization and by
16
+ `@intx/harness` for tool-call gating.
17
+
18
+ ```ts
19
+ import { authorize, createInMemoryGrantStore } from "@intx/authz";
20
+
21
+ const store = createInMemoryGrantStore([
22
+ {
23
+ id: "g1",
24
+ principalId: "user-1",
25
+ roleId: null,
26
+ effect: "allow",
27
+ origin: "system",
28
+ resource: "tenant:*:agent:*",
29
+ action: "read",
30
+ conditions: null,
31
+ expiresAt: null,
32
+ },
33
+ ]);
34
+
35
+ const result = await authorize(
36
+ store,
37
+ "user-1",
38
+ "acme",
39
+ "tenant:acme:agent:abc",
40
+ "read",
41
+ );
42
+
43
+ if (result.effect !== "allow") throw new Error("forbidden");
44
+ ```
45
+
46
+ `authorize` returns an `AuthzResult` whose `effect` is one of
47
+ `allow`, `deny`, `ask`, or `null` when no grant matches. Callers
48
+ translate `null` into a refusal (HTTP 403, tool-call block) because
49
+ evaluation is fail-closed.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@intx/authz",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/types": "0.0.0"
14
+ }
15
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { evaluateConditions } from "./conditions";
4
+ import type { ConditionContext, ConditionRegistry } from "./types";
5
+
6
+ function ctx(overrides?: Partial<ConditionContext>): ConditionContext {
7
+ return {
8
+ now: new Date("2025-06-15T14:30:00Z"),
9
+ resource: "agent:agt_abc",
10
+ action: "read",
11
+ principalId: "prn_1",
12
+ tenantId: "tnt_1",
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("evaluateConditions", () => {
18
+ test("null conditions are always met", async () => {
19
+ expect(await evaluateConditions(null, ctx())).toBe(true);
20
+ });
21
+
22
+ test("empty object conditions are always met", async () => {
23
+ expect(await evaluateConditions({}, ctx())).toBe(true);
24
+ });
25
+
26
+ test("unknown condition key throws an error", async () => {
27
+ expect(
28
+ evaluateConditions({ bogus_condition: true }, ctx(), {}),
29
+ ).rejects.toThrow('Unknown condition: "bogus_condition"');
30
+ });
31
+
32
+ test("single condition evaluated against registry", async () => {
33
+ const registry: ConditionRegistry = {
34
+ is_tuesday: () => true,
35
+ };
36
+
37
+ expect(
38
+ await evaluateConditions({ is_tuesday: null }, ctx(), registry),
39
+ ).toBe(true);
40
+ });
41
+
42
+ test("condition returning false rejects the grant", async () => {
43
+ const registry: ConditionRegistry = {
44
+ always_fail: () => false,
45
+ };
46
+
47
+ expect(
48
+ await evaluateConditions({ always_fail: null }, ctx(), registry),
49
+ ).toBe(false);
50
+ });
51
+
52
+ test("all conditions must pass (AND semantics)", async () => {
53
+ const registry: ConditionRegistry = {
54
+ cond_a: () => true,
55
+ cond_b: () => true,
56
+ cond_c: () => false,
57
+ };
58
+
59
+ expect(
60
+ await evaluateConditions(
61
+ { cond_a: null, cond_b: null, cond_c: null },
62
+ ctx(),
63
+ registry,
64
+ ),
65
+ ).toBe(false);
66
+ });
67
+
68
+ test("all conditions passing returns true", async () => {
69
+ const registry: ConditionRegistry = {
70
+ cond_a: () => true,
71
+ cond_b: () => true,
72
+ };
73
+
74
+ expect(
75
+ await evaluateConditions({ cond_a: null, cond_b: null }, ctx(), registry),
76
+ ).toBe(true);
77
+ });
78
+
79
+ test("evaluator receives the condition value", async () => {
80
+ let receivedValue: unknown;
81
+ const registry: ConditionRegistry = {
82
+ threshold: (value) => {
83
+ receivedValue = value;
84
+ return true;
85
+ },
86
+ };
87
+
88
+ await evaluateConditions({ threshold: 42 }, ctx(), registry);
89
+
90
+ expect(receivedValue).toBe(42);
91
+ });
92
+
93
+ test("evaluator receives the full context", async () => {
94
+ let receivedCtx: ConditionContext | undefined;
95
+ const registry: ConditionRegistry = {
96
+ spy: (_value, c) => {
97
+ receivedCtx = c;
98
+ return true;
99
+ },
100
+ };
101
+
102
+ const c = ctx({ principalId: "prn_spy", tenantId: "tnt_spy" });
103
+ await evaluateConditions({ spy: null }, c, registry);
104
+
105
+ expect(receivedCtx?.principalId).toBe("prn_spy");
106
+ expect(receivedCtx?.tenantId).toBe("tnt_spy");
107
+ expect(receivedCtx?.resource).toBe("agent:agt_abc");
108
+ expect(receivedCtx?.action).toBe("read");
109
+ });
110
+
111
+ test("async evaluators are supported", async () => {
112
+ const registry: ConditionRegistry = {
113
+ async_check: async () => {
114
+ await new Promise((r) => setTimeout(r, 1));
115
+ return true;
116
+ },
117
+ };
118
+
119
+ expect(
120
+ await evaluateConditions({ async_check: null }, ctx(), registry),
121
+ ).toBe(true);
122
+ });
123
+
124
+ test("async evaluator returning false rejects", async () => {
125
+ const registry: ConditionRegistry = {
126
+ async_fail: async () => {
127
+ await new Promise((r) => setTimeout(r, 1));
128
+ return false;
129
+ },
130
+ };
131
+
132
+ expect(
133
+ await evaluateConditions({ async_fail: null }, ctx(), registry),
134
+ ).toBe(false);
135
+ });
136
+
137
+ test("short-circuits on first failing condition", async () => {
138
+ let secondCalled = false;
139
+ const registry: ConditionRegistry = {
140
+ first: () => false,
141
+ second: () => {
142
+ secondCalled = true;
143
+ return true;
144
+ },
145
+ };
146
+
147
+ await evaluateConditions({ first: null, second: null }, ctx(), registry);
148
+
149
+ expect(secondCalled).toBe(false);
150
+ });
151
+
152
+ test("no registry provided with non-null conditions throws", async () => {
153
+ // No registry argument at all -- defaults to empty registry
154
+ expect(evaluateConditions({ some_condition: true }, ctx())).rejects.toThrow(
155
+ 'Unknown condition: "some_condition"',
156
+ );
157
+ });
158
+ });
@@ -0,0 +1,36 @@
1
+ import type { ConditionContext, ConditionRegistry } from "./types";
2
+
3
+ /**
4
+ * Evaluate whether a grant's conditions are satisfied.
5
+ *
6
+ * Each key in the conditions object is looked up in the registry.
7
+ * All conditions must be met for the grant to apply. If any
8
+ * condition key has no registered evaluator, an error is thrown
9
+ * to surface misconfiguration immediately.
10
+ *
11
+ * Null or empty conditions are always met.
12
+ */
13
+ export async function evaluateConditions(
14
+ conditions: Record<string, unknown> | null,
15
+ ctx: ConditionContext,
16
+ registry: ConditionRegistry = {},
17
+ ): Promise<boolean> {
18
+ if (!conditions) return true;
19
+
20
+ const keys = Object.keys(conditions);
21
+ if (keys.length === 0) return true;
22
+
23
+ for (const key of keys) {
24
+ const evaluator = registry[key];
25
+ if (!evaluator) {
26
+ throw new Error(
27
+ `Unknown condition: "${key}". Register an evaluator in the condition registry.`,
28
+ );
29
+ }
30
+
31
+ const result = await evaluator(conditions[key], ctx);
32
+ if (!result) return false;
33
+ }
34
+
35
+ return true;
36
+ }