synops/maskinrommet/src/auth.rs
vegard 1e34c3c67a Valider fase 15–16: sikkerhet, konsistens og atomisk toggle
Tre fikser funnet under validering:

1. SIKKERHET: Admin-endepunkter manglet autorisasjonssjekk.
   Alle /admin/*-endepunkter brukte kun AuthUser (autentisert),
   ikke admin-rolle. Ny AdminUser-extractor sjekker owner/admin-edge
   til samling — returnerer 403 Forbidden for ikke-admins.
   Berører: maintenance, jobs, resources, health, ai, usage.

2. Race condition i toggle_effect: les-modifiser-skriv uten transaksjon
   på active_effects JSON. Erstattet med atomisk PG jsonb-operasjon.

3. Manglende updated_by i set_gain, set_mute, set_mixer_role, toggle_effect.
   Nå spores hvem som endret mixer-tilstanden.
2026-03-18 15:39:30 +00:00

275 lines
8.3 KiB
Rust

use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::AppState;
// ---------------------------------------------------------------------------
// JWKS types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct JwksResponse {
pub keys: Vec<JwkKey>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JwkKey {
pub kid: Option<String>,
pub kty: String,
pub n: String,
pub e: String,
}
/// Cached JWKS keys and OIDC config fetched from Authentik at startup.
#[derive(Debug, Clone)]
pub struct JwksKeys {
pub keys: Vec<JwkKey>,
pub issuer: String,
pub audience: String,
}
impl JwksKeys {
/// Fetch JWKS from Authentik's OIDC discovery endpoint.
pub async fn fetch(issuer: &str, audience: &str) -> Result<Self, String> {
let jwks_url = format!("{}jwks/", issuer.trim_end_matches('/').to_owned() + "/");
tracing::info!("Henter JWKS fra {jwks_url}");
let resp = reqwest::get(&jwks_url)
.await
.map_err(|e| format!("Kunne ikke hente JWKS: {e}"))?;
let jwks: JwksResponse = resp
.json()
.await
.map_err(|e| format!("Kunne ikke parse JWKS: {e}"))?;
if jwks.keys.is_empty() {
return Err("JWKS inneholder ingen nøkler".to_string());
}
tracing::info!("Lastet {} JWKS-nøkler", jwks.keys.len());
Ok(Self {
keys: jwks.keys,
issuer: issuer.to_string(),
audience: audience.to_string(),
})
}
/// Find decoding key by kid, or use the first key if no kid in header.
pub fn decoding_key(&self, kid: Option<&str>) -> Result<DecodingKey, String> {
let key = match kid {
Some(kid) => self
.keys
.iter()
.find(|k| k.kid.as_deref() == Some(kid))
.ok_or_else(|| format!("Ukjent kid: {kid}"))?,
None => self.keys.first().ok_or("Ingen JWKS-nøkler tilgjengelig")?,
};
DecodingKey::from_rsa_components(&key.n, &key.e)
.map_err(|e| format!("Ugyldig RSA-nøkkel: {e}"))
}
}
// ---------------------------------------------------------------------------
// JWT claims
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct Claims {
sub: String,
// iss and exp are validated by jsonwebtoken automatically
}
// ---------------------------------------------------------------------------
// AuthUser extractor
// ---------------------------------------------------------------------------
/// Authenticated user extracted from a valid JWT.
/// Use as an axum extractor on protected endpoints.
#[derive(Debug, Clone)]
pub struct AuthUser {
pub node_id: Uuid,
pub authentik_sub: String,
}
/// Error response for auth failures.
#[derive(Serialize)]
struct AuthError {
error: String,
}
impl IntoResponse for AuthErrorKind {
fn into_response(self) -> Response {
let (status, message) = match self {
AuthErrorKind::MissingToken => (StatusCode::UNAUTHORIZED, "Mangler Authorization-header"),
AuthErrorKind::InvalidToken(ref _e) => (StatusCode::UNAUTHORIZED, "Ugyldig token"),
AuthErrorKind::UnknownIdentity => {
(StatusCode::UNAUTHORIZED, "Ukjent brukeridentitet")
}
AuthErrorKind::Forbidden => {
(StatusCode::FORBIDDEN, "Krever admin-tilgang")
}
AuthErrorKind::Internal => {
(StatusCode::INTERNAL_SERVER_ERROR, "Intern feil ved autentisering")
}
};
let body = Json(AuthError {
error: message.to_string(),
});
(status, body).into_response()
}
}
#[derive(Debug)]
pub enum AuthErrorKind {
MissingToken,
InvalidToken(String),
UnknownIdentity,
Forbidden,
Internal,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = AuthErrorKind;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
// Extract Bearer token from Authorization header
let auth_header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AuthErrorKind::MissingToken)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AuthErrorKind::MissingToken)?;
// Decode header to get kid
let header = decode_header(token)
.map_err(|e| AuthErrorKind::InvalidToken(e.to_string()))?;
// Get decoding key from JWKS
let decoding_key = app_state
.jwks
.decoding_key(header.kid.as_deref())
.map_err(|e| {
tracing::warn!("JWKS-nøkkel ikke funnet: {e}");
AuthErrorKind::InvalidToken(e)
})?;
// Validate JWT (signature, exp, iss, aud)
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&app_state.jwks.issuer]);
validation.set_audience(&[&app_state.jwks.audience]);
let token_data = decode::<Claims>(token, &decoding_key, &validation).map_err(|e| {
tracing::debug!("JWT-validering feilet: {e}");
AuthErrorKind::InvalidToken(e.to_string())
})?;
let authentik_sub = token_data.claims.sub;
// Look up auth_identities → node_id
let row = sqlx::query_scalar::<_, Uuid>(
"SELECT node_id FROM auth_identities WHERE authentik_sub = $1",
)
.bind(&authentik_sub)
.fetch_optional(&app_state.db)
.await
.map_err(|e| {
tracing::error!("DB-feil ved oppslag av auth_identities: {e}");
AuthErrorKind::Internal
})?;
let node_id = row.ok_or_else(|| {
tracing::warn!("Ingen auth_identity for sub={authentik_sub}");
AuthErrorKind::UnknownIdentity
})?;
tracing::debug!("Autentisert bruker: node_id={node_id}, sub={authentik_sub}");
Ok(AuthUser {
node_id,
authentik_sub,
})
}
}
// We need FromRef to extract AppState from the state
use axum::extract::FromRef;
// ---------------------------------------------------------------------------
// AdminUser extractor — krever owner/admin-edge til en samling
// ---------------------------------------------------------------------------
/// Admin-bruker: autentisert bruker som har `owner`- eller `admin`-edge til
/// minst én samling. Brukes som extractor på /admin/*-endepunkter.
#[derive(Debug, Clone)]
pub struct AdminUser {
pub node_id: Uuid,
pub authentik_sub: String,
}
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = AuthErrorKind;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// Autentiser først
let user = AuthUser::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
// Sjekk at brukeren er admin (owner/admin-edge til en samling)
let is_admin = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges
WHERE source_id = $1
AND edge_type IN ('owner', 'admin')
AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
)
"#,
)
.bind(user.node_id)
.fetch_one(&app_state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved admin-sjekk");
AuthErrorKind::Internal
})?;
if !is_admin {
tracing::warn!(
user_id = %user.node_id,
"Ikke-admin forsøkte å bruke admin-endepunkt"
);
return Err(AuthErrorKind::Forbidden);
}
Ok(AdminUser {
node_id: user.node_id,
authentik_sub: user.authentik_sub,
})
}
}