Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/proposals/multiple-policies.md
Original file line number Diff line number Diff line change
@@ -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.<package>.<rule>`.

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.
Comment on lines +49 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a naming and signature inconsistency between this section and the Design sketch (line 72). This section uses evalModule(modules, "authz.allow", input), while the sketch uses evalModules(target_pkg, target_rule, input). It would be better to align the function name and the argument structure (including the modules container) across the document.


## 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.
Comment on lines +85 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The proposed short-circuiting logic for send_local_response_403 may interfere with the 'audit observes' pattern. If an enforcing target is placed before an audit target in the targets[] list, a denial will prevent the audit target from being evaluated. Consider specifying that all non-enforcing targets should be evaluated before any short-circuiting occurs, or clarifying the expected ordering in the documentation.

- 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 }]`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency, use send_local_response_403 instead of send_403, matching the example provided in the Goals section (line 43).

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.
50 changes: 47 additions & 3 deletions src/ast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
Expand All @@ -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":[<module>, ...]}` -> 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 {
Expand Down
102 changes: 100 additions & 2 deletions src/eval.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\"," ++
Expand Down
Loading
Loading