From 683a791acb90715e0e5dc0576b1414b1ac56c3ed Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 9 May 2026 22:02:22 +0900 Subject: [PATCH 1/2] docs(proposal): multiple policies per VM --- docs/proposals/multiple-policies.md | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/proposals/multiple-policies.md diff --git a/docs/proposals/multiple-policies.md b/docs/proposals/multiple-policies.md new file mode 100644 index 0000000..cb5e759 --- /dev/null +++ b/docs/proposals/multiple-policies.md @@ -0,0 +1,126 @@ +# Multiple policies per VM + +Status: Proposed (draft PR, design doc only). +Tracking: ROADMAP.md → Medium term ("Multiple policies"). + +## Motivation + +Today the plugin configuration is a single `module`, evaluated against +a hard-coded target rule named `"allow"`. That's enough for one +filter doing one job. Real deployments often want: + +- Separate authn and authz rules in distinct, independently authored + modules. +- An audit policy whose decision is "log this" rather than "block + this", evaluated alongside the allow policy. +- Per-route policy bundles where the dispatch happens by package + name, mirroring how OPA addresses rules as `data..`. + +Stuffing all of those into one module loses the boundaries that make +the policies maintainable. + +## Goals + +1. Plugin config accepts either a single module (current shape, still + supported) or a list of modules: + + ```json + { "modules": [ + { "package": "authz", "type": "module", "rules": [ ... ] }, + { "package": "audit", "type": "module", "rules": [ ... ] } + ]} + ``` + +1. Each `module` gains an optional `package` string. Defaults to `""` + (the implicit single-module case). +1. The proxy-wasm shim grows a new plugin config field `targets[]` to + pick which `package.rule` pairs are evaluated: + + ```yaml + targets: + - package: authz + rule: allow + on_deny: send_local_response_403 + - package: audit + rule: log + on_deny: noop # decision is recorded out-of-band, not enforced + ``` + +1. Evaluator gains a fully qualified rule lookup: `evalModule(modules, + "authz.allow", input)` instead of just rule-by-name within a single + module. + +## Non-goals + +- Cross-module data references (`data.audit.events` from inside an + authz rule). Modules stay independent. If you need shared data, put + it in `input`. +- Hot-reload of individual modules. Replace the full plugin config or + reconfigure the filter; no per-module surgery. +- A package import system. There's no `import data.helpers` because + there are no shared helpers. + +## Design sketch + +### AST builder + +`src/ast.zig` gains a `Modules` (plural) container. The existing +`Module` keeps its `rules` field plus an optional `package` slice. + +### Evaluator + +`evalModules(target_pkg, target_rule, input)`: + +1. Find all modules whose `package` equals `target_pkg`. +1. Within those, OR-combine all rules whose name equals `target_rule`, + reusing the existing `evalModule` body. +1. Apply the same `default` handling. + +### Proxy-wasm shim + +The `proxy_on_request_headers` callback iterates `targets[]`. For +each: + +- Evaluate `package.rule`. +- If decision is deny and `on_deny` is `send_local_response_403`, + short-circuit immediately. +- If decision is recorded but not enforced (`on_deny: noop`), call + `proxy_log` with a structured line, continue. + +This makes "authz blocks, audit logs" a config decision, not a code +decision. + +### Config compatibility + +A bare module (current shape, no `modules` wrapper) is wrapped into a +synthetic single-element list with `package: ""` and a synthetic +`targets[]` of `[{ package: "", rule: "allow", on_deny: send_403 }]`. +Existing configs keep working unchanged. + +## API impact + +- New plugin config shape `modules[]` + `targets[]` (additive). +- New AST optional field `package` on `module` (additive). +- Existing single-module configs still work. + +## Test plan + +- Unit tests for `evalModules` covering: same package different rules, + same rule different packages, missing package (returns deny by + default). +- Integration test: two modules (`authz`, `audit`) with the audit + policy logging-only. +- Envoy example: extend `examples/envoy/envoy.yaml` to demonstrate + the audit-without-enforce target. + +## Open questions + +- Naming: `targets` vs `evaluations` vs `gates`? `targets` reads as + "what to look for", which matches the existing single-target + vocabulary. +- Should `on_deny: noop` be allowed without a matching `proxy_log` + call, i.e., is silent "informational evaluation" useful? A flag-day + decision; safer default is "log on every audit decision". +- Order of evaluation within `targets[]`: declaration order, or + topologically (audit always before allow)? Declaration order is + simpler and documentable. From da3e557b7294de47fb4efb12346d2e34be66eb64 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 9 May 2026 22:49:34 +0900 Subject: [PATCH 2/2] 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. --- src/ast.zig | 50 +++++++++++++++++++-- src/eval.zig | 102 ++++++++++++++++++++++++++++++++++++++++++- test/run.mjs | 63 ++++++++++++++++++++++++++ test/run_wasmtime.py | 46 +++++++++++++++++++ 4 files changed, 256 insertions(+), 5 deletions(-) diff --git a/src/ast.zig b/src/ast.zig index 884dc01..6ef2dd0 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -91,15 +91,30 @@ pub const Rule = struct { }; /// A compiled policy module. Rules with the same name OR-combine. +/// `package` is optional ("" means the implicit single-module case); +/// see `Modules` for how multi-package bundles are addressed. pub const Module = struct { rules: []const Rule, + package: []const u8 = "", +}; + +/// A bundle of modules. Allows the proxy-wasm shim to load multiple +/// independently authored policies into a single VM and dispatch +/// per request to a specific `(package, rule)` target. +/// +/// Bare `{"type":"module",...}` and bare expressions still work via +/// `buildModule`; `buildModulesBundle` wraps either form into a +/// single-element bundle with `package=""` for backwards compat. +pub const Modules = struct { + modules: []const Module, }; // Builders. Consume a `json.Value`, return arena-allocated nodes. /// Build a `Module`. Accepts either the canonical /// `{"type":"module","rules":[...]}` shape or a bare expression, -/// which is wrapped into a synthetic `allow` rule. +/// which is wrapped into a synthetic `allow` rule. `package` is +/// read from the canonical shape if present. pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module { if (node != .object) return error.InvalidAst; @@ -112,7 +127,11 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module { for (rules_v.array, 0..) |item, i| { rules[i] = try buildRule(allocator, item); } - return .{ .rules = rules }; + const pkg: []const u8 = if (json.lookupMember(node.object, "package")) |p_v| pkg_blk: { + if (p_v != .string) return error.InvalidPackage; + break :pkg_blk p_v.string; + } else ""; + return .{ .rules = rules, .package = pkg }; } // Legacy: bare expression -> single synthetic `allow` rule. @@ -126,7 +145,32 @@ pub fn buildModule(allocator: std.mem.Allocator, node: Value) !Module { .value = null, .is_default = false, }; - return .{ .rules = rules }; + return .{ .rules = rules, .package = "" }; +} + +/// Build a `Modules` bundle. Accepts: +/// `{"type":"modules","modules":[, ...]}` -> multi-package +/// `{"type":"module", ...}` -> single module +/// bare expression -> synthetic allow +pub fn buildModulesBundle(allocator: std.mem.Allocator, node: Value) !Modules { + if (node == .object) { + if (json.lookupMember(node.object, "type")) |t_v| { + if (t_v == .string and std.mem.eql(u8, t_v.string, "modules")) { + const list_v = try requireField(node.object, "modules"); + if (list_v != .array) return error.InvalidModules; + const mods = try allocator.alloc(Module, list_v.array.len); + for (list_v.array, 0..) |item, i| { + mods[i] = try buildModule(allocator, item); + } + return .{ .modules = mods }; + } + } + } + // Single-module / bare-expression form: wrap into a 1-element bundle. + const single = try buildModule(allocator, node); + const mods = try allocator.alloc(Module, 1); + mods[0] = single; + return .{ .modules = mods }; } fn buildRule(allocator: std.mem.Allocator, node: Value) !Rule { diff --git a/src/eval.zig b/src/eval.zig index e82d9d1..753c611 100644 --- a/src/eval.zig +++ b/src/eval.zig @@ -43,18 +43,50 @@ const Scope = struct { /// Run a single evaluation. `arena` must already be initialised; this /// function neither inits nor resets it -- that is the caller's job. +/// +/// Targets the default package ("") and the default rule ("allow"). +/// Use `evaluateAddressed` to dispatch into a specific `package.rule` +/// pair within a `{"type":"modules", ...}` bundle. pub fn evaluate( arena: *std.heap.ArenaAllocator, input_json: []const u8, ast_json: []const u8, +) !bool { + return evaluateAddressed(arena, input_json, ast_json, "", default_target_rule); +} + +/// Run a single evaluation against `target_package.target_rule`. The +/// AST source can be either a single module or a `Modules` bundle; +/// the legacy single-module form is treated as `package = ""`. +pub fn evaluateAddressed( + arena: *std.heap.ArenaAllocator, + input_json: []const u8, + ast_json: []const u8, + target_package: []const u8, + target_rule: []const u8, ) !bool { const allocator = arena.allocator(); const input_value = try json.parse(allocator, input_json); const ast_value = try json.parse(allocator, ast_json); - const module = try ast.buildModule(allocator, ast_value); + const bundle = try ast.buildModulesBundle(allocator, ast_value); - return evalModule(module, default_target_rule, input_value); + return evalBundle(bundle, target_package, target_rule, input_value); +} + +/// OR-combine evalModule across every module whose package matches. +/// Empty match set returns `false` (deny by default). +fn evalBundle( + bundle: ast.Modules, + target_package: []const u8, + target_rule: []const u8, + input: json.Value, +) !bool { + for (bundle.modules) |module| { + if (!std.mem.eql(u8, module.package, target_package)) continue; + if (try evalModule(module, target_rule, input)) return true; + } + return false; } /// Walk every rule named `target`. A `default` rule's value becomes @@ -452,6 +484,72 @@ test "evaluate: every over object defaults to keys" { try testing.expect(!(try run("{\"m\":{\"x\":1,\"banned\":2}}", policy))); } +fn runAddressed(input: []const u8, ast_src: []const u8, pkg: []const u8, rule: []const u8) !bool { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + return evaluateAddressed(&arena, input, ast_src, pkg, rule); +} + +test "modules bundle: address authz.allow vs audit.allow" { + const policy = + "{\"type\":\"modules\",\"modules\":[" ++ + "{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"eq\"," ++ + "\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++ + "\"right\":{\"type\":\"value\",\"value\":\"admin\"}}]}]}," ++ + "{\"type\":\"module\",\"package\":\"audit\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"value\",\"value\":true}]}]}" ++ + "]}"; + + // authz.allow fires only on admin. + try testing.expect(try runAddressed("{\"role\":\"admin\"}", policy, "authz", "allow")); + try testing.expect(!(try runAddressed("{\"role\":\"guest\"}", policy, "authz", "allow"))); + + // audit.allow always fires regardless of role. + try testing.expect(try runAddressed("{\"role\":\"guest\"}", policy, "audit", "allow")); +} + +test "modules bundle: missing package -> deny" { + const policy = + "{\"type\":\"modules\",\"modules\":[" ++ + "{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"value\",\"value\":true}]}]}" ++ + "]}"; + try testing.expect(!(try runAddressed("{}", policy, "missing", "allow"))); +} + +test "modules bundle: bare module wraps as package='' (backwards compat)" { + const policy = + "{\"type\":\"module\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"value\",\"value\":true}]}]}"; + try testing.expect(try runAddressed("{}", policy, "", "allow")); + // Same module via the default `evaluate` entry must still allow. + try testing.expect(try run("{}", policy)); +} + +test "modules bundle: OR across two modules in same package" { + const policy = + "{\"type\":\"modules\",\"modules\":[" ++ + "{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"eq\"," ++ + "\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++ + "\"right\":{\"type\":\"value\",\"value\":\"admin\"}}]}]}," ++ + "{\"type\":\"module\",\"package\":\"authz\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"eq\"," ++ + "\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"role\"]}," ++ + "\"right\":{\"type\":\"value\",\"value\":\"editor\"}}]}]}" ++ + "]}"; + try testing.expect(try runAddressed("{\"role\":\"admin\"}", policy, "authz", "allow")); + try testing.expect(try runAddressed("{\"role\":\"editor\"}", policy, "authz", "allow")); + try testing.expect(!(try runAddressed("{\"role\":\"guest\"}", policy, "authz", "allow"))); +} + test "evaluate: every+some over arrays" { const policy = "{\"type\":\"every\",\"var\":\"req\"," ++ diff --git a/test/run.mjs b/test/run.mjs index 2c44887..d89a186 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -487,6 +487,69 @@ check( 0, ); +// --------------------------------------------------------------------------- +// 10. modules bundle: package addressing +// +// The default `evaluate` ABI targets package="" + rule="allow", so a +// bare module wrapped at the top level keeps working. A modules +// bundle with package="" still answers via the default entry point. +// (Cross-package addressing is exercised by zig build test-unit.) +// --------------------------------------------------------------------------- +const wrappedModule = { + type: 'modules', + modules: [ + { + type: 'module', + package: '', + rules: [ + { + type: 'rule', + name: 'allow', + body: [ + { type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } }, + ], + }, + ], + }, + ], +}; +check('modules bundle: empty package -> allow when admin', decide({ user: { role: 'admin' } }, wrappedModule), 1); +check('modules bundle: empty package -> deny when guest', decide({ user: { role: 'guest' } }, wrappedModule), 0); + +// Bundle with two packages: the default ABI only sees the empty-package +// module. The "audit" module is invisible from the default entry. +const twoPackages = { + type: 'modules', + modules: [ + { + type: 'module', + package: '', + rules: [ + { + type: 'rule', + name: 'allow', + body: [ + { type: 'eq', left: refRole, right: { type: 'value', value: 'admin' } }, + ], + }, + ], + }, + { + type: 'module', + package: 'audit', + rules: [ + { + type: 'rule', + name: 'allow', + body: [{ type: 'value', value: true }], + }, + ], + }, + ], +}; +check('modules bundle: default entry picks empty package', decide({ user: { role: 'admin' } }, twoPackages), 1); +check('modules bundle: audit module invisible from default entry', decide({ user: { role: 'guest' } }, twoPackages), 0); + if (failed > 0) { console.error(`\n${failed} test(s) failed`); exit(1); diff --git a/test/run_wasmtime.py b/test/run_wasmtime.py index 000fac3..d0b23d1 100644 --- a/test/run_wasmtime.py +++ b/test/run_wasmtime.py @@ -364,6 +364,52 @@ def check(name: str, got, expected): 0, ) +# --------------------------------------------------------------------------- +# 10. modules bundle: package addressing (default entry point only) +# --------------------------------------------------------------------------- +wrapped_module = { + "type": "modules", + "modules": [ + { + "type": "module", + "package": "", + "rules": [ + { + "type": "rule", + "name": "allow", + "body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}], + } + ], + } + ], +} +check("modules bundle: empty package -> allow when admin", decide({"user": {"role": "admin"}}, wrapped_module), 1) +check("modules bundle: empty package -> deny when guest", decide({"user": {"role": "guest"}}, wrapped_module), 0) + +two_packages = { + "type": "modules", + "modules": [ + { + "type": "module", + "package": "", + "rules": [ + { + "type": "rule", + "name": "allow", + "body": [{"type": "eq", "left": ref_role, "right": {"type": "value", "value": "admin"}}], + } + ], + }, + { + "type": "module", + "package": "audit", + "rules": [{"type": "rule", "name": "allow", "body": [{"type": "value", "value": True}]}], + }, + ], +} +check("modules bundle: default entry picks empty package", decide({"user": {"role": "admin"}}, two_packages), 1) +check("modules bundle: audit module invisible from default entry", decide({"user": {"role": "guest"}}, two_packages), 0) + if failed: print(f"\n{failed} test(s) failed", file=sys.stderr) sys.exit(1)