Grafana's OIDC client secret only existed where authelia did. One `ssoLocal` gate — `grafana.enable && authelia.enable` — decided the client registration, the minted secret's delivery and the whole `auth.generic_oauth` block, so a swarm whose authelia runs on another host got Grafana with no SSO wiring at all. The local login form is disabled unconditionally, so that is no way in. Split the one gate into the two questions it was conflating: - `ssoConfigured` — does this SWARM have an identity provider (`swarm.authelia.url`, which is swarm-wide and whose own description makes null mean "no SSO configured"). With a delivery route present this is what emits Grafana's OIDC block. - `ssoLocal` — is authelia on THIS host, now spelled as the forge and matrix modules spell it. It decides only which unit delivers the secret. Where authelia is elsewhere, `swarm-bao-grafana-oidc.service` reads the secret from the swarm secret store, shaped after glue-queue-agent-credential.nix: cert login fails loudly because a retry fixes every state it fails on, the read degrades quietly because no retry turns "no value there" into a value, and nothing writes a stand-in. The producer is the publisher that already runs on authelia's host, which gains the swarm's service clients beside the per-hive ones at `swarm/services/<id>/oidc/client` — with the write grant in swarm-bao.nix and the hive read grant in `policy::render` to match. Registration moved to glue-grafana-oidc-client.nix. It has to be declared where authelia's config is rendered, and swarm-grafana.nix's config block hangs off this host running Grafana. Two judgement calls stated rather than buried: a hive's read policy now grants the whole `services` prefix, because a service's path names the service and nothing swarm-wide records which hive runs it (cost recorded in docs/trust-boundary/security.md); and the client is registered on any authelia host, because no swarm-wide "this swarm has a Grafana" fact exists to gate it on. Refs #4234 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
8.3 KiB
Rust
196 lines
8.3 KiB
Rust
//! The read agreement: which credentials a hive's own token may fetch.
|
|
//!
|
|
//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say
|
|
//! where a credential lives; this one says who is allowed to read it, and the
|
|
//! two have to agree on the same path or a delivery fails with a 403 that names
|
|
//! nothing.
|
|
//!
|
|
//! The document has one stanza per kind a hive reads, and they are not all
|
|
//! scoped alike — which is the point rather than an inconsistency:
|
|
//!
|
|
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
|
|
//! than on the ones that hive hosts. That is a decision, not an oversight: an
|
|
//! agent's path does not name its hive, so a per-hive grant has to be
|
|
//! enumerated and re-emitted, and an enumeration that can silently drift
|
|
//! advertises a boundary it does not hold. A wide grant that says so beats a
|
|
//! narrow one that only looks narrow. The narrower shapes, and what they would
|
|
//! cost, are in `docs/trust-boundary/security.md`.
|
|
//!
|
|
//! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm
|
|
//! service's client is registered once per swarm, so its path names the service
|
|
//! and never the host, and which hive runs a service is a `deploy.*` fact with
|
|
//! no swarm-wide spelling to scope against. Cost: the same doc.
|
|
//!
|
|
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
|
//! names its principal, so scoping it to the reader's own name costs nothing
|
|
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
|
|
//! is the point.
|
|
//!
|
|
//! Rendering stays separate from writing so the text can be asserted with no
|
|
//! store to talk to.
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, MOUNT, ROOT, checked_segment},
|
|
};
|
|
|
|
/// Namespace for a hive's own policy and cert-auth role.
|
|
///
|
|
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
|
|
/// the difference between a hive the controller may provision and a policy it
|
|
/// must not be able to rewrite — including its own.
|
|
pub const HIVE_PREFIX: &str = "hive-";
|
|
|
|
/// The policy and cert-auth role name for `hive`. One name, both objects: the
|
|
/// role attaches the policy by spelling it identically.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
|
|
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
|
checked_segment("hive", hive)?;
|
|
Ok(format!("{HIVE_PREFIX}{hive}"))
|
|
}
|
|
|
|
/// One read stanza. The only shape this module emits, so "read-only" is a
|
|
/// property of the renderer rather than of each call site.
|
|
fn read_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
|
}
|
|
|
|
/// Render `hive`'s policy document: read on every agent's credentials, on this
|
|
/// hive's own, and on the swarm services'.
|
|
///
|
|
/// The name is the only input, and it is deploy-time — so the document is
|
|
/// still a deploy-time object rather than derived state with a re-emission to
|
|
/// get wrong. Nothing about which agents exist changes the text.
|
|
///
|
|
/// Read-only: the controller mints these and never reads one back.
|
|
///
|
|
/// ⚠️ The controller kind is deliberately absent: a hive has no business
|
|
/// reading the credentials of the thing that provisions it. Adding it is a
|
|
/// boundary decision, not a consequence of the namespace growing.
|
|
///
|
|
/// The **service** kind is granted, and that was such a decision rather than a
|
|
/// consequence: a service whose identity provider is on another host reads its
|
|
/// own OIDC client secret with the certificate of the hive it runs on, that
|
|
/// being the only identity such a host has — so every hive can read every
|
|
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
|
|
///
|
|
/// The hive's *own* kind is granted, and that is the decision the agent-only
|
|
/// version of this grant said had to be made rather than assumed: a hive holds
|
|
/// the queue credential its own agents authenticate with, so it has to read the
|
|
/// one principal named after itself — and only that one, which is why the path
|
|
/// interpolates the name instead of widening to the whole kind.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
|
|
/// name is interpolated into a policy path, so a name that could close the
|
|
/// stanza could grant itself anything.
|
|
pub fn render(hive: &str) -> Result<String, Error> {
|
|
checked_segment("hive", hive)?;
|
|
let agents = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Agent)
|
|
));
|
|
let own = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
|
<&str>::from(Kind::Hive)
|
|
));
|
|
let services = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Service)
|
|
));
|
|
Ok(format!("{agents}{own}{services}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_document_grants_two_whole_prefixes_and_this_hive_alone() {
|
|
assert_eq!(
|
|
render("pr1ma").expect("a plain name is legal"),
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() {
|
|
// The property the swarm-grafana delivery depends on: the host running a
|
|
// swarm service reads that service's client secret with its own hive
|
|
// certificate, and the path names the service rather than the host. A
|
|
// stanza narrowed to the reader's name would 403 every such read.
|
|
let p = render("pr1ma").expect("legal");
|
|
assert!(p.contains("path \"secret/data/swarm/services/*\""));
|
|
assert!(
|
|
!p.contains("services/pr1ma"),
|
|
"the service stanza is not scoped to the reader"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_grant_is_read_only() {
|
|
// A hive reads credentials; a hive that could write one could hand
|
|
// itself an agent's identity.
|
|
let p = render("pr1ma").expect("legal");
|
|
assert!(!p.contains("create"));
|
|
assert!(!p.contains("update"));
|
|
assert!(!p.contains("delete"));
|
|
assert!(!p.contains("list"));
|
|
}
|
|
|
|
#[test]
|
|
fn one_hives_document_does_not_reach_another_hives_path() {
|
|
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
|
|
// is per-reader now, so identical text is no longer the property. This
|
|
// is what that test was protecting — that a document says only what the
|
|
// deploy-time name puts in it.
|
|
let a = render("alpha").expect("legal");
|
|
assert!(a.contains("swarm/hives/alpha/*"));
|
|
assert!(!a.contains("beta"));
|
|
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
|
}
|
|
|
|
#[test]
|
|
fn the_same_name_still_renders_byte_identically() {
|
|
// The half of the old property that survives: the text is a function of
|
|
// the deploy-time name alone, so a re-emission cannot drift.
|
|
assert_eq!(
|
|
render("pr1ma").expect("legal"),
|
|
render("pr1ma").expect("legal")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_that_could_close_the_stanza_is_refused() {
|
|
// The name reaches the document now, which it did not before — so the
|
|
// injection case is live again in the policy TEXT, not just in the
|
|
// policy's identifier.
|
|
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
|
|
assert!(
|
|
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
|
"the object NAME is a place a name can do damage too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_legal_charset_is_actually_reachable() {
|
|
// The control for the case above: if every name were refused, that
|
|
// assertion would pass while proving nothing.
|
|
assert!(hive_object_name("a-b_C9").is_ok());
|
|
assert!(render("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
|
|
// `hive-` is what the controller's own policy scopes both
|
|
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
|
|
// is one the controller cannot create at all.
|
|
let n = hive_object_name("pr1ma").expect("legal");
|
|
assert_eq!(n, "hive-pr1ma");
|
|
assert!(n.starts_with(HIVE_PREFIX));
|
|
}
|
|
}
|