From b45ca14c1443461171514ece5e49991fa2d62ad7 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 04:28:26 -0700 Subject: [PATCH] gatewayd: config, [approvals] users and socket Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/gatewayd/src/config.rs | 40 +++++++++++++ crates/gatewayd/tests/config_approvals.rs | 68 +++++++++++++++++++++++ docs/implementer-log.md | 1 + docs/runbook.md | 14 +++-- 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 crates/gatewayd/tests/config_approvals.rs diff --git a/crates/gatewayd/src/config.rs b/crates/gatewayd/src/config.rs index 04b9c38..ab9e22d 100644 --- a/crates/gatewayd/src/config.rs +++ b/crates/gatewayd/src/config.rs @@ -17,6 +17,18 @@ pub struct Config { pub paths: Paths, #[serde(default)] pub limits: Limits, + #[serde(default)] + pub approvals: ApprovalsConfig, +} + +/// `[approvals]`: who may answer approvals, and where `brokerd` brings them. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct ApprovalsConfig { + /// Mattermost user ids; empty means everyone in `[allow] users`. + pub users: Vec, + /// Empty: `/run/gateway/gateway.sock`. + pub socket: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -184,6 +196,13 @@ impl Config { )); } } + for id in &self.approvals.users { + if !self.allow.users.contains(id) { + return Some(format!( + "[approvals] {id:?} is not in [allow] users: an approver who is not heard could never answer" + )); + } + } if self.limits.queue == 0 { return Some("[limits] queue must be at least 1".to_string()); } @@ -221,6 +240,27 @@ impl Config { self.loop_.socket.clone() } + /// Who may answer approvals: `[approvals] users`, or `[allow] users` when that is empty. + pub fn approvers(&self) -> &[String] { + if self.approvals.users.is_empty() { + return &self.allow.users; + } + &self.approvals.users + } + + /// The first approver, or `None` when there are none. + pub fn first_approver(&self) -> Option<&str> { + self.approvers().first().map(String::as_str) + } + + /// `gateway.sock`: `[approvals] socket`, or `/run/gateway/gateway.sock` when empty. + pub fn gateway_socket(&self) -> PathBuf { + if self.approvals.socket.as_os_str().is_empty() { + return self.paths.home.join("run/gateway/gateway.sock"); + } + self.approvals.socket.clone() + } + pub fn state_path(&self) -> PathBuf { // /gateway/state.json. self.paths.home.join("gateway/state.json") diff --git a/crates/gatewayd/tests/config_approvals.rs b/crates/gatewayd/tests/config_approvals.rs new file mode 100644 index 0000000..136345d --- /dev/null +++ b/crates/gatewayd/tests/config_approvals.rs @@ -0,0 +1,68 @@ +//! `[approvals]` in `gatewayd.toml`: who may answer, the first approver, and `gateway.sock` +//! (M4b spec, section 3). Do not edit. + +use std::path::PathBuf; + +use gatewayd::config::Config; + +const KYLE: &str = "k0000000000000000000000000"; +const HERMES: &str = "h0000000000000000000000000"; +const OTHER: &str = "o0000000000000000000000000"; + +fn text(approvals: &str) -> String { + format!( + "[mattermost]\nurl = \"https://chat.example\"\n[secrets.mattermost_token]\nenv = \"T\"\n[allow]\nusers = [\"{KYLE}\", \"{HERMES}\"]\n[paths]\nhome = \"/srv/bx\"\n{approvals}" + ) +} + +fn config(approvals: &str) -> Config { + let c = Config::parse(&text(approvals)).unwrap(); + assert_eq!(c.problem(), None); + c +} + +#[test] +fn without_the_section_everyone_allowed_may_answer() { + let c = config(""); + assert_eq!(c.approvers(), [KYLE, HERMES]); + assert_eq!(c.first_approver(), Some(KYLE)); + assert_eq!( + c.gateway_socket(), + PathBuf::from("/srv/bx/run/gateway/gateway.sock") + ); + assert_eq!( + config("[approvals]\nusers = []\n").approvers(), + [KYLE, HERMES] + ); +} + +#[test] +fn a_list_narrows_who_may_answer() { + let c = config(&format!( + "[approvals]\nusers = [\"{HERMES}\"]\nsocket = \"/run/gw.sock\"\n" + )); + assert_eq!(c.approvers(), [HERMES]); + assert_eq!(c.first_approver(), Some(HERMES)); + assert_eq!(c.gateway_socket(), PathBuf::from("/run/gw.sock")); +} + +#[test] +fn an_approver_must_be_heard() { + let c = Config::parse(&text(&format!( + "[approvals]\nusers = [\"{KYLE}\", \"{OTHER}\"]\n" + ))) + .unwrap(); + let why = c.problem().expect("refused"); + assert_eq!( + why, + format!( + "[approvals] \"{OTHER}\" is not in [allow] users: an approver who is not heard could never answer" + ) + ); +} + +#[test] +fn unknown_keys_are_errors() { + assert!(Config::parse(&text("[approvals]\napprovers = []\n")).is_err()); + assert!(Config::parse(&text("[approvals]\nusers = []\nttl = 5\n")).is_err()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index a5734d3..c0cbae9 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? | | M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | ? | | M4b/04-brokerd-answer-post | 2026-09-24 | done | 1 | pass | none | Moved `enum Answer` and `struct Answered` from `ledger.rs` into `approvals.rs` before `pub struct Entry` (with doc comments), added `pub use crate::approvals::{Answer, Answered};` next to the `Verdict` use and `DecisionRecord` to the `use proto` line in `approvals.rs`. Added the `post` field to `Answer::Approved`/`Answer::Refused`, threaded it through `ledger::answer` (step 3 binds `post`, step 6 writes it into `AuditEvent::Approval` instead of `None`), and gave the two `bxctl` answers in `admin.rs` `post: None`. Added `Table::contains` before `take`. Copied `ledger_post.rs`, `broker_pending.rs`, `broker_sequence.rs` and `ledger_answer.rs` into `crates/brokerd/tests/`; every suite passes, `ledger_post` 3 passed, `ledger.rs` is 487 lines, and `make gate` prints `gate: ok` on the first run. | ? | diff --git a/docs/runbook.md b/docs/runbook.md index d778b0e..115f245 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -423,13 +423,17 @@ file or a directory, then this entry. **Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a `url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26 -characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`, -`env` and `file`. Or it could not create `/gateway/`. It will not guess at a configuration -that decides who it answers. +characters of `a-z0-9`, an empty `[allow] users`, an id in `[approvals] users` that is not also in +`[allow] users`, or a secret with not exactly one of `credential`, `env` and `file`. Or it could +not create `/gateway/`, or could not listen on `gateway.sock` (its directory is made 0700 +and the socket 0600, as `brokerd`'s are; a path of 108 bytes or more cannot be a socket). It will +not guess at a configuration that decides who it answers and who approves. **Confirm.** The line says which: `: …` for the file (unknown keys are errors; check -it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare : …` for the -directory (`ls -ld "$(dirname )"`). +it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3, and +`docs/specs/2026-09-24-m4b-approvals.md`, section 3), `cannot prepare : …` for a directory +(`ls -ld "$(dirname )"`), or `cannot listen on : …` for `gateway.sock` +(`Address already in use` means another `gatewayd` runs: `pgrep -x gatewayd`). **Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the user's profile, or by `GET /api/v4/users/username/`.