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;
pub const WORKFLOW_RUN_SCOPE: &str = "klieo.workflow.run";
pub const WORKFLOW_READ_SCOPE: &str = "klieo.workflow.read";
pub const WORKFLOW_READ_ALL_SCOPE: &str = "klieo.workflow.read.all";
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());
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)
}
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
}
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"));
}
}