Skip to content

Commit 0e18049

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 5dcf93e commit 0e18049

5 files changed

Lines changed: 269 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ jobs:
4141
path: zig-out/bin/zopa.wasm
4242
if-no-files-found: error
4343

44+
test-unit:
45+
name: test (unit)
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
49+
50+
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
51+
with:
52+
version: 0.16.0
53+
54+
- name: zig build test-unit
55+
run: zig build test-unit
56+
4457
test-node:
4558
name: test (node)
4659
runs-on: ubuntu-latest

src/ast.zig

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

7070
/// A compiled policy module. Rules with the same name OR-combine.
71+
/// `package` is optional ("" means the implicit single-module case);
72+
/// see `Modules` for how multi-package bundles are addressed.
7173
pub const Module = struct {
7274
rules: []const Rule,
75+
package: []const u8 = "",
76+
};
77+
78+
/// A bundle of modules. Allows the proxy-wasm shim to load multiple
79+
/// independently authored policies into a single VM and dispatch
80+
/// per request to a specific `(package, rule)` target.
81+
///
82+
/// Bare `{"type":"module",...}` and bare expressions still work via
83+
/// `buildModule`; `buildModulesBundle` wraps either form into a
84+
/// single-element bundle with `package=""` for backwards compat.
85+
pub const Modules = struct {
86+
modules: []const Module,
7387
};
7488

7589
// Builders. Consume a `json.Value`, return arena-allocated nodes.
7690

7791
/// Build a `Module`. Accepts either the canonical
7892
/// `{"type":"module","rules":[...]}` shape or a bare expression,
79-
/// which is wrapped into a synthetic `allow` rule.
93+
/// which is wrapped into a synthetic `allow` rule. `package` is
94+
/// read from the canonical shape if present.
8095
pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
8196
if (node != .object) return error.InvalidAst;
8297

@@ -89,7 +104,11 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
89104
for (rules_v.array, 0..) |item, i| {
90105
rules[i] = try buildRule(allocator, item);
91106
}
92-
return .{ .rules = rules };
107+
const pkg: []const u8 = if (json.lookupMember(node.object, "package")) |p_v| pkg_blk: {
108+
if (p_v != .string) return error.InvalidPackage;
109+
break :pkg_blk p_v.string;
110+
} else "";
111+
return .{ .rules = rules, .package = pkg };
93112
}
94113

95114
// Legacy: bare expression -> single synthetic `allow` rule.
@@ -103,7 +122,32 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module {
103122
.value = null,
104123
.is_default = false,
105124
};
106-
return .{ .rules = rules };
125+
return .{ .rules = rules, .package = "" };
126+
}
127+
128+
/// Build a `Modules` bundle. Accepts:
129+
/// `{"type":"modules","modules":[<module>, ...]}` -> multi-package
130+
/// `{"type":"module", ...}` -> single module
131+
/// bare expression -> synthetic allow
132+
pub fn buildModulesBundle(allocator: std.mem.Allocator, node: Value) !Modules {
133+
if (node == .object) {
134+
if (json.lookupMember(node.object, "type")) |t_v| {
135+
if (t_v == .string and std.mem.eql(u8, t_v.string, "modules")) {
136+
const list_v = try requireField(node.object, "modules");
137+
if (list_v != .array) return error.InvalidModules;
138+
const mods = try allocator.alloc(Module, list_v.array.len);
139+
for (list_v.array, 0..) |item, i| {
140+
mods[i] = try buildModule(allocator, item);
141+
}
142+
return .{ .modules = mods };
143+
}
144+
}
145+
}
146+
// Single-module / bare-expression form: wrap into a 1-element bundle.
147+
const single = try buildModule(allocator, node);
148+
const mods = try allocator.alloc(Module, 1);
149+
mods[0] = single;
150+
return .{ .modules = mods };
107151
}
108152

