milestone 20: declarative configuration for iac
This commit is contained in:
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
|
||||
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
/// What file authority found when it went to delete a row.
|
||||
pub const ObservedDelete = enum { deleted, declared, absent };
|
||||
|
||||
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
|
||||
/// two halves are a single decision. Split across two statements, a concurrent
|
||||
/// `nxdns import` — which takes the same write lock for its own reconcile — can
|
||||
/// promote the row between the read and the DELETE, and file authority would
|
||||
/// delete a client the file had just declared. Holding the write lock across
|
||||
/// both makes the promotion wait, and it then sees the row already gone or
|
||||
/// still there, never half of each.
|
||||
///
|
||||
/// A read-only outcome commits an empty transaction, which costs nothing and
|
||||
/// keeps the one exit path.
|
||||
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
const row = try clients_repo.getClient(database, arena, id);
|
||||
const verdict: ObservedDelete = if (row) |found|
|
||||
(if (found.hand_edited) .declared else .deleted)
|
||||
else
|
||||
.absent;
|
||||
|
||||
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
|
||||
try tx.commit();
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
|
||||
/// file authority lets it through: an observed row is runtime state the file
|
||||
/// never declared, and without a way to remove it a mis-identified or departed
|
||||
/// device would be immortal — the file can promote an IP, never forget one.
|
||||
/// A row the file *declares* is configuration, and deleting it would contradict
|
||||
/// the file, so it answers the same 403 the router answers elsewhere. This is
|
||||
/// the one policy decision that needs a row read, which is why it is here and
|
||||
/// not a table column.
|
||||
///
|
||||
/// A row that is not there is a 404, exactly as in database mode: file
|
||||
/// authority must not turn a missing row into a policy verdict.
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const path = switch (state.authority) {
|
||||
.database => return resource.remove(state, io, request),
|
||||
.managed_file => |managed| managed,
|
||||
};
|
||||
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = deleteIfObserved(database, request.arena, request.id.?);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
switch (outcome catch |err| return mutations.respondFailure(
|
||||
request,
|
||||
mutations.dbFailure(err, group_conflict),
|
||||
delete_what,
|
||||
)) {
|
||||
.absent => return mutations.respondFailure(request, .not_found, ""),
|
||||
.declared => return http_util.respondManagedByFile(request, path),
|
||||
.deleted => {},
|
||||
}
|
||||
|
||||
if (mutations.reload(state, io)) |failure| {
|
||||
return mutations.respondFailure(request, failure, delete_what);
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
const delete_what = "deleting a client";
|
||||
|
||||
/// The prefixes are one list resource with no `/{id}` route: the whole set is
|
||||
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
|
||||
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "file authority deletes an observed client and refuses a declared one" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
try bench.exec(
|
||||
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
|
||||
);
|
||||
|
||||
// The declared row is configuration; it survives, and nothing is written.
|
||||
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
|
||||
}
|
||||
|
||||
test "the observed check and the delete are one transaction" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
|
||||
// transaction is what proves this takes the write lock rather than reading
|
||||
// and deleting through two unsynchronised statements — the window a
|
||||
// concurrent `nxdns import` would promote the row in. Without the
|
||||
// transaction both statements run and the row is gone.
|
||||
var tx = try db.Tx.begin(&bench.database);
|
||||
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
tx.rollback();
|
||||
|
||||
// The row is untouched: the refusal happened before any statement ran.
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
}
|
||||
|
||||
test "the prefix list is replaced whole" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
|
||||
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
|
||||
|
||||
/// Enums arrive as the words the database stores, so they are parsed from text
|
||||
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
|
||||
///
|
||||
/// An optional model field collapses to its child, because `Partial` wraps
|
||||
/// every field in one optional of its own and that optional already carries the
|
||||
/// only meaning a PUT has for absence — "leave it". A double optional would be
|
||||
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
|
||||
fn FieldType(comptime T: type) type {
|
||||
return switch (@typeInfo(T)) {
|
||||
.@"enum" => []const u8,
|
||||
.optional => |info| FieldType(info.child),
|
||||
else => T,
|
||||
};
|
||||
}
|
||||
@@ -321,16 +327,17 @@ pub fn applyPut(
|
||||
|
||||
// The password never becomes a row: the hash made above is what the merged
|
||||
// configuration — and therefore the settings table — carries.
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
const previous_hash = cfg.web.password_hash orelse "";
|
||||
if (password != null) cfg.web.password_hash = new_hash;
|
||||
cfg.web.password = "";
|
||||
cfg.web.password = null;
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
|
||||
// The gpa copy the live holder will own, made before the write so a
|
||||
// committed transaction can never be followed by a failed revocation.
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
|
||||
for (pairs.items) |pair| {
|
||||
try settings_repo.putSetting(database, pair.key, pair.value);
|
||||
}
|
||||
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
|
||||
// reconcile engine owns that row, because only it can tell "the file said
|
||||
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
|
||||
// merged configuration is the whole truth — so this handler writes the row
|
||||
// itself rather than losing the password change.
|
||||
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which source governs this process's configuration, and when it last read
|
||||
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
|
||||
/// read-only — declaratively, rather than by probing a route for a 403.
|
||||
///
|
||||
/// It rides `GET /api/settings` because that route needs a session: the
|
||||
/// managed path is a filesystem path and must never reach the open
|
||||
/// `/api/version` or `/api/health`.
|
||||
///
|
||||
/// `reconciled_at` means exactly "this process loaded the file at T". A file
|
||||
/// whose mtime is newer has not been loaded by the running process. It cannot
|
||||
/// answer "is the file what the server uses" — a stepped clock or a preserved
|
||||
/// mtime defeats the comparison in either direction, and the database can move
|
||||
/// under `nxdns import` without either timestamp moving.
|
||||
const AuthorityView = struct {
|
||||
mode: []const u8,
|
||||
path: ?[]const u8,
|
||||
reconciled_at: ?i64,
|
||||
};
|
||||
|
||||
fn authorityView(state: *const server.WebState) AuthorityView {
|
||||
return switch (state.authority) {
|
||||
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
|
||||
.managed_file => |path| .{
|
||||
.mode = "managed_file",
|
||||
.path = path,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
|
||||
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
const cfg = loaded catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
|
||||
|
||||
return respondSettings(request, .ok, cfg);
|
||||
return respondSettings(request, state, .ok, cfg);
|
||||
}
|
||||
|
||||
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
|
||||
return switch (try applyPut(state, io, request.arena, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
|
||||
.config => |cfg| respondSettings(request, .ok, cfg),
|
||||
.config => |cfg| respondSettings(request, state, .ok, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
|
||||
fn respondSettings(
|
||||
request: *Request,
|
||||
state: *const server.WebState,
|
||||
status: std.http.Status,
|
||||
cfg: model.Config,
|
||||
) HandlerError!void {
|
||||
return http_util.respondJson(request, status, .{
|
||||
.settings = view(cfg),
|
||||
.restart_required = restart_required_keys,
|
||||
.authority = authorityView(state),
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -498,8 +547,10 @@ const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact: the keys the
|
||||
// database stores, minus the hash the API never serializes.
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
|
||||
patch.web = .{ .password = "correct horse battery staple" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
|
||||
// The plain password is nowhere in the table, and the hash is.
|
||||
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
|
||||
);
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expectEqual(
|
||||
auth.Outcome.ok,
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
|
||||
patch.web = .{ .password = "" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
|
||||
}
|
||||
|
||||
test "reading the settings with no database is unavailable" {
|
||||
|
||||
Reference in New Issue
Block a user