@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,936 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { authorize, evaluateGrants } from "./evaluate";
4
+ import type { ConditionRegistry, GrantRule, GrantStore } from "./types";
5
+
6
+ function grant(
7
+ overrides: Partial<GrantRule> &
8
+ Pick<GrantRule, "resource" | "action" | "effect">,
9
+ ): GrantRule {
10
+ return {
11
+ id: `grt_${Math.random().toString(36).slice(2, 10)}`,
12
+ origin: "role",
13
+ conditions: null,
14
+ expiresAt: null,
15
+ roleId: null,
16
+ principalId: null,
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("evaluateGrants", () => {
22
+ test("returns null effect when no grants match", async () => {
23
+ const result = await evaluateGrants([], "agent:*", "read");
24
+
25
+ expect(result.effect).toBeNull();
26
+ expect(result.matchingGrants).toHaveLength(0);
27
+ expect(result.resolvedBy).toBeNull();
28
+ });
29
+
30
+ test("returns null when grants exist but none match", async () => {
31
+ const grants = [
32
+ grant({ resource: "wallet:*", action: "read", effect: "allow" }),
33
+ ];
34
+
35
+ const result = await evaluateGrants(grants, "agent:agt_abc", "create");
36
+
37
+ expect(result.effect).toBeNull();
38
+ expect(result.matchingGrants).toHaveLength(0);
39
+ });
40
+
41
+ test("single matching grant determines effect", async () => {
42
+ const grants = [grant({ resource: "*", action: "read", effect: "allow" })];
43
+
44
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
45
+
46
+ expect(result.effect).toBe("allow");
47
+ expect(result.matchingGrants).toHaveLength(1);
48
+ expect(result.resolvedBy).not.toBeNull();
49
+ });
50
+
51
+ test("more specific grant beats less specific", async () => {
52
+ const grants = [
53
+ grant({ resource: "*", action: "*", effect: "allow" }),
54
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
55
+ ];
56
+
57
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
58
+
59
+ expect(result.effect).toBe("deny");
60
+ expect(result.matchingGrants).toHaveLength(2);
61
+ expect(result.resolvedBy?.resource).toBe("agent:*");
62
+ });
63
+
64
+ test("at equal specificity deny beats allow", async () => {
65
+ const grants = [
66
+ grant({ resource: "agent:*", action: "read", effect: "allow" }),
67
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
68
+ ];
69
+
70
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
71
+
72
+ expect(result.effect).toBe("deny");
73
+ });
74
+
75
+ test("at equal specificity ask beats allow", async () => {
76
+ const grants = [
77
+ grant({ resource: "agent:*", action: "read", effect: "allow" }),
78
+ grant({ resource: "agent:*", action: "read", effect: "ask" }),
79
+ ];
80
+
81
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
82
+
83
+ expect(result.effect).toBe("ask");
84
+ });
85
+
86
+ test("at equal specificity deny beats ask", async () => {
87
+ const grants = [
88
+ grant({ resource: "agent:*", action: "read", effect: "ask" }),
89
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
90
+ ];
91
+
92
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
93
+
94
+ expect(result.effect).toBe("deny");
95
+ });
96
+
97
+ test("exact resource match beats wildcard even with weaker effect", async () => {
98
+ const grants = [
99
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
100
+ grant({ resource: "agent:agt_abc", action: "read", effect: "allow" }),
101
+ ];
102
+
103
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
104
+
105
+ expect(result.effect).toBe("allow");
106
+ expect(result.resolvedBy?.resource).toBe("agent:agt_abc");
107
+ });
108
+
109
+ test("wildcard action matches specific action", async () => {
110
+ const grants = [grant({ resource: "*", action: "*", effect: "allow" })];
111
+
112
+ const result = await evaluateGrants(grants, "credential:crd_123", "manage");
113
+
114
+ expect(result.effect).toBe("allow");
115
+ });
116
+
117
+ test("owner wildcard grant allows everything", async () => {
118
+ const grants = [
119
+ grant({
120
+ resource: "*",
121
+ action: "*",
122
+ effect: "allow",
123
+ origin: "system",
124
+ }),
125
+ ];
126
+
127
+ expect((await evaluateGrants(grants, "agent:*", "read")).effect).toBe(
128
+ "allow",
129
+ );
130
+ expect(
131
+ (await evaluateGrants(grants, "wallet:wal_123", "manage")).effect,
132
+ ).toBe("allow");
133
+ expect((await evaluateGrants(grants, "grant:*", "create")).effect).toBe(
134
+ "allow",
135
+ );
136
+ });
137
+
138
+ test("member read-only grant allows read but not create", async () => {
139
+ const grants = [grant({ resource: "*", action: "read", effect: "allow" })];
140
+
141
+ expect((await evaluateGrants(grants, "agent:*", "read")).effect).toBe(
142
+ "allow",
143
+ );
144
+ expect(
145
+ (await evaluateGrants(grants, "agent:*", "create")).effect,
146
+ ).toBeNull();
147
+ expect(
148
+ (await evaluateGrants(grants, "agent:*", "manage")).effect,
149
+ ).toBeNull();
150
+ });
151
+
152
+ test("admin grants allow read, create, and manage", async () => {
153
+ const grants = [
154
+ grant({ resource: "*", action: "read", effect: "allow" }),
155
+ grant({ resource: "*", action: "create", effect: "allow" }),
156
+ grant({ resource: "*", action: "manage", effect: "allow" }),
157
+ ];
158
+
159
+ expect((await evaluateGrants(grants, "agent:*", "read")).effect).toBe(
160
+ "allow",
161
+ );
162
+ expect((await evaluateGrants(grants, "agent:*", "create")).effect).toBe(
163
+ "allow",
164
+ );
165
+ expect(
166
+ (await evaluateGrants(grants, "wallet:wal_123", "manage")).effect,
167
+ ).toBe("allow");
168
+ });
169
+
170
+ test("agent-specific grants from seed data", async () => {
171
+ const grants = [
172
+ grant({
173
+ resource: "documents:*",
174
+ action: "read",
175
+ effect: "allow",
176
+ origin: "creator",
177
+ }),
178
+ grant({
179
+ resource: "documents:*",
180
+ action: "write",
181
+ effect: "ask",
182
+ origin: "creator",
183
+ }),
184
+ ];
185
+
186
+ expect(
187
+ (await evaluateGrants(grants, "documents:doc_1", "read")).effect,
188
+ ).toBe("allow");
189
+ expect(
190
+ (await evaluateGrants(grants, "documents:doc_1", "write")).effect,
191
+ ).toBe("ask");
192
+ expect(
193
+ (await evaluateGrants(grants, "repos:repo_1", "read")).effect,
194
+ ).toBeNull();
195
+ });
196
+
197
+ test("reports all matching grants in result", async () => {
198
+ const grants = [
199
+ grant({ id: "g1", resource: "*", action: "*", effect: "allow" }),
200
+ grant({ id: "g2", resource: "agent:*", action: "*", effect: "allow" }),
201
+ grant({ id: "g3", resource: "agent:*", action: "read", effect: "allow" }),
202
+ ];
203
+
204
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
205
+
206
+ expect(result.matchingGrants).toHaveLength(3);
207
+ expect(result.resolvedBy?.id).toBe("g3");
208
+ });
209
+
210
+ test("grant with null expiresAt is always evaluated", async () => {
211
+ const grants = [grant({ resource: "*", action: "read", effect: "allow" })];
212
+
213
+ const result = await evaluateGrants(grants, "agent:*", "read");
214
+ expect(result.effect).toBe("allow");
215
+ });
216
+ });
217
+
218
+ describe("evaluateGrants determinism", () => {
219
+ test("duplicate grants with identical specificity and effect resolve consistently", async () => {
220
+ const grants = [
221
+ grant({ id: "g1", resource: "agent:*", action: "read", effect: "allow" }),
222
+ grant({ id: "g2", resource: "agent:*", action: "read", effect: "allow" }),
223
+ ];
224
+
225
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
226
+
227
+ expect(result.effect).toBe("allow");
228
+ expect(result.matchingGrants).toHaveLength(2);
229
+ expect(result.resolvedBy).not.toBeNull();
230
+ });
231
+
232
+ test("three-way effect tiebreaker at equal specificity: deny wins", async () => {
233
+ const grants = [
234
+ grant({ resource: "agent:*", action: "read", effect: "allow" }),
235
+ grant({ resource: "agent:*", action: "read", effect: "ask" }),
236
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
237
+ ];
238
+
239
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
240
+
241
+ expect(result.effect).toBe("deny");
242
+ expect(result.matchingGrants).toHaveLength(3);
243
+ });
244
+
245
+ test("effect tiebreaker is independent of grant input order", async () => {
246
+ const perms: ("allow" | "ask" | "deny")[] = ["deny", "allow", "ask"];
247
+
248
+ for (const effect1 of perms) {
249
+ const grants = [
250
+ grant({ resource: "agent:*", action: "read", effect: effect1 }),
251
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
252
+ ];
253
+
254
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
255
+ expect(result.effect).toBe("deny");
256
+ }
257
+ });
258
+ });
259
+
260
+ describe("evaluateGrants origin neutrality", () => {
261
+ test("origin does not affect evaluation precedence", async () => {
262
+ const origins = ["system", "role", "creator", "invoker"] as const;
263
+
264
+ for (const origin of origins) {
265
+ const grants = [
266
+ grant({ resource: "agent:*", action: "read", effect: "allow", origin }),
267
+ ];
268
+
269
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
270
+ expect(result.effect).toBe("allow");
271
+ }
272
+ });
273
+
274
+ test("grants with different sources but same pattern resolve by specificity", async () => {
275
+ const grants = [
276
+ grant({
277
+ resource: "*",
278
+ action: "*",
279
+ effect: "allow",
280
+ origin: "system",
281
+ }),
282
+ grant({
283
+ resource: "agent:*",
284
+ action: "read",
285
+ effect: "deny",
286
+ origin: "creator",
287
+ }),
288
+ ];
289
+
290
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
291
+
292
+ expect(result.effect).toBe("deny");
293
+ });
294
+ });
295
+
296
+ describe("evaluateGrants conditions", () => {
297
+ test("grants with null conditions are unaffected by missing registry", async () => {
298
+ const grants = [grant({ resource: "*", action: "read", effect: "allow" })];
299
+
300
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
301
+ expect(result.effect).toBe("allow");
302
+ });
303
+
304
+ test("grants with non-null conditions are skipped when no registry provided (fail-closed)", async () => {
305
+ const grants = [
306
+ grant({
307
+ resource: "wallet:*",
308
+ action: "spend",
309
+ effect: "allow",
310
+ conditions: { max_spend_per_day: 100 },
311
+ }),
312
+ ];
313
+
314
+ const result = await evaluateGrants(grants, "wallet:wal_abc", "spend");
315
+ expect(result.effect).toBeNull();
316
+ expect(result.matchingGrants).toHaveLength(0);
317
+ });
318
+
319
+ test("grant with conditions evaluated to true is included", async () => {
320
+ const registry: ConditionRegistry = {
321
+ max_spend_per_day: () => true,
322
+ };
323
+ const grants = [
324
+ grant({
325
+ resource: "wallet:*",
326
+ action: "spend",
327
+ effect: "allow",
328
+ conditions: { max_spend_per_day: 100 },
329
+ }),
330
+ ];
331
+
332
+ const result = await evaluateGrants(grants, "wallet:wal_abc", "spend", {
333
+ registry,
334
+ });
335
+ expect(result.effect).toBe("allow");
336
+ expect(result.matchingGrants).toHaveLength(1);
337
+ });
338
+
339
+ test("grant with conditions evaluated to false is excluded", async () => {
340
+ const registry: ConditionRegistry = {
341
+ max_spend_per_day: () => false,
342
+ };
343
+ const grants = [
344
+ grant({
345
+ resource: "wallet:*",
346
+ action: "spend",
347
+ effect: "allow",
348
+ conditions: { max_spend_per_day: 100 },
349
+ }),
350
+ ];
351
+
352
+ const result = await evaluateGrants(grants, "wallet:wal_abc", "spend", {
353
+ registry,
354
+ });
355
+ expect(result.effect).toBeNull();
356
+ });
357
+
358
+ test("unknown condition key in registry throws during evaluation", async () => {
359
+ const grants = [
360
+ grant({
361
+ resource: "wallet:*",
362
+ action: "spend",
363
+ effect: "allow",
364
+ conditions: { unknown_cond: true },
365
+ }),
366
+ ];
367
+
368
+ expect(
369
+ evaluateGrants(grants, "wallet:wal_abc", "spend", { registry: {} }),
370
+ ).rejects.toThrow('Unknown condition: "unknown_cond"');
371
+ });
372
+
373
+ test("conditional and unconditional grants coexist", async () => {
374
+ const registry: ConditionRegistry = {
375
+ time_window: () => false,
376
+ };
377
+ const grants = [
378
+ grant({
379
+ id: "g1",
380
+ resource: "agent:*",
381
+ action: "read",
382
+ effect: "allow",
383
+ conditions: { time_window: { start: "09:00", end: "17:00" } },
384
+ }),
385
+ grant({
386
+ id: "g2",
387
+ resource: "*",
388
+ action: "*",
389
+ effect: "allow",
390
+ }),
391
+ ];
392
+
393
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read", {
394
+ registry,
395
+ });
396
+
397
+ // g1 is skipped (condition failed), g2 matches
398
+ expect(result.effect).toBe("allow");
399
+ expect(result.matchingGrants).toHaveLength(1);
400
+ expect(result.resolvedBy?.id).toBe("g2");
401
+ });
402
+
403
+ test("more specific conditional grant beats less specific unconditional", async () => {
404
+ const registry: ConditionRegistry = {
405
+ time_window: () => true,
406
+ };
407
+ const grants = [
408
+ grant({
409
+ id: "g1",
410
+ resource: "*",
411
+ action: "*",
412
+ effect: "allow",
413
+ }),
414
+ grant({
415
+ id: "g2",
416
+ resource: "agent:*",
417
+ action: "read",
418
+ effect: "deny",
419
+ conditions: { time_window: { start: "09:00", end: "17:00" } },
420
+ }),
421
+ ];
422
+
423
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read", {
424
+ registry,
425
+ });
426
+
427
+ expect(result.effect).toBe("deny");
428
+ expect(result.resolvedBy?.id).toBe("g2");
429
+ });
430
+
431
+ test("context fields are passed through to condition evaluators", async () => {
432
+ let capturedPrincipal = "";
433
+ let capturedTenant = "";
434
+ const registry: ConditionRegistry = {
435
+ spy: (_value, ctx) => {
436
+ capturedPrincipal = ctx.principalId;
437
+ capturedTenant = ctx.tenantId;
438
+ return true;
439
+ },
440
+ };
441
+ const grants = [
442
+ grant({
443
+ resource: "*",
444
+ action: "*",
445
+ effect: "allow",
446
+ conditions: { spy: null },
447
+ }),
448
+ ];
449
+
450
+ await evaluateGrants(grants, "agent:*", "read", {
451
+ registry,
452
+ principalId: "prn_ctx",
453
+ tenantId: "tnt_ctx",
454
+ });
455
+
456
+ expect(capturedPrincipal).toBe("prn_ctx");
457
+ expect(capturedTenant).toBe("tnt_ctx");
458
+ });
459
+ });
460
+
461
+ describe("evaluateGrants nested resource patterns", () => {
462
+ test("api:stripe:* matches api:stripe:charges", async () => {
463
+ const grants = [
464
+ grant({ resource: "api:stripe:*", action: "invoke", effect: "allow" }),
465
+ ];
466
+
467
+ const result = await evaluateGrants(grants, "api:stripe:charges", "invoke");
468
+
469
+ expect(result.effect).toBe("allow");
470
+ });
471
+
472
+ test("api:* matches api:stripe:charges (broader wildcard)", async () => {
473
+ const grants = [
474
+ grant({ resource: "api:*", action: "invoke", effect: "allow" }),
475
+ ];
476
+
477
+ const result = await evaluateGrants(grants, "api:stripe:charges", "invoke");
478
+
479
+ expect(result.effect).toBe("allow");
480
+ });
481
+
482
+ test("more specific nested pattern beats broader wildcard", async () => {
483
+ const grants = [
484
+ grant({ resource: "api:*", action: "invoke", effect: "allow" }),
485
+ grant({ resource: "api:stripe:*", action: "invoke", effect: "deny" }),
486
+ ];
487
+
488
+ const result = await evaluateGrants(grants, "api:stripe:charges", "invoke");
489
+
490
+ expect(result.effect).toBe("deny");
491
+ expect(result.resolvedBy?.resource).toBe("api:stripe:*");
492
+ });
493
+ });
494
+
495
+ describe("evaluateGrants no-match contract", () => {
496
+ test("no-match returns null effect, not ask", async () => {
497
+ const grants = [
498
+ grant({ resource: "wallet:*", action: "read", effect: "allow" }),
499
+ ];
500
+
501
+ const result = await evaluateGrants(grants, "agent:agt_abc", "read");
502
+
503
+ expect(result.effect).toBeNull();
504
+ expect(result.effect).not.toBe("ask");
505
+ });
506
+
507
+ test("empty grants with empty resource and action returns null", async () => {
508
+ const result = await evaluateGrants([], "", "");
509
+
510
+ expect(result.effect).toBeNull();
511
+ expect(result.matchingGrants).toHaveLength(0);
512
+ expect(result.resolvedBy).toBeNull();
513
+ });
514
+ });
515
+
516
+ describe("evaluateGrants action verbs from spec", () => {
517
+ test("invoke action is matched correctly", async () => {
518
+ const grants = [
519
+ grant({ resource: "tool:bash", action: "invoke", effect: "allow" }),
520
+ ];
521
+
522
+ expect((await evaluateGrants(grants, "tool:bash", "invoke")).effect).toBe(
523
+ "allow",
524
+ );
525
+ expect(
526
+ (await evaluateGrants(grants, "tool:bash", "read")).effect,
527
+ ).toBeNull();
528
+ });
529
+
530
+ test("spend action with conditions via registry", async () => {
531
+ const registry: ConditionRegistry = {
532
+ max_spend_per_day: (value) => typeof value === "number" && value > 50,
533
+ };
534
+ const grants = [
535
+ grant({
536
+ resource: "wallet:*",
537
+ action: "spend",
538
+ effect: "allow",
539
+ conditions: { max_spend_per_day: 100 },
540
+ }),
541
+ ];
542
+
543
+ expect(
544
+ (await evaluateGrants(grants, "wallet:wal_123", "spend", { registry }))
545
+ .effect,
546
+ ).toBe("allow");
547
+ expect(
548
+ (await evaluateGrants(grants, "wallet:wal_123", "manage", { registry }))
549
+ .effect,
550
+ ).toBeNull();
551
+ });
552
+
553
+ test("wildcard action at lower specificity loses to exact action", async () => {
554
+ const grants = [
555
+ grant({ resource: "tool:*", action: "*", effect: "allow" }),
556
+ grant({ resource: "tool:*", action: "invoke", effect: "deny" }),
557
+ ];
558
+
559
+ const result = await evaluateGrants(grants, "tool:bash", "invoke");
560
+
561
+ expect(result.effect).toBe("deny");
562
+ });
563
+ });
564
+
565
+ describe("evaluateGrants large grant set", () => {
566
+ test("correct resolution across many grants of varying specificity", async () => {
567
+ const grants = [
568
+ grant({ id: "g01", resource: "*", action: "*", effect: "allow" }),
569
+ grant({ id: "g02", resource: "agent:*", action: "*", effect: "allow" }),
570
+ grant({
571
+ id: "g03",
572
+ resource: "agent:*",
573
+ action: "read",
574
+ effect: "allow",
575
+ }),
576
+ grant({
577
+ id: "g04",
578
+ resource: "wallet:*",
579
+ action: "read",
580
+ effect: "allow",
581
+ }),
582
+ grant({
583
+ id: "g05",
584
+ resource: "wallet:*",
585
+ action: "spend",
586
+ effect: "ask",
587
+ }),
588
+ grant({
589
+ id: "g06",
590
+ resource: "tool:*",
591
+ action: "invoke",
592
+ effect: "allow",
593
+ }),
594
+ grant({
595
+ id: "g07",
596
+ resource: "tool:bash",
597
+ action: "invoke",
598
+ effect: "deny",
599
+ }),
600
+ grant({
601
+ id: "g08",
602
+ resource: "credential:*",
603
+ action: "use",
604
+ effect: "ask",
605
+ }),
606
+ grant({
607
+ id: "g09",
608
+ resource: "api:stripe:*",
609
+ action: "invoke",
610
+ effect: "allow",
611
+ }),
612
+ grant({
613
+ id: "g10",
614
+ resource: "api:stripe:charges",
615
+ action: "invoke",
616
+ effect: "deny",
617
+ }),
618
+ grant({
619
+ id: "g11",
620
+ resource: "documents:*",
621
+ action: "read",
622
+ effect: "allow",
623
+ }),
624
+ grant({
625
+ id: "g12",
626
+ resource: "documents:*",
627
+ action: "write",
628
+ effect: "ask",
629
+ }),
630
+ ];
631
+
632
+ const bash = await evaluateGrants(grants, "tool:bash", "invoke");
633
+ expect(bash.effect).toBe("deny");
634
+ expect(bash.resolvedBy?.id).toBe("g07");
635
+
636
+ const charges = await evaluateGrants(
637
+ grants,
638
+ "api:stripe:charges",
639
+ "invoke",
640
+ );
641
+ expect(charges.effect).toBe("deny");
642
+ expect(charges.resolvedBy?.id).toBe("g10");
643
+
644
+ const agentRead = await evaluateGrants(grants, "agent:agt_abc", "read");
645
+ expect(agentRead.effect).toBe("allow");
646
+ expect(agentRead.resolvedBy?.id).toBe("g03");
647
+
648
+ const walletSpend = await evaluateGrants(grants, "wallet:wal_123", "spend");
649
+ expect(walletSpend.effect).toBe("ask");
650
+ expect(walletSpend.resolvedBy?.id).toBe("g05");
651
+
652
+ const agentCreate = await evaluateGrants(grants, "agent:agt_abc", "create");
653
+ expect(agentCreate.effect).toBe("allow");
654
+ expect(agentCreate.resolvedBy?.id).toBe("g02");
655
+
656
+ const docWrite = await evaluateGrants(grants, "documents:doc_1", "write");
657
+ expect(docWrite.effect).toBe("ask");
658
+ expect(docWrite.resolvedBy?.id).toBe("g12");
659
+ });
660
+ });
661
+
662
+ function memoryStore(grants: GrantRule[]): GrantStore {
663
+ return {
664
+ async collectGrants() {
665
+ return grants;
666
+ },
667
+ };
668
+ }
669
+
670
+ describe("authorize with in-memory store", () => {
671
+ test("delegates to evaluateGrants with collected grants", async () => {
672
+ const store = memoryStore([
673
+ grant({ resource: "*", action: "*", effect: "allow" }),
674
+ ]);
675
+
676
+ const result = await authorize(
677
+ store,
678
+ "prn_1",
679
+ "tnt_1",
680
+ "agent:agt_abc",
681
+ "read",
682
+ );
683
+
684
+ expect(result.effect).toBe("allow");
685
+ expect(result.matchingGrants).toHaveLength(1);
686
+ });
687
+
688
+ test("returns null when store returns no grants", async () => {
689
+ const store = memoryStore([]);
690
+
691
+ const result = await authorize(
692
+ store,
693
+ "prn_1",
694
+ "tnt_1",
695
+ "agent:agt_abc",
696
+ "read",
697
+ );
698
+
699
+ expect(result.effect).toBeNull();
700
+ });
701
+
702
+ test("specificity resolution works through authorize", async () => {
703
+ const store = memoryStore([
704
+ grant({ resource: "*", action: "*", effect: "allow" }),
705
+ grant({ resource: "agent:*", action: "read", effect: "deny" }),
706
+ ]);
707
+
708
+ const result = await authorize(
709
+ store,
710
+ "prn_1",
711
+ "tnt_1",
712
+ "agent:agt_abc",
713
+ "read",
714
+ );
715
+
716
+ expect(result.effect).toBe("deny");
717
+ expect(result.resolvedBy?.resource).toBe("agent:*");
718
+ });
719
+
720
+ test("store receives principalId and tenantId", async () => {
721
+ let receivedPrincipalId = "";
722
+ let receivedTenantId = "";
723
+
724
+ const store: GrantStore = {
725
+ async collectGrants(principalId, tenantId) {
726
+ receivedPrincipalId = principalId;
727
+ receivedTenantId = tenantId;
728
+ return [grant({ resource: "*", action: "*", effect: "allow" })];
729
+ },
730
+ };
731
+
732
+ await authorize(store, "prn_abc", "tnt_xyz", "agent:*", "read");
733
+
734
+ expect(receivedPrincipalId).toBe("prn_abc");
735
+ expect(receivedTenantId).toBe("tnt_xyz");
736
+ });
737
+
738
+ test("full scenario: role-based grants via store", async () => {
739
+ const store = memoryStore([
740
+ grant({ resource: "*", action: "read", effect: "allow", origin: "role" }),
741
+ grant({
742
+ resource: "wallet:*",
743
+ action: "spend",
744
+ effect: "ask",
745
+ origin: "role",
746
+ }),
747
+ grant({
748
+ resource: "tool:bash",
749
+ action: "invoke",
750
+ effect: "deny",
751
+ origin: "creator",
752
+ }),
753
+ ]);
754
+
755
+ const read = await authorize(
756
+ store,
757
+ "prn_1",
758
+ "tnt_1",
759
+ "agent:agt_1",
760
+ "read",
761
+ );
762
+ expect(read.effect).toBe("allow");
763
+
764
+ const spend = await authorize(
765
+ store,
766
+ "prn_1",
767
+ "tnt_1",
768
+ "wallet:wal_1",
769
+ "spend",
770
+ );
771
+ expect(spend.effect).toBe("ask");
772
+
773
+ const bash = await authorize(
774
+ store,
775
+ "prn_1",
776
+ "tnt_1",
777
+ "tool:bash",
778
+ "invoke",
779
+ );
780
+ expect(bash.effect).toBe("deny");
781
+
782
+ const noMatch = await authorize(
783
+ store,
784
+ "prn_1",
785
+ "tnt_1",
786
+ "agent:agt_1",
787
+ "manage",
788
+ );
789
+ expect(noMatch.effect).toBeNull();
790
+ });
791
+
792
+ test("passes registry through to condition evaluation", async () => {
793
+ const registry: ConditionRegistry = {
794
+ time_window: () => true,
795
+ };
796
+ const store = memoryStore([
797
+ grant({
798
+ resource: "agent:*",
799
+ action: "read",
800
+ effect: "allow",
801
+ conditions: { time_window: { start: "09:00", end: "17:00" } },
802
+ }),
803
+ ]);
804
+
805
+ const result = await authorize(
806
+ store,
807
+ "prn_1",
808
+ "tnt_1",
809
+ "agent:agt_abc",
810
+ "read",
811
+ registry,
812
+ );
813
+
814
+ expect(result.effect).toBe("allow");
815
+ });
816
+
817
+ test("conditional grant skipped without registry in authorize", async () => {
818
+ const store = memoryStore([
819
+ grant({
820
+ resource: "agent:*",
821
+ action: "read",
822
+ effect: "allow",
823
+ conditions: { time_window: { start: "09:00", end: "17:00" } },
824
+ }),
825
+ ]);
826
+
827
+ const result = await authorize(
828
+ store,
829
+ "prn_1",
830
+ "tnt_1",
831
+ "agent:agt_abc",
832
+ "read",
833
+ );
834
+
835
+ expect(result.effect).toBeNull();
836
+ });
837
+
838
+ test("authorize passes principalId and tenantId to condition context", async () => {
839
+ let capturedPrincipal = "";
840
+ let capturedTenant = "";
841
+ const registry: ConditionRegistry = {
842
+ spy: (_v, ctx) => {
843
+ capturedPrincipal = ctx.principalId;
844
+ capturedTenant = ctx.tenantId;
845
+ return true;
846
+ },
847
+ };
848
+ const store = memoryStore([
849
+ grant({
850
+ resource: "*",
851
+ action: "*",
852
+ effect: "allow",
853
+ conditions: { spy: null },
854
+ }),
855
+ ]);
856
+
857
+ await authorize(
858
+ store,
859
+ "prn_check",
860
+ "tnt_check",
861
+ "agent:*",
862
+ "read",
863
+ registry,
864
+ );
865
+
866
+ expect(capturedPrincipal).toBe("prn_check");
867
+ expect(capturedTenant).toBe("tnt_check");
868
+ });
869
+ });
870
+
871
+ describe("evaluateGrants — expiry", () => {
872
+ test("expired grant is skipped", async () => {
873
+ const past = new Date(Date.now() - 60_000);
874
+ const grants = [
875
+ grant({
876
+ resource: "tool:bash",
877
+ action: "invoke",
878
+ effect: "allow",
879
+ expiresAt: past,
880
+ }),
881
+ ];
882
+
883
+ const result = await evaluateGrants(grants, "tool:bash", "invoke");
884
+ expect(result.effect).toBeNull();
885
+ });
886
+
887
+ test("non-expired grant is evaluated normally", async () => {
888
+ const future = new Date(Date.now() + 60_000);
889
+ const grants = [
890
+ grant({
891
+ resource: "tool:bash",
892
+ action: "invoke",
893
+ effect: "allow",
894
+ expiresAt: future,
895
+ }),
896
+ ];
897
+
898
+ const result = await evaluateGrants(grants, "tool:bash", "invoke");
899
+ expect(result.effect).toBe("allow");
900
+ });
901
+
902
+ test("expired deny does not override a live allow", async () => {
903
+ const past = new Date(Date.now() - 60_000);
904
+ const grants = [
905
+ grant({
906
+ resource: "tool:bash",
907
+ action: "invoke",
908
+ effect: "allow",
909
+ expiresAt: null,
910
+ }),
911
+ grant({
912
+ resource: "tool:bash",
913
+ action: "invoke",
914
+ effect: "deny",
915
+ expiresAt: past,
916
+ }),
917
+ ];
918
+
919
+ const result = await evaluateGrants(grants, "tool:bash", "invoke");
920
+ expect(result.effect).toBe("allow");
921
+ });
922
+
923
+ test("null expiresAt is treated as never-expiring", async () => {
924
+ const grants = [
925
+ grant({
926
+ resource: "tool:bash",
927
+ action: "invoke",
928
+ effect: "allow",
929
+ expiresAt: null,
930
+ }),
931
+ ];
932
+
933
+ const result = await evaluateGrants(grants, "tool:bash", "invoke");
934
+ expect(result.effect).toBe("allow");
935
+ });
936
+ });