milestone 20: declarative configuration for iac
This commit is contained in:
@@ -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