add swarm-authelia-bridge: the only thing allowed to write swarm-authelia's users database
This commit is contained in:
parent
16d578e692
commit
fb5d461e52
10 changed files with 876 additions and 0 deletions
23
Cargo.lock
generated
23
Cargo.lock
generated
|
|
@ -4555,6 +4555,29 @@ version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-authelia-bridge"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"swarm-authelia-bridge-sock",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-authelia-bridge-sock"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarm-controller"
|
name = "swarm-controller"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ members = [
|
||||||
"hive-sock-client",
|
"hive-sock-client",
|
||||||
"hive-types",
|
"hive-types",
|
||||||
"hivectl",
|
"hivectl",
|
||||||
|
"swarm-authelia-bridge",
|
||||||
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
"swarm-nats-auth",
|
"swarm-nats-auth",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
|
|
@ -85,6 +87,7 @@ hive-host-sock = { path = "hive-host-sock" }
|
||||||
hive-priv-sock = { path = "hive-priv-sock" }
|
hive-priv-sock = { path = "hive-priv-sock" }
|
||||||
hive-sock-client = { path = "hive-sock-client" }
|
hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
tower-http = { version = "0.7", features = ["fs"] }
|
||||||
|
|
|
||||||
14
swarm-authelia-bridge-sock/Cargo.toml
Normal file
14
swarm-authelia-bridge-sock/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "swarm-authelia-bridge-sock"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
23
swarm-authelia-bridge-sock/README.md
Normal file
23
swarm-authelia-bridge-sock/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# swarm-authelia-bridge-sock
|
||||||
|
|
||||||
|
Wire types for the **`swarm-authelia-bridge` socket** — the contract between
|
||||||
|
`swarm-authelia-bridge` (server, runs alongside `swarm-authelia`) and
|
||||||
|
`swarm-controller` (client).
|
||||||
|
|
||||||
|
## Why it's its own crate
|
||||||
|
|
||||||
|
Same rationale as `hive-priv-sock` (which this mirrors in spirit, though the
|
||||||
|
transport differs — this bridge is network-facing HTTP, not a unix socket,
|
||||||
|
since it has to reach a possibly-split-host `swarm-controller`): the bridge
|
||||||
|
is a narrowly-scoped, unprivileged-but-file-owning helper, and splitting the
|
||||||
|
wire contract out of any larger crate keeps both its own dependency
|
||||||
|
footprint and its interface small enough to audit at a glance. No server or
|
||||||
|
client logic here, only the request/response shapes both sides import.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
One operation today: idempotently ensure an agent exists as an authelia
|
||||||
|
subject. Deliberately **not** a wholesale-replace-the-file API — the bridge
|
||||||
|
owns both `users.json` (canonical) and rendering `users.yml` internally; a
|
||||||
|
caller only ever asks for one user to exist, never sends rendered YAML or a
|
||||||
|
file blob. See `swarm-authelia-bridge/README.md` for the helper itself.
|
||||||
110
swarm-authelia-bridge-sock/src/lib.rs
Normal file
110
swarm-authelia-bridge-sock/src/lib.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
//! Wire types for the `swarm-authelia-bridge` socket.
|
||||||
|
//!
|
||||||
|
//! Both `swarm-authelia-bridge` (server) and `swarm-controller` (client)
|
||||||
|
//! import these so the shapes stay in sync. No server or client protocol
|
||||||
|
//! logic lives here, only the JSON contract carried as the body of the
|
||||||
|
//! bridge's one HTTP endpoint (`POST /requests`, bearer-authenticated) —
|
||||||
|
//! see `swarm-authelia-bridge`'s own docs for the transport.
|
||||||
|
//!
|
||||||
|
//! # Why a bridge at all, and why this shape
|
||||||
|
//!
|
||||||
|
//! `swarm-authelia`'s users database (`users.yml`) is owned by the
|
||||||
|
//! `authelia-swarm` system user, a different uid than `swarm-controller`'s
|
||||||
|
//! own — so `swarm-controller` cannot write it directly without either root
|
||||||
|
//! (`CAP_CHOWN`) or a shared group, both rejected for the same reason
|
||||||
|
//! `swarmctl`'s own README already rejected them for this exact file. The
|
||||||
|
//! fix taken instead: run this bridge's own
|
||||||
|
//! systemd unit as `User = "authelia-swarm";` — the literal name
|
||||||
|
//! `swarm-authelia.nix` already derives, resolved by systemd at start, no
|
||||||
|
//! numeric uid ever hand-pinned into nix eval — so the bridge simply *owns*
|
||||||
|
//! the file it writes. Fully ordinary permissions, no capabilities, no root.
|
||||||
|
//!
|
||||||
|
//! **Per-operation, not wholesale-replace.** [`BridgeRequest::EnsureAgentIdentity`]
|
||||||
|
//! asks for one user to exist; the bridge owns both `users.json` (canonical)
|
||||||
|
//! and rendering `users.yml` internally. A caller never sends rendered YAML
|
||||||
|
//! or a file blob — that would invite a last-writer-wins race between
|
||||||
|
//! independent callers and duplicate the rendering logic on both sides of
|
||||||
|
//! the wire.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A request to the bridge. One variant today — see the module doc for why
|
||||||
|
/// this isn't a file-replace API.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum BridgeRequest {
|
||||||
|
/// Idempotently ensure `name` exists as an authelia subject —
|
||||||
|
/// `swarm-controller`'s `SwarmNodeKind::CreateIdentity` node's entire
|
||||||
|
/// job. Idempotence is load-bearing (agent creation's own design
|
||||||
|
/// consensus): re-running this for an agent that already has an
|
||||||
|
/// identity is a
|
||||||
|
/// genuine no-op, reported as [`BridgeResponse::AlreadyExists`] — no
|
||||||
|
/// password re-mint, no `users.yml` rewrite, nothing for authelia's
|
||||||
|
/// `file.watch` to react to.
|
||||||
|
EnsureAgentIdentity {
|
||||||
|
/// The agent's name — becomes the authelia username verbatim. The
|
||||||
|
/// bridge validates this server-side (same conservative charset
|
||||||
|
/// `swarmctl::users::validate_username` already enforces); this
|
||||||
|
/// crate carries the wire shape only, not the validation rule.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bridge's answer to a [`BridgeRequest`].
|
||||||
|
///
|
||||||
|
/// `#[serde(tag = "status")]` rather than a bare `Result`-shaped wrapper: an
|
||||||
|
/// external tag reads directly as one of three named outcomes on the wire
|
||||||
|
/// (`{"status":"created",...}`), with no separate "was this an error"
|
||||||
|
/// boolean to keep in sync with which variant it is.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
|
pub enum BridgeResponse {
|
||||||
|
/// The agent had no identity yet; one was minted and `users.yml` was
|
||||||
|
/// rewritten.
|
||||||
|
Created,
|
||||||
|
/// The agent already had an identity. No write happened — the
|
||||||
|
/// idempotent no-op path.
|
||||||
|
AlreadyExists,
|
||||||
|
/// The request was rejected or the write failed. Carries a message
|
||||||
|
/// for the caller to log/propagate, not a typed error enum: the
|
||||||
|
/// failure modes here (bad username, authelia binary failed, disk
|
||||||
|
/// full) have no caller-actionable distinction today — see
|
||||||
|
/// `PrivResponse` in `hive-priv-sock` for the same reasoning.
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{BridgeRequest, BridgeResponse};
|
||||||
|
|
||||||
|
/// Pins the external-tag wire shape — a reader off the wire (or a log
|
||||||
|
/// line) should be able to tell the three outcomes apart without
|
||||||
|
/// cross-referencing this crate's source.
|
||||||
|
#[test]
|
||||||
|
fn response_variants_tag_on_status() {
|
||||||
|
let created = serde_json::to_value(BridgeResponse::Created).unwrap();
|
||||||
|
assert_eq!(created, serde_json::json!({"status": "created"}));
|
||||||
|
|
||||||
|
let exists = serde_json::to_value(BridgeResponse::AlreadyExists).unwrap();
|
||||||
|
assert_eq!(exists, serde_json::json!({"status": "already_exists"}));
|
||||||
|
|
||||||
|
let err = serde_json::to_value(BridgeResponse::Error {
|
||||||
|
message: "boom".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
serde_json::json!({"status": "error", "message": "boom"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_round_trips() {
|
||||||
|
let req = BridgeRequest::EnsureAgentIdentity {
|
||||||
|
name: "atlas".to_owned(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
|
||||||
|
let BridgeRequest::EnsureAgentIdentity { name } = back;
|
||||||
|
assert_eq!(name, "atlas");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
swarm-authelia-bridge/Cargo.toml
Normal file
23
swarm-authelia-bridge/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "swarm-authelia-bridge"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "swarm-authelia-bridge"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
swarm-authelia-bridge-sock.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
41
swarm-authelia-bridge/README.md
Normal file
41
swarm-authelia-bridge/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# swarm-authelia-bridge
|
||||||
|
|
||||||
|
The only thing allowed to write `swarm-authelia`'s users database.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
`swarm-controller`'s `CreateIdentity` job needs to add an agent as an
|
||||||
|
authelia subject, but `swarm-controller` runs unprivileged and does not own
|
||||||
|
`users.yml` — a different uid does (`authelia-swarm`, the user
|
||||||
|
`services.authelia.instances.swarm` runs as). Root/`CAP_CHOWN`/a shared
|
||||||
|
group were all examined and rejected during this crate's design thread —
|
||||||
|
see `swarmctl/README.md`'s identical analysis of this same file, written
|
||||||
|
before this crate existed.
|
||||||
|
|
||||||
|
The fix: run **this** process as `User = "authelia-swarm";` instead —
|
||||||
|
inside the `swarm-authelia` container, alongside authelia itself — so it
|
||||||
|
simply owns the file it writes. No elevated privilege anywhere.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s
|
||||||
|
`BridgeRequest` verbatim — one variant today (`EnsureAgentIdentity`,
|
||||||
|
idempotently ensure an agent exists as an authelia subject).
|
||||||
|
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||||
|
7662), against `swarm-controller`'s **existing** machine-client identity
|
||||||
|
(already minted for the queue connection) — no new credential.
|
||||||
|
- No restart of authelia after a write — relies on
|
||||||
|
`authentication_backend.file.watch`, confirmed working against the
|
||||||
|
pinned 4.39.20 build during this design work.
|
||||||
|
- Network-facing rather than unix-socket-only: `swarm-authelia`'s container
|
||||||
|
shares the host netns, so the same listener serves both a co-located
|
||||||
|
`swarm-controller` (loopback) and a split-host one (bind wider + firewall)
|
||||||
|
with no separate transport.
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
`swarmctl` still writes an independent `users.json`/`users.yml` for human
|
||||||
|
accounts, assuming co-location with `swarm-controller`'s host. Two
|
||||||
|
canonical stores for the same physical file is a real seam, not solved
|
||||||
|
here — routing `swarmctl` through this bridge too is a plausible follow-up,
|
||||||
|
out of scope for the agent-identity slice this crate shipped with.
|
||||||
80
swarm-authelia-bridge/src/introspect.rs
Normal file
80
swarm-authelia-bridge/src/introspect.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! Validating a presented bearer token against authelia.
|
||||||
|
//!
|
||||||
|
//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token
|
||||||
|
//! introspection), not a shared dependency — `swarm-nats-auth` has no lib
|
||||||
|
//! target to import, and this is a handful of lines; factor out if a third
|
||||||
|
//! consumer shows up. Same verdict rule: `active` is the whole answer, and
|
||||||
|
//! everything that isn't an explicit `active: true` is a denial (network
|
||||||
|
//! error, timeout, non-2xx, unparseable body) — the failure modes of an
|
||||||
|
//! HTTP call are exactly the conditions an attacker would like this to
|
||||||
|
//! fall open under.
|
||||||
|
//!
|
||||||
|
//! Authenticates the introspection call itself with **this bridge's own**
|
||||||
|
//! client credentials (a resource server introspecting a token proves its
|
||||||
|
//! own identity to the `IdP`, per RFC 7662) — a separate authelia machine
|
||||||
|
//! client from the token being checked (`swarm-controller`'s).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Generous relative to `swarm-nats-auth`'s 1.5s (that one is bounded by
|
||||||
|
/// the NATS server's own 2s `auth_callout` timeout; an HTTP request here
|
||||||
|
/// has no such external deadline to stay under), but still bounded — a
|
||||||
|
/// hung introspection call must not wedge a `CreateIdentity` job forever.
|
||||||
|
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IntrospectionResponse {
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask authelia whether `token` is currently valid. `Ok(true)` only on a
|
||||||
|
/// 2xx body with `active: true`; every other outcome is `Ok(false)`
|
||||||
|
/// (logged) or `Err` when the call could not be made at all — callers
|
||||||
|
/// must treat both as a denial.
|
||||||
|
pub async fn is_active(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
client_id: &str,
|
||||||
|
client_secret: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let resp = http
|
||||||
|
.post(url)
|
||||||
|
.basic_auth(client_id, Some(client_secret))
|
||||||
|
.form(&[("token", token)])
|
||||||
|
.timeout(INTROSPECTION_TIMEOUT)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("introspection request")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
tracing::warn!(%status, "introspection returned non-2xx; denying");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let body: IntrospectionResponse = match resp.json().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "introspection body did not parse; denying");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(body.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_active_true_deserializes_to_a_grant() {
|
||||||
|
let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap();
|
||||||
|
assert!(yes.active);
|
||||||
|
let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap();
|
||||||
|
assert!(!no.active);
|
||||||
|
assert!(serde_json::from_str::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
247
swarm-authelia-bridge/src/main.rs
Normal file
247
swarm-authelia-bridge/src/main.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
//! `swarm-authelia-bridge` — the only thing allowed to write
|
||||||
|
//! `swarm-authelia`'s users database.
|
||||||
|
//!
|
||||||
|
//! Runs **inside the `swarm-authelia` container**, as
|
||||||
|
//! `User = "authelia-swarm";` (the literal name
|
||||||
|
//! `swarm-authelia.nix`'s `unitName` already derives) — the same user
|
||||||
|
//! authelia's own instance runs as, and therefore the file's actual
|
||||||
|
//! owner. That is the whole answer to "how does an unprivileged
|
||||||
|
//! `swarm-controller` write a file it doesn't own": it doesn't — this
|
||||||
|
//! process does, sidestepping the uid boundary instead of bridging it
|
||||||
|
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
||||||
|
//! `swarmctl/README.md`'s own identical analysis of this same file).
|
||||||
|
//!
|
||||||
|
//! Network-facing, not unix-socket-only: `swarm-authelia`'s container
|
||||||
|
//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/
|
||||||
|
//! `hive-matrix`), so one listener serves a co-located `swarm-controller`
|
||||||
|
//! (loopback) and a split-host one (bind wider, firewall it) with the
|
||||||
|
//! same code path — no separate transport for the cross-host case.
|
||||||
|
//! Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||||
|
//! 7662) against `swarm-controller`'s **existing** machine-client
|
||||||
|
//! identity (already minted for the queue connection) — no new
|
||||||
|
//! credential to mint or deliver, and always local/fast to verify since
|
||||||
|
//! this process runs right next to the authelia it asks.
|
||||||
|
//!
|
||||||
|
//! One endpoint, `POST /requests`, body =
|
||||||
|
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim — one variant
|
||||||
|
//! today (`EnsureAgentIdentity`, idempotently ensure an agent exists as
|
||||||
|
//! an authelia subject). Not a wholesale-replace-the-file API — this
|
||||||
|
//! process owns rendering `users.yml` internally; see `store` module.
|
||||||
|
|
||||||
|
mod introspect;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
|
||||||
|
|
||||||
|
/// Where the introspecting bearer token is authenticated *to* — this
|
||||||
|
/// bridge's own authelia machine client, distinct from
|
||||||
|
/// `swarm-controller`'s (the token being checked). Set by the nix module
|
||||||
|
/// that provisions this bridge's own client alongside authelia.
|
||||||
|
struct Config {
|
||||||
|
bind: String,
|
||||||
|
store_path: std::path::PathBuf,
|
||||||
|
users_file: std::path::PathBuf,
|
||||||
|
authelia_bin: std::path::PathBuf,
|
||||||
|
introspection_url: String,
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
bind: env_var("SWARM_AUTHELIA_BRIDGE_BIND")?,
|
||||||
|
store_path: env_var("SWARM_AUTHELIA_BRIDGE_STORE")?.into(),
|
||||||
|
users_file: env_var("SWARM_AUTHELIA_BRIDGE_USERS_FILE")?.into(),
|
||||||
|
authelia_bin: env_var("SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN")?.into(),
|
||||||
|
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
||||||
|
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
||||||
|
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_var(name: &str) -> Result<String> {
|
||||||
|
std::env::var(name).with_context(|| format!("{name} is unset"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_secret_file(path: &str) -> Result<String> {
|
||||||
|
let raw =
|
||||||
|
std::fs::read_to_string(path).with_context(|| format!("reading secret file {path}"))?;
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
bail!("secret file {path} is empty");
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
config: Config,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
let bind = config.bind.clone();
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
config,
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/requests", post(handle_request))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding {bind}"))?;
|
||||||
|
tracing::info!(%bind, "swarm-authelia-bridge listening");
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.context("serving swarm-authelia-bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single endpoint — body is the wire-crate's [`BridgeRequest`]
|
||||||
|
/// verbatim (JSON), not a per-operation REST route. One variant today,
|
||||||
|
/// but this is what keeps a second operation from needing a new route +
|
||||||
|
/// a new extractor shape: it just becomes a new match arm below.
|
||||||
|
async fn handle_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<BridgeRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(resp) = authorize(&state, &headers).await {
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
let BridgeRequest::EnsureAgentIdentity { name } = req;
|
||||||
|
match handle(&state, name).await {
|
||||||
|
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let detail = format!("{e:#}");
|
||||||
|
tracing::warn!(error = %detail, "ensure_identity failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(BridgeResponse::Error { message: detail }),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract + introspect the bearer token. `Err` carries the response to
|
||||||
|
/// return outright (401 for anything short of an active token) — kept
|
||||||
|
/// separate from `handle`'s error path, which is "the op itself failed,"
|
||||||
|
/// a different case from "the caller was never let in."
|
||||||
|
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
||||||
|
let deny = || {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(BridgeResponse::Error {
|
||||||
|
message: "missing or invalid bearer token".to_owned(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
};
|
||||||
|
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||||
|
return Err(deny());
|
||||||
|
};
|
||||||
|
let Ok(auth) = auth.to_str() else {
|
||||||
|
return Err(deny());
|
||||||
|
};
|
||||||
|
let Some(token) = auth.strip_prefix("Bearer ") else {
|
||||||
|
return Err(deny());
|
||||||
|
};
|
||||||
|
match introspect::is_active(
|
||||||
|
&state.http,
|
||||||
|
&state.config.introspection_url,
|
||||||
|
&state.config.client_id,
|
||||||
|
&state.config.client_secret,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) => Err(deny()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "introspection call failed; denying");
|
||||||
|
Err(deny())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
||||||
|
store::validate_username(&name)?;
|
||||||
|
let cfg = &state.config;
|
||||||
|
let mut user_store = store::load_store(&cfg.store_path, &cfg.users_file)?;
|
||||||
|
if user_store.users.contains_key(&name) {
|
||||||
|
return Ok(BridgeResponse::AlreadyExists);
|
||||||
|
}
|
||||||
|
let generated = generate_password(&cfg.authelia_bin).await?;
|
||||||
|
user_store.users.insert(
|
||||||
|
name.clone(),
|
||||||
|
store::User {
|
||||||
|
displayname: name.clone(),
|
||||||
|
password: generated,
|
||||||
|
email: None,
|
||||||
|
groups: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
store::publish(&cfg.store_path, &cfg.users_file, &user_store)?;
|
||||||
|
tracing::info!(agent = %name, "created authelia identity");
|
||||||
|
Ok(BridgeResponse::Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a password digest via the **configured** authelia — the argon2
|
||||||
|
/// parameters baked into a hash have to match the verifier's, same
|
||||||
|
/// reasoning `swarmctl::generate_password` documents (this is
|
||||||
|
/// deliberately a fresh async port of that function, not a shared one —
|
||||||
|
/// `swarmctl` is a separate binary crate with no lib target).
|
||||||
|
///
|
||||||
|
/// `--random`, not `--password <pw>`: `/proc/<pid>/cmdline` is
|
||||||
|
/// world-readable, so a password on argv would be readable by any local
|
||||||
|
/// process for the call's lifetime. Only the digest is kept — the
|
||||||
|
/// plaintext is generated and immediately discarded, since nothing reads
|
||||||
|
/// it back today (still an open question what an agent's login actually
|
||||||
|
/// uses this for).
|
||||||
|
async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
||||||
|
let out = tokio::process::Command::new(bin)
|
||||||
|
.args(["crypto", "hash", "generate", "argon2", "--random"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("running {}", bin.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"{} failed ({}): {}",
|
||||||
|
bin.display(),
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?;
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.trim().strip_prefix("Digest:"))
|
||||||
|
.map(|v| v.trim().to_owned())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"could not parse {}'s output: missing 'Digest:'",
|
||||||
|
bin.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
312
swarm-authelia-bridge/src/store.rs
Normal file
312
swarm-authelia-bridge/src/store.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
//! The canonical user store this bridge owns, and the authelia users
|
||||||
|
//! database rendered from it.
|
||||||
|
//!
|
||||||
|
//! Deliberately its own copy, not shared code with `swarmctl::users` even
|
||||||
|
//! though the YAML shape is identical (same "factor out later if
|
||||||
|
//! duplication actually bites" reasoning as `swarm-controller::forge`
|
||||||
|
//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level
|
||||||
|
//! design thread). One real difference from `swarmctl`'s copy: this
|
||||||
|
//! store lives **wherever this
|
||||||
|
//! bridge runs** (inside the `swarm-authelia` container, alongside
|
||||||
|
//! `authelia-swarm`'s own state), not under `swarm-controller`'s state
|
||||||
|
//! dir — the two are not guaranteed to be the same host once
|
||||||
|
//! `swarm-authelia` and `swarm-controller` split across hosts, and this
|
||||||
|
//! bridge only ever runs where `swarm-authelia` does.
|
||||||
|
//!
|
||||||
|
//! ⚠️ **Known limitation, not solved here**: `swarmctl` still writes its
|
||||||
|
//! own independent `users.json`/`users.yml` for human accounts, assuming
|
||||||
|
//! co-location with `swarm-controller`'s host. Two independent canonical
|
||||||
|
//! stores for the same physical `users.yml` is a real seam — tracked as a
|
||||||
|
//! follow-up (route `swarmctl` through this bridge too), not attempted in
|
||||||
|
//! this slice, whose scope is agent identities only.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs::{self, File, Permissions};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the `swarm-authelia` module's first-boot unit writes into
|
||||||
|
/// `users.yml` when there is no database yet — same constant/shape
|
||||||
|
/// `swarmctl::users::SEED_USERS_FILE` uses, since both write the same
|
||||||
|
/// physical file format.
|
||||||
|
pub const SEED_USERS_FILE: &str = "users: {}";
|
||||||
|
|
||||||
|
/// The canonical store, serialised as JSON. `BTreeMap` for a stable,
|
||||||
|
/// diffable render — same rationale as `swarmctl::users::UserStore`.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct UserStore {
|
||||||
|
pub users: BTreeMap<String, User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub displayname: String,
|
||||||
|
/// The argon2 **digest**, never a plaintext password.
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same conservative charset `swarmctl::users::validate_username` already
|
||||||
|
/// enforces — usernames are YAML map keys, log lines, and access-control
|
||||||
|
/// subjects, so keeping them to plain ASCII means nothing downstream ever
|
||||||
|
/// has to reason about a name that needs quoting.
|
||||||
|
pub fn validate_username(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() || name.len() > 64 {
|
||||||
|
bail!("username must be 1..=64 characters, got {}", name.len());
|
||||||
|
}
|
||||||
|
if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) {
|
||||||
|
bail!("username must start with an ASCII letter or digit: {name:?}");
|
||||||
|
}
|
||||||
|
if let Some(bad) = name
|
||||||
|
.chars()
|
||||||
|
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
|
||||||
|
{
|
||||||
|
bail!("username may only contain [A-Za-z0-9._-], found {bad:?} in {name:?}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject control characters rather than escape them — same reasoning as
|
||||||
|
/// `swarmctl::users::reject_control_chars`: a display name or email
|
||||||
|
/// carrying one is a bug or an injection attempt in every real case.
|
||||||
|
fn reject_control_chars(field: &str, value: &str) -> Result<()> {
|
||||||
|
if let Some(c) = value.chars().find(|c| c.is_control()) {
|
||||||
|
bail!(
|
||||||
|
"{field} contains control character U+{:04X}; refusing to write it",
|
||||||
|
c as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A double-quoted YAML scalar — everything is quoted, including values
|
||||||
|
/// that would be fine bare, since an argon2 digest alone contains `$`,
|
||||||
|
/// `=`, `,` and `/`. Control characters are excluded upstream, so `"` and
|
||||||
|
/// `\` are the complete escape set.
|
||||||
|
fn quote(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the store as authelia's block-style YAML users database.
|
||||||
|
/// Fallible for the same reason `swarmctl::users::render_yaml` is: it
|
||||||
|
/// re-validates every value it is about to emit, so "no control character
|
||||||
|
/// ever reaches the file" is a property of the one path that writes it.
|
||||||
|
pub fn render_yaml(store: &UserStore) -> Result<String> {
|
||||||
|
if store.users.is_empty() {
|
||||||
|
return Ok(format!("{SEED_USERS_FILE}\n"));
|
||||||
|
}
|
||||||
|
let mut out = String::from("users:\n");
|
||||||
|
for (name, user) in &store.users {
|
||||||
|
validate_username(name)?;
|
||||||
|
reject_control_chars("displayname", &user.displayname)?;
|
||||||
|
reject_control_chars("password digest", &user.password)?;
|
||||||
|
writeln!(out, " {name}:")?;
|
||||||
|
writeln!(out, " displayname: {}", quote(&user.displayname))?;
|
||||||
|
writeln!(out, " password: {}", quote(&user.password))?;
|
||||||
|
if let Some(email) = &user.email {
|
||||||
|
reject_control_chars("email", email)?;
|
||||||
|
writeln!(out, " email: {}", quote(email))?;
|
||||||
|
}
|
||||||
|
if !user.groups.is_empty() {
|
||||||
|
writeln!(out, " groups:")?;
|
||||||
|
for group in &user.groups {
|
||||||
|
reject_control_chars("group", group)?;
|
||||||
|
writeln!(out, " - {}", quote(group))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the canonical store, or start an empty one if this bridge has
|
||||||
|
/// never written a user. Same overwrite guard as
|
||||||
|
/// `swarmctl::load_store`: starting empty means the next write
|
||||||
|
/// **overwrites** `users_file`, which is only safe when that file is
|
||||||
|
/// still the untouched first-boot seed.
|
||||||
|
pub fn load_store(store_path: &Path, users_file: &Path) -> Result<UserStore> {
|
||||||
|
match fs::read_to_string(store_path) {
|
||||||
|
Ok(raw) => serde_json::from_str(&raw)
|
||||||
|
.with_context(|| format!("parsing the user store at {}", store_path.display())),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
match fs::read_to_string(users_file) {
|
||||||
|
Ok(existing) if !is_untouched_seed(&existing) => bail!(
|
||||||
|
"no user store at {} but {} already holds users — refusing to \
|
||||||
|
overwrite it",
|
||||||
|
store_path.display(),
|
||||||
|
users_file.display()
|
||||||
|
),
|
||||||
|
Ok(_) | Err(_) => Ok(UserStore::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an existing `users.yml` is safe to take over — i.e. it is the
|
||||||
|
/// untouched first-boot seed. Empty counts too: a zero-byte file holds
|
||||||
|
/// nothing to lose.
|
||||||
|
fn is_untouched_seed(contents: &str) -> bool {
|
||||||
|
let trimmed = contents.trim();
|
||||||
|
trimmed.is_empty() || trimmed == SEED_USERS_FILE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the store + the rendered users file. **No restart** (the load-
|
||||||
|
/// bearing difference from `swarmctl::publish`): this bridge relies on
|
||||||
|
/// authelia's `authentication_backend.file.watch`, confirmed working
|
||||||
|
/// against the pinned 4.39.20 build during this design work — a
|
||||||
|
/// restart would drop every active SSO session, which mara ruled out for
|
||||||
|
/// agent creation specifically (not a rare, human-initiated event).
|
||||||
|
pub fn publish(store_path: &Path, users_file: &Path, store: &UserStore) -> Result<()> {
|
||||||
|
let rendered = render_yaml(store)?;
|
||||||
|
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?;
|
||||||
|
// Store first — if the store lands and the users file doesn't, the
|
||||||
|
// next run re-renders and repairs it. The other order loses a user.
|
||||||
|
write_atomic(store_path, &format!("{store_json}\n"))?;
|
||||||
|
write_atomic(users_file, &rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`,
|
||||||
|
/// this does **not** need to preserve a foreign owner via `chown` — this
|
||||||
|
/// process runs as `users_file`'s own owning user (see the module doc:
|
||||||
|
/// the whole point of this bridge is running as `authelia-swarm`), so the
|
||||||
|
/// temp file it creates is already correctly owned. Preserves the
|
||||||
|
/// existing mode, same reasoning as `swarmctl`'s version (conservative
|
||||||
|
/// default for a file of password hashes).
|
||||||
|
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
|
||||||
|
let dir = path
|
||||||
|
.parent()
|
||||||
|
.with_context(|| format!("{} has no parent directory", path.display()))?;
|
||||||
|
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("{} has no file name", path.display()))?;
|
||||||
|
let tmp = dir.join(format!(
|
||||||
|
".{}.swarm-authelia-bridge.tmp",
|
||||||
|
name.to_string_lossy()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mode = fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.map_or(0o600, |meta| meta.permissions().mode() & 0o7777);
|
||||||
|
|
||||||
|
let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
|
||||||
|
file.write_all(contents.as_bytes())
|
||||||
|
.with_context(|| format!("writing {}", tmp.display()))?;
|
||||||
|
file.sync_all()
|
||||||
|
.with_context(|| format!("flushing {}", tmp.display()))?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
fs::set_permissions(&tmp, Permissions::from_mode(mode))
|
||||||
|
.with_context(|| format!("setting mode on {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn user(password: &str) -> User {
|
||||||
|
User {
|
||||||
|
displayname: "atlas".to_owned(),
|
||||||
|
password: password.to_owned(),
|
||||||
|
email: None,
|
||||||
|
groups: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_store_renders_the_seed_document() {
|
||||||
|
let out = render_yaml(&UserStore::default()).expect("renders");
|
||||||
|
assert_eq!(out, "users: {}\n");
|
||||||
|
assert!(is_untouched_seed(&out));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_argon2_digest_survives_quoting() {
|
||||||
|
let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w==";
|
||||||
|
let mut store = UserStore::default();
|
||||||
|
store.users.insert("atlas".to_owned(), user(digest));
|
||||||
|
let out = render_yaml(&store).expect("renders");
|
||||||
|
assert!(out.contains(&format!("password: \"{digest}\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usernames_outside_the_conservative_set_are_refused() {
|
||||||
|
for bad in ["", "-leading", "has space", "quote\"d", "sla/sh"] {
|
||||||
|
assert!(
|
||||||
|
validate_username(bad).is_err(),
|
||||||
|
"{bad:?} should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for good in ["atlas", "svc-agent_1", "a.b"] {
|
||||||
|
validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_control_character_is_refused_not_escaped() {
|
||||||
|
let mut u = user("$argon2id$x");
|
||||||
|
u.displayname = "bad\nname".to_owned();
|
||||||
|
let mut store = UserStore::default();
|
||||||
|
store.users.insert("atlas".to_owned(), u);
|
||||||
|
let err = render_yaml(&store).expect_err("must refuse");
|
||||||
|
assert!(err.to_string().contains("control character"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `write_atomic` round-trips through a real temp dir — the property
|
||||||
|
/// under test is the rename-into-place, not just the render.
|
||||||
|
#[test]
|
||||||
|
fn publish_writes_both_files_and_the_store_reloads() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let store_path = dir.join("users.json");
|
||||||
|
let users_file = dir.join("users.yml");
|
||||||
|
fs::write(&users_file, SEED_USERS_FILE).unwrap();
|
||||||
|
|
||||||
|
let mut store = UserStore::default();
|
||||||
|
store.users.insert("atlas".to_owned(), user("$argon2id$x"));
|
||||||
|
publish(&store_path, &users_file, &store).expect("publish");
|
||||||
|
|
||||||
|
let reloaded = load_store(&store_path, &users_file).expect("reload");
|
||||||
|
assert!(reloaded.users.contains_key("atlas"));
|
||||||
|
let yaml = fs::read_to_string(&users_file).unwrap();
|
||||||
|
assert!(yaml.contains("atlas"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only temp dir under the OS temp root — disposable scratch for
|
||||||
|
/// one test's lifetime, not durable state (see `state-not-tmp`: this
|
||||||
|
/// is exactly the legitimate use, not a place we persist anything).
|
||||||
|
fn tempdir() -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"swarm-authelia-bridge-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue