Skip to content

Commit da3e557

Browse files
committed
feat(modules): add package field + Modules bundle + addressed evaluate
ast.zig: - Module gains optional package field (default ""). - New Modules struct wrapping a slice of Module. - New buildModulesBundle handles {"type":"modules","modules":[...]} plus the legacy single-module / bare-expression forms (wrapped as a 1-element bundle with package="" for backwards compat). eval.zig: - New evaluateAddressed(arena, input, ast, target_pkg, target_rule) parses the AST as a bundle and dispatches to package.rule. - Existing evaluate() delegates to evaluateAddressed with ("", "allow"), so all existing call sites are untouched. - New evalBundle OR-combines evalModule across every module whose package matches; missing package -> deny by default. Tests: - src/eval.zig: 4 unit tests (cross-package addressing, missing package, backwards compat, OR within a package). - test/run.mjs and test/run_wasmtime.py: 4 cases each verifying the wire format works through the wasm boundary. Release build grows from 50K to 54K. ci.yml gains the test-unit job in line with the other implementation branches.
1 parent 683a791 commit da3e557

4 files changed

Lines changed: 256 additions & 5 deletions

File tree

src/ast.zig

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,30 @@ pub const Rule = struct {
9191
};
9292

9393
/// A compiled policy module. Rules with the same name OR-combine.
94+
/// `package` is optional ("" means the implicit single-module case);
95+
/// see `Modules` for how multi-package bundles are addressed.
9496
pub const Module = struct {
9597
rules: []const Rule,
98+
package: []const u8 = "",
99+
};
100+
101+
/// A bundle of modules. Allows the proxy-wasm shim to load multiple
102+
/// independently authored policies into a single VM and dispatch
103+
/// per request to a specific `(package, rule)` target.
104+
///
105+
/// Bare `{"type":"module",...}` and bare expressions still work via
106+
/// `buildModule`; `buildModulesBundle` wraps either form into a
107+
/// single-element bundle with `package=""` for backwards compat.
108+
pub const Modules = struct {
109+
modules: []const Module,
96110
};
97111

98112
// Builders. Consume a `json.Value`, return arena-allocated nodes.
99113

100114
/// Build a `Module`. Accepts either the canonical
101115
/// `{"type":"module","rules":[...]}` shape or a bare expression,
102-
/// which is wrapped into a synthetic `allow` rule.
116+
/// which is wrapped into a synthetic `allow` rule. `package` is
117+
/// read from the canonical shape if present.
103118
pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
104119
if (node != .object) return error.InvalidAst;
105120

@@ -112,7 +127,11 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
112127
for (rules_v.array, 0..) |item, i| {
113128
rules[i] = try buildRule(allocator, item);
114129
}
115-
return .{ .rules = rules };
130+
const pkg: []const u8 = if (json.lookupMember(node.object, "package")) |p_v| pkg_blk: {
131+
if (p_v != .string) return error.InvalidPackage;
132+
break :pkg_blk p_v.string;
133+
} else "";
134+
return .{ .rules = rules, .package = pkg };
116135
}
117136

118137
// Legacy: bare expression -> single synthetic `allow` rule.
@@ -126,7 +145,32 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
126145
.value = null,
127146
.is_default = false,
128147
};
129-
return .{ .rules = rules };
148+
return .{ .rules = rules, .package = "" };
149+
}
150+
151+
/// Build a `Modules` bundle. Accepts:
152+
/// `{"type":"modules","modules":[<module>, ...]}` -> multi-package
153+
/// `{"type":"module", ...}` -> single module
154+
/// bare expression -> synthetic allow
155+
pub fn buildModulesBundle(allocator: std.mem.Allocator, node: Value) !Modules {
156+
if (node == .object) {
157+
if (json.lookupMember(node.object, "type")) |t_v| {
158+
if (t_v == .string and std.mem.eql(u8, t_v.string, "modules")) {
159+
const list_v = try requireField(node.object, "modules");
160+
if (list_v != .array) return error.InvalidModules;
161+
const mods = try allocator.alloc(Module, list_v.array.len);
162+
for (list_v.array, 0..) |item, i| {
163+
mods[i] = try buildModule(allocator, item);
164+
}
165+
return .{ .modules = mods };
166+
}
167+
}
168+
}
169+
// Single-module / bare-expression form: wrap into a 1-element bundle.
170+
const single = try buildModule(allocator, node);
171+
const mods = try allocator.alloc(Module, 1);
172+
mods[0] = single;
173+
return .{ .modules = mods };
130174
}
131175

132176
fn buildRule(allocator: std.mem.Allocator, node: Value) !Rule {

src/eval.zig

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,50 @@ const Scope = struct {
4343

4444
/// Run a single evaluation. `arena` must already be initialised; this
4545
/// function neither inits nor resets it -- that is the caller's job.
46+
///
47+
/// Targets the default package ("") and the default rule ("allow").
48+
/// Use `evaluateAddressed` to dispatch into a specific `package.rule`
49+
/// pair within a `{"type":"modules", ...}` bundle.
4650
pub fn evaluate(
4751
arena: *std.heap.ArenaAllocator,
4852
input_json: []const u8,
4953
ast_json: []const u8,
54+
) !bool {
55+
return evaluateAddressed(arena, input_json, ast_json, "", default_target_rule);
56+
}
57+
58+
/// Run a single evaluation against `target_package.target_rule`. The
59+
/// AST source can be either a single module or a `Modules` bundle;
60+
/// the legacy single-module form is treated as `package = ""`.
61+
pub fn evaluateAddressed(
62+
arena: *std.heap.ArenaAllocator,
63+
input_json: []const u8,
64+
ast_json: []const u8,
65+
target_package: []const u8,
66+
target_rule: []const u8,
5067
) !bool {
5168
const allocator = arena.allocator();
5269

5370
const input_value = try json.parse(allocator, input_json);
5471
const ast_value = try json.parse(allocator, ast_json);
55-
const module = try ast.buildModule(allocator, ast_value);
72+
const bundle = try ast.buildModulesBundle(allocator, ast_value);
5673

57-
return evalModule(module, default_target_rule, input_value);
74+
return evalBundle(bundle, target_package, target_rule, input_value);
75+
}
76+
77+
/// OR-combine evalModule across every module whose package matches.
78+
/// Empty match set returns `false` (deny by default).
79+
fn evalBundle(
80+
bundle: ast.Modules,
81+
target_package: []const u8,
82+
target_rule: []const u8,
83+
input: json.Value,
84+
) !bool {
85+
for (bundle.modules) |module| {
86+
if (!std.mem.eql(u8, module.package, target_package)) continue;
87+
if (try evalModule(module, target_rule, input)) return true;
88+
}
89+
return false;
5890
}
5991

6092
/// Walk every rule named `target`. A `default` rule's value becomes
@@ -452,6 +484,72 @@ test "evaluate: every over object defaults to keys" {
452484
try testing.expect(!(try run("{\"m\":{\"x\":1,\"banned\":2}}", policy)));
453485
}
454486

487+
fn runAddressed(input: []const u8, ast_src: []const u8, pkg: []const u8, rule: []const u8) !bool {
488+
var arena = std.heap.ArenaAllocator.init(testing.allocator);
489+
defer arena.deinit();
490+
return evaluateAddressed(&arena, input, ast_src, pkg, rule);
491+
}
492+
493+
test "modules bundle: address authz.allow vs audit.allow" {
494+
const policy =
495+
"{\"type\":\"modules\",\"modules\":[" ++
496+
"{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++
497+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
498+
"{\"type\":\"eq\"," ++
499+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++
500+
"\"right\":{\"type\":\"value\",\"value\":\"admin\"}}]}]}," ++
501+
"{\"type\":\"module\",\"package\":\"audit\",\"rules\":[" ++
502+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
503+
"{\"type\":\"value\",\"value\":true}]}]}" ++
504+
"]}";
505+
506+
// authz.allow fires only on admin.
507+
try testing.expect(try runAddressed("{\"role\":\"admin\"}", policy, "authz", "allow"));
508+
try testing.expect(!(try runAddressed("{\"role\":\"guest\"}", policy, "authz", "allow")));
509+
510+
// audit.allow always fires regardless of role.
511+
try testing.expect(try runAddressed("{\"role\":\"guest\"}", policy, "audit", "allow"));
512+
}
513+
514+
test "modules bundle: missing package -> deny" {
515+
const policy =
516+
"{\"type\":\"modules\",\"modules\":[" ++
517+
"{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++
518+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
519+
"{\"type\":\"value\",\"value\":true}]}]}" ++
520+
"]}";
521+
try testing.expect(!(try runAddressed("{}", policy, "missing", "allow")));
522+
}
523+
524+
test "modules bundle: bare module wraps as package='' (backwards compat)" {
525+
const policy =
526+
"{\"type\":\"module\",\"rules\":[" ++
527+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
528+
"{\"type\":\"value\",\"value\":true}]}]}";
529+
try testing.expect(try runAddressed("{}", policy, "", "allow"));
530+
// Same module via the default `evaluate` entry must still allow.
531+
try testing.expect(try run("{}", policy));
532+
}
533+
534+
test "modules bundle: OR across two modules in same package" {
535+
const policy =
536+
"{\"type\":\"modules\",\"modules\":[" ++
537+
"{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++
538+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
539+
"{\"type\":\"eq\"," ++
540+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++
541+
"\"right\":{\"type\":\"value\",\"value\":\"admin\"}}]}]}," ++
542+
"{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++
543+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
544+
"{\"type\":\"eq\"," ++
545+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++
546+
"\"right\":{\"type\":\"value\",\"value\":\"editor\"}}]}]}" ++
547+
"]}";
548+
try testing.expect(try runAddressed("{\"role\":\"admin\"}", policy, "authz", "allow"));
549+
try testing.expect(try runAddressed("{\"role\":\"editor\"}", policy, "authz", "allow"));
550+
try testing.expect(!(try runAddressed("{\"role\":\"guest\"}", policy, "authz", "allow")));
551+
}
552+
455553
test "evaluate: every+some over arrays" {
456554
const policy =
457555
"{\"type\":\"every\",\"var\":\"req\"," ++

test/run.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,69 @@ check(
487487
0,
488488
);
489489

490+
// ---------------------------------------------------------------------------
491+
// 10. modules bundle: package addressing
492+
//
493+
// The default `evaluate` ABI targets package="" + rule="allow", so a
494+
// bare module wrapped at the top level keeps working. A modules
495+
// bundle with package="" still answers via the default entry point.
496+
// (Cross-package addressing is exercised by zig build test-unit.)
497+
// ---------------------------------------------------------------------------
498+
const wrappedModule = {
499+
type: 'modules',
500+
modules: [
501+
{
502+
type: 'module',
503+
package: '',
504+
rules: [
505+
{
506+
type: 'rule',
507+
name: 'allow',
508+
body: [
509+
{ type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } },
510+
],
511+
},
512+
],
513+
},
514+
],
515+
};
516+
check('modules bundle: empty package -> allow when admin', decide({ user: { role: 'admin' } }, wrappedModule), 1);
517+
check('modules bundle: empty package -> deny when guest', decide({ user: { role: 'guest' } }, wrappedModule), 0);
518+
519+
// Bundle with two packages: the default ABI only sees the empty-package
520+
// module. The "audit" module is invisible from the default entry.
521+
const twoPackages = {
522+
type: 'modules',
523+
modules: [
524+
{
525+
type: 'module',
526+
package: '',
527+
rules: [
528+
{
529+
type: 'rule',
530+
name: 'allow',
531+
body: [
532+
{ type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } },
533+
],
534+
},
535+
],
536+
},
537+
{
538+
type: 'module',
539+
package: 'audit',
540+
rules: [
541+
{
542+
type: 'rule',
543+
name: 'allow',
544+
body: [{ type: 'value', value: true }],
545+
},
546+
],
547+
},
548+
],
549+
};
550+
check('modules bundle: default entry picks empty package', decide({ user: { role: 'admin' } }, twoPackages), 1);
551+
check('modules bundle: audit module invisible from default entry', decide({ user: { role: 'guest' } }, twoPackages), 0);
552+
490553
if (failed > 0) {
491554
console.error(`\n${failed} test(s) failed`);
492555
exit(1);

test/run_wasmtime.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,52 @@ def check(name: str, got, expected):
364364
0,
365365
)
366366

367+
# ---------------------------------------------------------------------------
368+
# 10. modules bundle: package addressing (default entry point only)
369+
# ---------------------------------------------------------------------------
370+
wrapped_module = {
371+
"type": "modules",
372+
"modules": [
373+
{
374+
"type": "module",
375+
"package": "",
376+
"rules": [
377+
{
378+
"type": "rule",
379+
"name": "allow",
380+
"body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}],
381+
}
382+
],
383+
}
384+
],
385+
}
386+
check("modules bundle: empty package -> allow when admin", decide({"user": {"role": "admin"}}, wrapped_module), 1)
387+
check("modules bundle: empty package -> deny when guest", decide({"user": {"role": "guest"}}, wrapped_module), 0)
388+
389+
two_packages = {
390+
"type": "modules",
391+
"modules": [
392+
{
393+
"type": "module",
394+
"package": "",
395+
"rules": [
396+
{
397+
"type": "rule",
398+
"name": "allow",
399+
"body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}],
400+
}
401+
],
402+
},
403+
{
404+
"type": "module",
405+
"package": "audit",
406+
"rules": [{"type": "rule", "name": "allow", "body": [{"type": "value", "value": True}]}],
407+
},
408+
],
409+
}
410+
check("modules bundle: default entry picks empty package", decide({"user": {"role": "admin"}}, two_packages), 1)
411+
check("modules bundle: audit module invisible from default entry", decide({"user": {"role": "guest"}}, two_packages), 0)
412+
367413
if failed:
368414
print(f"\n{failed} test(s) failed", file=sys.stderr)
369415
sys.exit(1)

0 commit comments

Comments
 (0)