milestone 20: declarative configuration for iac
This commit is contained in:
+132
-56
@@ -18,6 +18,17 @@
|
||||
//! bucket. The static assets are ruling 18's remaining exemption; they are
|
||||
//! not routes — the router sends unmatched non-`/api` paths to
|
||||
//! `WebState.fallback` before any policy check.
|
||||
//!
|
||||
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
|
||||
//! under file authority the file is the sole declarative source, so a
|
||||
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
|
||||
//! default value on purpose — a route added without a stated class must not
|
||||
//! inherit one. Classification is per route, not per prefix:
|
||||
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
|
||||
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
|
||||
//! `runtime_action` here because deleting an *observed* row discards runtime
|
||||
//! state the file never declared; the declared case needs a row read and the
|
||||
//! clients handler answers it.
|
||||
|
||||
const router = @import("router.zig");
|
||||
|
||||
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
|
||||
|
||||
pub const table: []const router.RouteInfo = &.{
|
||||
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
|
||||
|
||||
// Authentication.
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
|
||||
|
||||
// Query log, stats, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
|
||||
|
||||
// Groups.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
|
||||
|
||||
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
|
||||
// cannot collide with `{id}`, which only matches a positive integer.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
|
||||
|
||||
// Rules.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
|
||||
|
||||
// Clients (no POST — rows come from DNS activity or import, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
|
||||
|
||||
// Upstreams (restart-required resource).
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
|
||||
|
||||
// Pause and settings.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
|
||||
|
||||
// Certificates (milestone-10 ruling 8).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
|
||||
try testing.expectEqual(exempt.len, found);
|
||||
}
|
||||
|
||||
test "the config writes are exactly the declarative mutations" {
|
||||
const writes = [_][]const u8{
|
||||
"POST /api/groups",
|
||||
"PUT /api/groups/{id}",
|
||||
"DELETE /api/groups/{id}",
|
||||
"PUT /api/groups/{id}/sources",
|
||||
"POST /api/blocklists",
|
||||
"PUT /api/blocklists/{id}",
|
||||
"DELETE /api/blocklists/{id}",
|
||||
"POST /api/rules",
|
||||
"PUT /api/rules/{id}",
|
||||
"DELETE /api/rules/{id}",
|
||||
"POST /api/local-records",
|
||||
"PUT /api/local-records/{id}",
|
||||
"DELETE /api/local-records/{id}",
|
||||
"POST /api/forward-zones",
|
||||
"PUT /api/forward-zones/{id}",
|
||||
"DELETE /api/forward-zones/{id}",
|
||||
"PUT /api/clients/{id}",
|
||||
"PUT /api/client-prefixes",
|
||||
"POST /api/upstreams",
|
||||
"PUT /api/upstreams/{id}",
|
||||
"DELETE /api/upstreams/{id}",
|
||||
"PUT /api/settings",
|
||||
};
|
||||
try expectClass(.config_write, &writes);
|
||||
}
|
||||
|
||||
test "the runtime actions are exactly ruling 7's list" {
|
||||
const actions = [_][]const u8{
|
||||
"POST /api/auth/login",
|
||||
"POST /api/auth/logout",
|
||||
"POST /api/blocklists/update",
|
||||
"DELETE /api/clients/{id}",
|
||||
"POST /api/pause",
|
||||
"POST /api/certs/reload",
|
||||
};
|
||||
try expectClass(.runtime_action, &actions);
|
||||
}
|
||||
|
||||
test "every read is a GET and every GET is a read" {
|
||||
for (table) |route| {
|
||||
try testing.expectEqual(route.method == .GET, route.policy == .read);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that the routes classified `policy` are exactly `expected`, each
|
||||
/// written `METHOD /pattern`.
|
||||
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var found: usize = 0;
|
||||
for (table) |route| {
|
||||
if (route.policy != policy) continue;
|
||||
found += 1;
|
||||
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
|
||||
var listed = false;
|
||||
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
|
||||
if (!listed) {
|
||||
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
try testing.expectEqual(expected.len, found);
|
||||
}
|
||||
|
||||
test "item routes capture one id and collection routes capture none" {
|
||||
for (table) |route| {
|
||||
const captures = std.mem.count(u8, route.pattern, "{id}");
|
||||
|
||||
Reference in New Issue
Block a user