@@ -38,18 +38,50 @@ const Scope = struct {
3838
3939/// Run a single evaluation. `arena` must already be initialised; this
4040/// function neither inits nor resets it -- that is the caller's job.
41+ ///
42+ /// Targets the default package ("") and the default rule ("allow").
43+ /// Use `evaluateAddressed` to dispatch into a specific `package.rule`
44+ /// pair within a `{"type":"modules", ...}` bundle.
4145pub fn evaluate (
4246 arena : * std.heap.ArenaAllocator ,
4347 input_json : []const u8 ,
4448 ast_json : []const u8 ,
49+ ) ! bool {
50+ return evaluateAddressed (arena , input_json , ast_json , "" , default_target_rule );
51+ }
52+
53+ /// Run a single evaluation against `target_package.target_rule`. The
54+ /// AST source can be either a single module or a `Modules` bundle;
55+ /// the legacy single-module form is treated as `package = ""`.
56+ pub fn evaluateAddressed (
57+ arena : * std.heap.ArenaAllocator ,
58+ input_json : []const u8 ,
59+ ast_json : []const u8 ,
60+ target_package : []const u8 ,
61+ target_rule : []const u8 ,
4562) ! bool {
4663 const allocator = arena .allocator ();
4764
4865 const input_value = try json .parse (allocator , input_json );
4966 const ast_value = try json .parse (allocator , ast_json );
50- const module = try ast .buildModule (allocator , ast_value );
67+ const bundle = try ast .buildModulesBundle (allocator , ast_value );
5168
52- return evalModule (module , default_target_rule , input_value );
69+ return evalBundle (bundle , target_package , target_rule , input_value );
70+ }
71+
72+ /// OR-combine evalModule across every module whose package matches.
73+ /// Empty match set returns `false` (deny by default).
74+ fn evalBundle (
75+ bundle : ast.Modules ,
76+ target_package : []const u8 ,
77+ target_rule : []const u8 ,
78+ input : json.Value ,
79+ ) ! bool {
80+ for (bundle .modules ) | module | {
81+ if (! std .mem .eql (u8 , module .package , target_package )) continue ;
82+ if (try evalModule (module , target_rule , input )) return true ;
83+ }
84+ return false ;
5385}
5486
5587/// Walk every rule named `target`. A `default` rule's value becomes
@@ -317,6 +349,72 @@ test "evaluate: default rule when no other rule matches" {
317349 try testing .expect (try run ("{\" role\" :\" admin\" }" , policy ));
318350}
319351
352+ fn runAddressed (input : []const u8 , ast_src : []const u8 , pkg : []const u8 , rule : []const u8 ) ! bool {
353+ var arena = std .heap .ArenaAllocator .init (testing .allocator );
354+ defer arena .deinit ();
355+ return evaluateAddressed (& arena , input , ast_src , pkg , rule );
356+ }
357+
358+ test "modules bundle: address authz.allow vs audit.allow" {
359+ const policy =
360+ "{\" type\" :\" modules\" ,\" modules\" :[" ++
361+ "{\" type\" :\" module\" ,\" package\" :\" authz\" ,\" rules\" :[" ++
362+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
363+ "{\" type\" :\" eq\" ," ++
364+ "\" left\" :{\" type\" :\" ref\" ,\" path\" :[\" input\" ,\" role\" ]}," ++
365+ "\" right\" :{\" type\" :\" value\" ,\" value\" :\" admin\" }}]}]}," ++
366+ "{\" type\" :\" module\" ,\" package\" :\" audit\" ,\" rules\" :[" ++
367+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
368+ "{\" type\" :\" value\" ,\" value\" :true}]}]}" ++
369+ "]}" ;
370+
371+ // authz.allow fires only on admin.
372+ try testing .expect (try runAddressed ("{\" role\" :\" admin\" }" , policy , "authz" , "allow" ));
373+ try testing .expect (! (try runAddressed ("{\" role\" :\" guest\" }" , policy , "authz" , "allow" )));
374+
375+ // audit.allow always fires regardless of role.
376+ try testing .expect (try runAddressed ("{\" role\" :\" guest\" }" , policy , "audit" , "allow" ));
377+ }
378+
379+ test "modules bundle: missing package -> deny" {
380+ const policy =
381+ "{\" type\" :\" modules\" ,\" modules\" :[" ++
382+ "{\" type\" :\" module\" ,\" package\" :\" authz\" ,\" rules\" :[" ++
383+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
384+ "{\" type\" :\" value\" ,\" value\" :true}]}]}" ++
385+ "]}" ;
386+ try testing .expect (! (try runAddressed ("{}" , policy , "missing" , "allow" )));
387+ }
388+
389+ test "modules bundle: bare module wraps as package='' (backwards compat)" {
390+ const policy =
391+ "{\" type\" :\" module\" ,\" rules\" :[" ++
392+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
393+ "{\" type\" :\" value\" ,\" value\" :true}]}]}" ;
394+ try testing .expect (try runAddressed ("{}" , policy , "" , "allow" ));
395+ // Same module via the default `evaluate` entry must still allow.
396+ try testing .expect (try run ("{}" , policy ));
397+ }
398+
399+ test "modules bundle: OR across two modules in same package" {
400+ const policy =
401+ "{\" type\" :\" modules\" ,\" modules\" :[" ++
402+ "{\" type\" :\" module\" ,\" package\" :\" authz\" ,\" rules\" :[" ++
403+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
404+ "{\" type\" :\" eq\" ," ++
405+ "\" left\" :{\" type\" :\" ref\" ,\" path\" :[\" input\" ,\" role\" ]}," ++
406+ "\" right\" :{\" type\" :\" value\" ,\" value\" :\" admin\" }}]}]}," ++
407+ "{\" type\" :\" module\" ,\" package\" :\" authz\" ,\" rules\" :[" ++
408+ "{\" type\" :\" rule\" ,\" name\" :\" allow\" ,\" body\" :[" ++
409+ "{\" type\" :\" eq\" ," ++
410+ "\" left\" :{\" type\" :\" ref\" ,\" path\" :[\" input\" ,\" role\" ]}," ++
411+ "\" right\" :{\" type\" :\" value\" ,\" value\" :\" editor\" }}]}]}" ++
412+ "]}" ;
413+ try testing .expect (try runAddressed ("{\" role\" :\" admin\" }" , policy , "authz" , "allow" ));
414+ try testing .expect (try runAddressed ("{\" role\" :\" editor\" }" , policy , "authz" , "allow" ));
415+ try testing .expect (! (try runAddressed ("{\" role\" :\" guest\" }" , policy , "authz" , "allow" )));
416+ }
417+
320418test "evaluate: every+some over arrays" {
321419 const policy =
322420 "{\" type\" :\" every\" ,\" var\" :\" req\" ," ++
0 commit comments