//! `/api/rules` — the per-group allow and block rules. //! //! A rule names its group by row id, not by name: the API identifies every //! resource by id, and a `group_id` no group holds is then the foreign-key //! violation it is (409) rather than a lookup that quietly writes nothing. //! //! `kind` and `action` travel as the words the database stores (`exact` / //! `wildcard`, `allow` / `block`), so one vocabulary describes a rule in the //! config file, in the database and on the wire. //! //! Rules take effect live: the write is followed by the reload seam, and the //! next query is matched against the new snapshot (ruling 12). const std = @import("std"); const Allocator = std.mem.Allocator; const http_util = @import("../http_util.zig"); const model = @import("../../config/model.zig"); const mutations = @import("mutations.zig"); const rules_repo = @import("../../storage/repositories/rules_repo.zig"); const server = @import("../server.zig"); const Failure = mutations.Failure; const Request = http_util.Request; const HandlerError = http_util.HandlerError; const group_conflict = "that group does not exist"; const Body = struct { group_id: i64, pattern: []const u8, kind: []const u8, action: []const u8, }; const Created = union(enum) { id: i64, fail: Failure }; /// A body's `kind` and `action` decoded, or the 400 that says which word was /// not understood. fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } { const kind = model.RuleKind.fromDb(body.kind) orelse return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } }; const action = model.RuleAction.fromDb(body.action) orelse return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } }; return .{ .input = .{ .group_id = body.group_id, .pattern = body.pattern, .kind = kind, .action = action, } }; } // --------------------------------------------------------------------------- // decisions // --------------------------------------------------------------------------- fn applyCreate( state: *server.WebState, io: std.Io, arena: Allocator, item: rules_repo.RuleInput, ) error{OutOfMemory}!Created { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { return .{ .fail = .{ .invalid = problem } }; } state.config_lock.lockUncancelable(io); const inserted = rules_repo.insertRuleRow(database, item, mutations.nowSeconds(io)); state.config_lock.unlock(io); const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, group_conflict) }; if (mutations.reload(state, io)) |failure| return .{ .fail = failure }; return .{ .id = id }; } fn applyUpdate( state: *server.WebState, io: std.Io, arena: Allocator, id: i64, item: rules_repo.RuleInput, ) error{OutOfMemory}!?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { return .{ .invalid = problem }; } state.config_lock.lockUncancelable(io); const written = rules_repo.updateRule(database, id, item); state.config_lock.unlock(io); written catch |err| return mutations.dbFailure(err, group_conflict); return mutations.reload(state, io); } fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = rules_repo.deleteRule(database, id); state.config_lock.unlock(io); written catch |err| return mutations.dbFailure(err, group_conflict); return mutations.reload(state, io); } // --------------------------------------------------------------------------- // routes // --------------------------------------------------------------------------- /// The wire shape of a rule: the row with its enums spelled the way the /// database spells them. const RuleView = struct { id: i64, group_id: i64, group: []const u8, pattern: []const u8, kind: []const u8, action: []const u8, created_at: i64, fn from(row: rules_repo.RuleRow) RuleView { return .{ .id = row.id, .group_id = row.group_id, .group = row.group, .pattern = row.pattern, .kind = row.kind.toDb(), .action = row.action.toDb(), .created_at = row.created_at, }; } }; const resource = mutations.Resource(.{ .Row = rules_repo.RuleRow, .list = rules_repo.listRuleRows, .get = rules_repo.getRule, .remove = applyDelete, .label = "a rule", .plural = "rules", .envelope = "rules", .view = RuleView.from, }); pub const list = resource.list; pub const get = resource.get; pub const remove = resource.remove; pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| return mutations.respondBadBody(request, err); const item = switch (toInput(parsed.value)) { .fail => |failure| return mutations.respondFailure(request, failure, "creating a rule"), .input => |value| value, }; return switch (try applyCreate(state, io, request.arena, item)) { .fail => |failure| mutations.respondFailure(request, failure, "creating a rule"), .id => |id| http_util.respondJson(request, .created, .{ .id = id, .group_id = item.group_id, .pattern = item.pattern, .kind = item.kind.toDb(), .action = item.action.toDb(), }, &.{}), }; } pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| return mutations.respondBadBody(request, err); const item = switch (toInput(parsed.value)) { .fail => |failure| return mutations.respondFailure(request, failure, "updating a rule"), .input => |value| value, }; const id = request.id.?; if (try applyUpdate(state, io, request.arena, id, item)) |failure| { return mutations.respondFailure(request, failure, "updating a rule"); } return http_util.respondJson(request, .ok, .{ .id = id, .group_id = item.group_id, .pattern = item.pattern, .kind = item.kind.toDb(), .action = item.action.toDb(), }, &.{}); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const block_ads: rules_repo.RuleInput = .{ .group_id = 1, .pattern = "ads.example", .kind = .exact, .action = .block, }; test "a created rule is stored with the clock's created_at and reloads" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); try testing.expectEqual(@as(usize, 1), bench.reloads); const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?; try testing.expectEqualStrings("ads.example", row.pattern); try testing.expectEqual(model.RuleAction.block, row.action); try testing.expectEqualStrings("default", row.group); try testing.expect(row.created_at > 0); } test "a pattern the validator refuses never reaches the database" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const starred = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .group_id = 1, .pattern = "ads.*.example", .kind = .exact, .action = .block, }); try testing.expect(starred.fail == .invalid); const starless = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .group_id = 1, .pattern = "ads.example", .kind = .wildcard, .action = .allow, }); try testing.expect(starless.fail == .invalid); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules")); try testing.expectEqual(@as(usize, 0), bench.reloads); } test "a group id no group holds is a conflict" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .group_id = 404, .pattern = "ads.example", .kind = .exact, .action = .block, }); try testing.expectEqualStrings(group_conflict, created.fail.conflict); try testing.expectEqual(@as(usize, 0), bench.reloads); } test "an edited rule keeps its created_at" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); const before = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?.created_at; const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{ .group_id = 1, .pattern = "*.ads.example", .kind = .wildcard, .action = .allow, }); try testing.expectEqual(@as(?Failure, null), failure); const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?; try testing.expectEqualStrings("*.ads.example", row.pattern); try testing.expectEqual(model.RuleKind.wildcard, row.kind); try testing.expectEqual(before, row.created_at); try testing.expectEqual(@as(usize, 2), bench.reloads); } test "an id no rule holds is a 404 on both update and delete" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expectEqual( Failure.not_found, (try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, block_ads)).?, ); try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?); try testing.expectEqual(@as(usize, 0), bench.reloads); } test "a deleted rule is gone and the change is announced" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id)); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules")); try testing.expectEqual(@as(usize, 2), bench.reloads); } test "an unknown kind or action is a 400 before anything is written" { try testing.expect(toInput(.{ .group_id = 1, .pattern = "ads.example", .kind = "glob", .action = "block", }).fail == .invalid); try testing.expect(toInput(.{ .group_id = 1, .pattern = "ads.example", .kind = "exact", .action = "drop", }).fail == .invalid); const good = toInput(.{ .group_id = 1, .pattern = "ads.example", .kind = "wildcard", .action = "allow", }); try testing.expectEqual(model.RuleKind.wildcard, good.input.kind); try testing.expectEqual(model.RuleAction.allow, good.input.action); const third = toInput(.{ .group_id = 1, .pattern = "^ad[0-9]+-", .kind = "regex", .action = "block", }); try testing.expectEqual(model.RuleKind.regex, third.input.kind); } test "a regex rule is stored, and a pattern the engine refuses is a 400 that names it" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .group_id = 1, .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block, }); const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?; try testing.expectEqual(model.RuleKind.regex, row.kind); // Stored verbatim: a regex is not a name, so nothing lowercases or // dot-strips it on the way to the table. try testing.expectEqualStrings("^ad[0-9]+-", row.pattern); const unclosed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .group_id = 1, .pattern = "(", .kind = .regex, .action = .block, }); try testing.expectEqualStrings( "rules[0].pattern: '(' is not a valid regex pattern", unclosed.fail.invalid, ); // The refused pattern reached no table, and the good one is still the only // row: a 400 costs no write and no reload. try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM rules")); try testing.expectEqual(@as(usize, 1), bench.reloads); }