swarm-nats-auth: read its own OIDC client secret from the store
The responder reads its OIDC client secret at `swarm/services/<client-id>/oidc/client` with the store login it already opens for the callout keys, and holds it in memory. `--client-secret-file` is removed. When introspection answers non-2xx, the responder re-reads the secret from the store once and, if it changed, retries the introspection once. Both fit inside the request's 1.5s introspection budget, under the server's 2s callout timeout: the budget is now one deadline for the whole request. A re-read that does not finish by then keeps running in the background, the request is denied, and the next request introspects with what it read. One re-read runs at a time; a refusal while one runs waits on it. In swarm-nats.nix the `swarm-bao-nats-auth-oidc` fetch unit, the flag and the `oidc-client.secret` LoadCredential entry are gone, along with the unit's policy ordering in glue-bao-readers-policy-order.nix. A tmpfiles `r` rule removes the copy a container still holds at /var/lib/nixos-containers/swarm-nats/var/lib/swarm-nats-auth/oidc-client.secret; nothing reads it. No policy change: the `swarm-nats-auth` role already reads this path (`natsAuthReaders` in swarm-bao.nix), which is the role the removed fetch unit logged in as. Deploy order: none, and no operator step. The store's grant already exists, so the queue host can be deployed before, after or without the store's host and authelia's. Rolling back re-creates the fetch unit and the file; the store leaf is untouched.
This commit is contained in:
parent
a66670be87
commit
6646329da5
12 changed files with 557 additions and 253 deletions
|
|
@ -33,12 +33,26 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// Kept under `swarm-nats.nix`'s `authorization.timeout = "2s"` — see the
|
||||
/// module docs. A slower answer is not a late success, it is a denial that
|
||||
/// already happened.
|
||||
/// already happened. It bounds a whole request, the re-read of this
|
||||
/// responder's own secret and the retry after it included, which is why
|
||||
/// [`identify_caller`] takes a deadline rather than a duration.
|
||||
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// What one introspection call said.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// A 2xx: the caller, or `None` for every shape that is not an admission.
|
||||
Caller(Option<String>),
|
||||
/// A non-2xx: the `IdP` did not answer about the token at all, which is
|
||||
/// also how it refuses this responder's own credential. `oidc_secret`
|
||||
/// re-reads that credential and retries once before this denies.
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// The two fields this responder acts on — one per decision. Authelia returns
|
||||
/// more (`sub`, `scope`, `exp`); modelling them would imply we check them.
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -75,10 +89,12 @@ impl IntrospectionResponse {
|
|||
|
||||
/// Ask the `IdP` whether `token` is currently valid, and as whom.
|
||||
///
|
||||
/// Returns `Ok(Some(client_id))` **only** on a 2xx whose body says `active:
|
||||
/// true` and names the client. Every other outcome is `Ok(None)` with the
|
||||
/// reason logged, or `Err` when the call could not be made at all — callers
|
||||
/// must treat both as a denial.
|
||||
/// Returns `Ok(Answer::Caller(Some(client_id)))` **only** on a 2xx whose
|
||||
/// body says `active: true` and names the client. Every other 2xx is
|
||||
/// `Ok(Answer::Caller(None))` with the reason logged, a non-2xx is
|
||||
/// `Ok(Answer::Refused)`, and `Err` is a call that could not be made at all
|
||||
/// or did not finish by `deadline` — callers must treat all but the first as
|
||||
/// a denial.
|
||||
///
|
||||
/// `own_client_id` / `own_client_secret` are *this responder's* credential for
|
||||
/// the introspection endpoint. They are not the caller's identity and must
|
||||
|
|
@ -93,26 +109,27 @@ pub async fn identify_caller(
|
|||
own_client_id: &str,
|
||||
own_client_secret: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<String>> {
|
||||
deadline: Instant,
|
||||
) -> Result<Answer> {
|
||||
let resp = http
|
||||
.post(url)
|
||||
.basic_auth(own_client_id, Some(own_client_secret))
|
||||
.form(&[("token", token)])
|
||||
.timeout(INTROSPECTION_TIMEOUT)
|
||||
.timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.send()
|
||||
.await
|
||||
.context("introspection request")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
tracing::warn!(%status, "introspection returned non-2xx; denying");
|
||||
return Ok(None);
|
||||
tracing::warn!(%status, "introspection returned non-2xx");
|
||||
return Ok(Answer::Refused);
|
||||
}
|
||||
let body: IntrospectionResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "introspection body did not parse; denying");
|
||||
return Ok(None);
|
||||
return Ok(Answer::Caller(None));
|
||||
}
|
||||
};
|
||||
if body.active && body.caller().is_none() {
|
||||
|
|
@ -121,7 +138,7 @@ pub async fn identify_caller(
|
|||
// rejected for no visible reason.
|
||||
tracing::warn!("introspection said active but named no client; denying");
|
||||
}
|
||||
Ok(body.caller().map(str::to_owned))
|
||||
Ok(Answer::Caller(body.caller().map(str::to_owned)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue