feat(gateway): hivectl gateway user management + fix htpasswdFile assertion

Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.

Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.

Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
atlas 2026-06-01 23:00:38 +02:00
commit 4bff450343
61 changed files with 1084 additions and 547 deletions

View file

@ -59,10 +59,7 @@ pub fn children_of(name: &str) -> Vec<String> {
/// Pure form of [`children_of`] for unit tests.
#[must_use]
pub fn children_of_in(
topo: &BTreeMap<String, Option<String>>,
name: &str,
) -> Vec<String> {
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
topo.iter()
.filter_map(|(agent, parent)| {
if parent.as_deref() == Some(name) {
@ -90,7 +87,13 @@ pub fn top_level_agents() -> Vec<String> {
#[must_use]
pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.iter()
.filter_map(|(name, parent)| if parent.is_none() { Some(name.clone()) } else { None })
.filter_map(|(name, parent)| {
if parent.is_none() {
Some(name.clone())
} else {
None
}
})
.collect()
}
@ -501,8 +504,12 @@ mod tests {
// `alice` who lives under the manager) would close the loop.
// The general cycle walk catches this; no separate manager
// guard needed.
let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob"))
.unwrap_err();
let err = apply_set_parent(
&topo_three_level(),
crate::lifecycle::MANAGER_NAME,
Some("bob"),
)
.unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
@ -678,20 +685,32 @@ mod tests {
"alice".to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
assert!(has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_absent_agent() {
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_empty_list() {
let mut roles = BTreeMap::new();
roles.insert("alice".to_owned(), vec![]);
assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
/// Revoking a role must leave the key present with an empty list so
@ -701,7 +720,10 @@ mod tests {
let mgr = crate::lifecycle::MANAGER_NAME;
// Build an in-memory roles map as set_role would see it after granting.
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
roles.insert(mgr.to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()]);
roles.insert(
mgr.to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
// Simulate the revoke path of set_role (in-memory, no disk).
let list = roles.entry(mgr.to_owned()).or_default();
@ -727,7 +749,10 @@ mod tests {
let mgr_present = agent_names.iter().any(|n| n == mgr);
let should_seed = mgr_present && !roles.contains_key(mgr);
// should_seed must be false because manager key is present (tombstone).
assert!(!should_seed, "reconcile_roles must not re-seed an explicit revoke");
assert!(
!should_seed,
"reconcile_roles must not re-seed an explicit revoke"
);
}
/// `reconcile_roles` seeds the manager on first appearance (no prior entry).