klieo-workflow-api 3.6.0

Embeddable Axum run-service router fronting klieo-workflow (submit / poll declarative workflows).
Documentation
//! Per-route authentication + scope gating.
//!
//! Each request is authenticated once; the route's required scope is checked
//! before the handler runs, and the verified [`Identity`](klieo_auth_common::Identity)
//! is inserted into request extensions for the handler to read.

use crate::error::WorkflowApiError;
use crate::state::WorkflowRunState;
use axum::{body::Body, extract::State, http::Request, middleware::Next, response::Response};
use klieo_auth_common::{Headers, Identity};
use std::sync::Arc;

/// Scope required to submit a run (`POST /runs`).
pub const WORKFLOW_RUN_SCOPE: &str = "klieo.workflow.run";
/// Scope required to read run state (`GET /runs/{id}`). Alone, this grants
/// read access only to runs the caller itself authored — see
/// [`WORKFLOW_READ_ALL_SCOPE`].
pub const WORKFLOW_READ_SCOPE: &str = "klieo.workflow.read";
/// Scope required to read a run authored by a DIFFERENT caller (operator /
/// cross-author read). Held alongside [`WORKFLOW_READ_SCOPE`], not instead
/// of it — the route-level middleware still requires the base read scope.
pub const WORKFLOW_READ_ALL_SCOPE: &str = "klieo.workflow.read.all";

/// Whether `identity` may read a run authored by `author`: either the
/// caller is that run's own author, or holds the cross-author
/// [`WORKFLOW_READ_ALL_SCOPE`]. Callers on the losing branch must map this
/// to [`WorkflowApiError::RunNotFound`], not `Forbidden` — a run's mere
/// existence is not disclosed to a non-owner.
pub(crate) fn can_read_run(identity: &Identity, author: &str) -> bool {
    identity.as_str() == author || identity.has_scope(WORKFLOW_READ_ALL_SCOPE)
}

struct HttpHeadersAdapter<'a>(&'a axum::http::HeaderMap);

impl Headers for HttpHeadersAdapter<'_> {
    fn get(&self, name: &str) -> Option<&str> {
        self.0.get(name).and_then(|v| v.to_str().ok())
    }
}

async fn authenticate_with_scope(
    state: Arc<WorkflowRunState>,
    mut request: Request<Body>,
    next: Next,
    scope: &str,
) -> Result<Response, WorkflowApiError> {
    let identity = {
        let adapter = HttpHeadersAdapter(request.headers());
        // Bearer credentials live in headers; v1 signs nothing over the body,
        // so an empty payload is passed. A body-signing scheme would need the
        // request body buffered here before it reaches the handler.
        state.authenticator.authenticate(&adapter, &[]).await
    }
    .map_err(|e| {
        tracing::warn!(reason = %e, "workflow-api request rejected: auth failure");
        WorkflowApiError::Unauthorized
    })?;
    if !identity.has_scope(scope) {
        tracing::warn!(
            principal_hash = %klieo_core::principal_hash(identity.as_str()),
            scope,
            "workflow-api request rejected: missing scope"
        );
        return Err(WorkflowApiError::Forbidden);
    }
    request.extensions_mut().insert(identity);
    Ok(next.run(request).await)
}

/// Middleware gating `POST /runs` on [`WORKFLOW_RUN_SCOPE`].
pub(crate) async fn require_run_scope(
    State(state): State<Arc<WorkflowRunState>>,
    request: Request<Body>,
    next: Next,
) -> Result<Response, WorkflowApiError> {
    authenticate_with_scope(state, request, next, WORKFLOW_RUN_SCOPE).await
}

/// Middleware gating `GET /runs/{id}` on [`WORKFLOW_READ_SCOPE`].
pub(crate) async fn require_read_scope(
    State(state): State<Arc<WorkflowRunState>>,
    request: Request<Body>,
    next: Next,
) -> Result<Response, WorkflowApiError> {
    authenticate_with_scope(state, request, next, WORKFLOW_READ_SCOPE).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    fn identity_without_scopes(principal: &str) -> Identity {
        Identity::with_scopes(principal, HashSet::new())
    }

    fn identity_with_read_all(principal: &str) -> Identity {
        Identity::with_scopes(
            principal,
            HashSet::from([WORKFLOW_READ_ALL_SCOPE.to_string()]),
        )
    }

    #[test]
    fn author_can_read_their_own_run() {
        assert!(can_read_run(&identity_without_scopes("alice"), "alice"));
    }

    #[test]
    fn non_author_without_read_all_is_denied() {
        assert!(!can_read_run(&identity_without_scopes("bob"), "alice"));
    }

    #[test]
    fn non_author_with_read_all_is_allowed() {
        assert!(can_read_run(&identity_with_read_all("bob"), "alice"));
    }
}