109153
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
@@ -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.
4145
pub 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+
320418
test "evaluate: every+some over arrays" {
321419
const policy =
322420
"{\"type\":\"every\",\"var\":\"req\"," ++

test/run.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,69 @@ check(
354354
0,
355355
);
356356

357+
// ---------------------------------------------------------------------------
358+
// 8. modules bundle: package addressing
359+
//
360+
// The default `evaluate` ABI targets package="" + rule="allow", so a
361+
// bare module wrapped at the top level keeps working. A modules
362+
// bundle with package="" still answers via the default entry point.
363+
// (Cross-package addressing is exercised by zig build test-unit.)
364+
// ---------------------------------------------------------------------------
365+
const wrappedModule = {
366+
type: 'modules',
367+
modules: [
368+
{
369+
type: 'module',
370+
package: '',
371+
rules: [
372+
{
373+
type: 'rule',
374+
name: 'allow',
375+
body: [
376+
{ type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } },
377+
],
378+
},
379+
],
380+
},
381+
],
382+
};
383+
check('modules bundle: empty package -> allow when admin', decide({ user: { role: 'admin' } }, wrappedModule), 1);
384+
check('modules bundle: empty package -> deny when guest', decide({ user: { role: 'guest' } }, wrappedModule), 0);
385+
386+
// Bundle with two packages: the default ABI only sees the empty-package
387+
// module. The "audit" module is invisible from the default entry.
388+
const twoPackages = {
389+
type: 'modules',
390+
modules: [
391+
{
392+
type: 'module',
393+
package: '',
394+
rules: [
395+
{
396+
type: 'rule',
397+
name: 'allow',
398+
body: [
399+
{ type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } },
400+
],
401+
},
402+
],
403+
},
404+
{
405+
type: 'module',
406+
package: 'audit',
407+
rules: [
408+
{
409+
type: 'rule',
410+
name: 'allow',
411+
body: [{ type: 'value', value: true }],
412+
},
413+
],
414+
},
415+
],
416+
};
417+
check('modules bundle: default entry picks empty package', decide({ user: { role: 'admin' } }, twoPackages), 1);
418+
check('modules bundle: audit module invisible from default entry', decide({ user: { role: 'guest' } }, twoPackages), 0);
419+
357420
if (failed > 0) {
358421
console.error(`\n${failed} test(s) failed`);
359422
exit(1);

test/run_wasmtime.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,52 @@ def check(name: str, got, expected):
238238
0,
239239
)
240240

241+
# ---------------------------------------------------------------------------
242+
# 8. modules bundle: package addressing (default entry point only)
243+
# ---------------------------------------------------------------------------
244+
wrapped_module = {
245+
"type": "modules",
246+
"modules": [
247+
{
248+
"type": "module",
249+
"package": "",
250+
"rules": [
251+
{
252+
"type": "rule",
253+
"name": "allow",
254+
"body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}],
255+
}
256+
],
257+
}
258+
],
259+
}
260+
check("modules bundle: empty package -> allow when admin", decide({"user": {"role": "admin"}}, wrapped_module), 1)
261+
check("modules bundle: empty package -> deny when guest", decide({"user": {"role": "guest"}}, wrapped_module), 0)
262+
263+
two_packages = {
264+
"type": "modules",
265+
"modules": [
266+
{
267+
"type": "module",
268+
"package": "",
269+
"rules": [
270+
{
271+
"type": "rule",
272+
"name": "allow",
273+
"body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}],
274+
}
275+
],
276+
},
277+
{
278+
"type": "module",
279+
"package": "audit",
280+
"rules": [{"type": "rule", "name": "allow", "body": [{"type": "value", "value": True}]}],
281+
},
282+
],
283+
}
284+
check("modules bundle: default entry picks empty package", decide({"user": {"role": "admin"}}, two_packages), 1)
285+
check("modules bundle: audit module invisible from default entry", decide({"user": {"role": "guest"}}, two_packages), 0)
286+
241287
if failed:
242288
print(f"\n{failed} test(s) failed", file=sys.stderr)
243289
sys.exit(1)

0 commit comments

Comments
 (0)