use crate::extract::RequestId;
use crate::extract::{Auth, IdTag};
use crate::prelude::*;
use axum::{
body::Body,
extract::State,
http::{Request, header, response::Response},
middleware::Next,
};
use cloudillo_types::auth_adapter::AuthCtx;
use cloudillo_types::types::TokenScope;
use std::pin::Pin;
const TENANT_API_KEY_PREFIX: &str = "cl_";
const IDP_API_KEY_PREFIX: &str = "idp_";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKeyType {
Tenant,
Idp,
}
fn get_api_key_type(token: &str) -> Option<ApiKeyType> {
if token.starts_with(TENANT_API_KEY_PREFIX) {
Some(ApiKeyType::Tenant)
} else if token.starts_with(IDP_API_KEY_PREFIX) {
Some(ApiKeyType::Idp)
} else {
None
}
}
pub type PermissionCheckInput =
(State<App>, Auth, axum::extract::Path<String>, Request<Body>, Next);
pub type PermissionCheckOutput =
Pin<Box<dyn Future<Output = Result<axum::response::Response, Error>> + Send>>;
#[derive(Clone)]
pub struct PermissionCheckFactory<F>
where
F: Fn(
State<App>,
Auth,
axum::extract::Path<String>,
Request<Body>,
Next,
) -> PermissionCheckOutput
+ Clone
+ Send
+ Sync,
{
handler: F,
}
impl<F> PermissionCheckFactory<F>
where
F: Fn(
State<App>,
Auth,
axum::extract::Path<String>,
Request<Body>,
Next,
) -> PermissionCheckOutput
+ Clone
+ Send
+ Sync,
{
pub fn new(handler: F) -> Self {
Self { handler }
}
pub fn call(
&self,
state: State<App>,
auth: Auth,
path: axum::extract::Path<String>,
req: Request<Body>,
next: Next,
) -> PermissionCheckOutput {
(self.handler)(state, auth, path, req, next)
}
}
fn extract_token_from_query(query: &str) -> Option<String> {
for param in query.split('&') {
if param.starts_with("token=") {
let token = param.strip_prefix("token=")?;
if !token.is_empty() {
return Some(token.to_string());
}
}
}
None
}
pub async fn require_leader(
Auth(auth_ctx): Auth,
req: Request<Body>,
next: Next,
) -> ClResult<Response<Body>> {
if auth_ctx.scope.as_deref().and_then(TokenScope::parse).is_some() {
warn!(
subject = %auth_ctx.id_tag,
scope = ?auth_ctx.scope,
"Owner/leader permission denied - delegated token"
);
return Err(Error::PermissionDenied);
}
if !crate::roles::is_leader(&auth_ctx.roles) {
warn!(
subject = %auth_ctx.id_tag,
roles = ?auth_ctx.roles,
"Owner/leader permission denied"
);
return Err(Error::PermissionDenied);
}
Ok(next.run(req).await)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScopeGate {
Enforce,
Skip,
}
async fn authenticate(
state: App,
mut req: Request<Body>,
next: Next,
gate: ScopeGate,
) -> ClResult<Response<Body>> {
let id_tag = req
.extensions()
.get::<IdTag>()
.ok_or_else(|| {
warn!("IdTag not found in request extensions");
Error::PermissionDenied
})?
.clone();
let tn_id = state.auth_adapter.read_tn_id(&id_tag.0).await.map_err(|_| {
warn!("Failed to resolve tenant ID for id_tag: {}", id_tag.0);
Error::PermissionDenied
})?;
let token = if let Some(auth_header) =
req.headers().get("Authorization").and_then(|h| h.to_str().ok())
{
if let Some(token) = auth_header.strip_prefix("Bearer ") {
token.trim().to_string()
} else {
warn!("Authorization header present but doesn't start with 'Bearer ': {}", auth_header);
return Err(Error::PermissionDenied);
}
} else {
let query_token = extract_token_from_query(req.uri().query().unwrap_or(""));
if query_token.is_none() {
warn!("No Authorization header and no token query parameter found");
}
query_token.ok_or(Error::PermissionDenied)?
};
let claims = match get_api_key_type(&token) {
Some(ApiKeyType::Tenant) => {
let validation = state.auth_adapter.validate_api_key(&token).await.map_err(|e| {
warn!("Tenant API key validation failed: {:?}", e);
Error::PermissionDenied
})?;
if validation.tn_id != tn_id {
warn!(
"API key tenant mismatch: key belongs to {:?} but request is for {:?}",
validation.tn_id, tn_id
);
return Err(Error::PermissionDenied);
}
AuthCtx {
tn_id: validation.tn_id,
id_tag: validation.id_tag,
roles: validation.roles.map(|r| crate::roles::parse_roles(&r)).unwrap_or_default(),
scope: validation.scopes,
anonymous: false,
}
}
Some(ApiKeyType::Idp) => {
let idp_adapter = state.idp_adapter.as_ref().ok_or_else(|| {
warn!("IDP API key used but Identity Provider not available");
Error::ServiceUnavailable("Identity Provider not available".to_string())
})?;
let auth_id_tag = idp_adapter
.verify_api_key(&token)
.await
.map_err(|e| {
warn!("IDP API key validation error: {:?}", e);
Error::PermissionDenied
})?
.ok_or_else(|| {
warn!("IDP API key validation failed: key not found or expired");
Error::PermissionDenied
})?;
AuthCtx {
tn_id, id_tag: auth_id_tag.into(),
roles: Box::new([]), scope: None,
anonymous: false,
}
}
None => {
state.auth_adapter.validate_access_token(tn_id, &id_tag.0, &token).await?
}
};
if gate == ScopeGate::Enforce
&& !crate::scope::scope_permits(claims.scope.as_deref(), req.method(), req.uri().path())
{
warn!(
scope = ?claims.scope,
path = %req.uri().path(),
"Scoped token denied access to non-matching endpoint"
);
return Err(Error::PermissionDenied);
}
req.extensions_mut().insert(Auth(claims));
Ok(next.run(req).await)
}
pub async fn require_auth(
State(state): State<App>,
req: Request<Body>,
next: Next,
) -> ClResult<Response<Body>> {
authenticate(state, req, next, ScopeGate::Enforce).await
}
pub async fn require_auth_public_data(
State(state): State<App>,
req: Request<Body>,
next: Next,
) -> ClResult<Response<Body>> {
authenticate(state, req, next, ScopeGate::Skip).await
}
pub async fn optional_auth(
State(state): State<App>,
mut req: Request<Body>,
next: Next,
) -> ClResult<Response<Body>> {
let id_tag = req.extensions().get::<IdTag>().cloned();
let token = if let Some(auth_header) =
req.headers().get(header::AUTHORIZATION).and_then(|h| h.to_str().ok())
{
auth_header.strip_prefix("Bearer ").map(|token| token.trim().to_string())
} else if req.uri().path().starts_with("/ws/") || req.uri().path().starts_with("/api/files/") {
let query = req.uri().query().unwrap_or("");
extract_token_from_query(query)
} else {
None
};
if let (Some(id_tag), Some(ref token)) = (id_tag, token) {
match state.auth_adapter.read_tn_id(&id_tag.0).await {
Ok(tn_id) => {
let claims_result: Result<Result<AuthCtx, Error>, Error> =
match get_api_key_type(token) {
Some(ApiKeyType::Tenant) => {
state.auth_adapter.validate_api_key(token).await.map(|validation| {
if validation.tn_id != tn_id {
return Err(Error::PermissionDenied);
}
Ok(AuthCtx {
tn_id: validation.tn_id,
id_tag: validation.id_tag,
roles: validation
.roles
.map(|r| crate::roles::parse_roles(&r))
.unwrap_or_default(),
scope: validation.scopes,
anonymous: false,
})
})
}
Some(ApiKeyType::Idp) => {
if let Some(idp_adapter) = state.idp_adapter.as_ref() {
match idp_adapter.verify_api_key(token).await {
Ok(Some(auth_id_tag)) => Ok(Ok(AuthCtx {
tn_id,
id_tag: auth_id_tag.into(),
roles: Box::new([]),
scope: None,
anonymous: false,
})),
Ok(None) => {
warn!(
"IDP API key validation failed: key not found or expired"
);
Err(Error::PermissionDenied)
}
Err(e) => {
warn!("IDP API key validation error: {:?}", e);
Err(Error::PermissionDenied)
}
}
} else {
warn!("IDP API key used but Identity Provider not available");
Err(Error::ServiceUnavailable(
"Identity Provider not available".to_string(),
))
}
}
None => {
state
.auth_adapter
.validate_access_token(tn_id, &id_tag.0, token)
.await
.map(Ok)
}
};
match claims_result {
Ok(Ok(claims)) => {
let allowed = crate::scope::scope_permits(
claims.scope.as_deref(),
req.method(),
req.uri().path(),
);
if allowed {
req.extensions_mut().insert(Auth(claims));
} else {
warn!(
scope = ?claims.scope,
path = %req.uri().path(),
"Scoped token denied access in optional_auth, treating as unauthenticated"
);
}
}
Ok(Err(e)) => {
warn!("Token validation failed (tenant mismatch): {:?}", e);
}
Err(e) => {
warn!("Token validation failed: {:?}", e);
}
}
}
Err(e) => {
warn!("Failed to resolve tenant ID: {:?}", e);
}
}
}
Ok(next.run(req).await)
}
pub async fn request_id_middleware(mut req: Request<Body>, next: Next) -> Response<Body> {
let span = RequestId::install(&mut req);
let request_id = req.extensions().get::<RequestId>().map(|r| r.0.clone()).unwrap_or_default();
let mut response = {
use tracing::Instrument;
next.run(req).instrument(span).await
};
if let Ok(header_value) = request_id.parse() {
response.headers_mut().insert("X-Request-ID", header_value);
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, http::StatusCode, middleware, routing::get};
use tower::ServiceExt;
fn auth_ctx(roles: &[&str], scope: Option<&str>) -> AuthCtx {
AuthCtx {
tn_id: TnId(1),
id_tag: "alice.example.com".into(),
roles: roles.iter().map(|r| Box::from(*r)).collect(),
scope: scope.map(Box::from),
anonymous: false,
}
}
async fn run_require_leader(auth: Option<AuthCtx>) -> StatusCode {
let app: Router = Router::new()
.route("/x", get(|| async { "ok" }))
.layer(middleware::from_fn(require_leader));
let mut req = Request::builder().uri("/x").body(Body::empty()).expect("build request");
if let Some(ctx) = auth {
req.extensions_mut().insert(Auth(ctx));
}
app.oneshot(req).await.expect("router responds").status()
}
#[tokio::test]
async fn require_leader_allows_leader() {
assert_eq!(run_require_leader(Some(auth_ctx(&["leader"], None))).await, StatusCode::OK);
}
#[tokio::test]
async fn require_leader_denies_non_leader_roles() {
assert_eq!(
run_require_leader(Some(auth_ctx(&["contributor"], None))).await,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn require_leader_denies_role_less_principal() {
assert_eq!(run_require_leader(Some(auth_ctx(&[], None))).await, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn require_leader_denies_delegated_token_with_leader_roles() {
assert_eq!(
run_require_leader(Some(auth_ctx(&["leader"], Some("file:f1~abc:W")))).await,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn require_leader_allows_capability_token_with_leader_roles() {
assert_eq!(
run_require_leader(Some(auth_ctx(&["leader"], Some("carddav:read")))).await,
StatusCode::OK
);
}
#[tokio::test]
async fn require_leader_denies_missing_auth() {
assert_eq!(run_require_leader(None).await, StatusCode::FORBIDDEN);
}
#[test]
fn file_scope_needs_the_permissive_tier_for_the_batch_route() {
use crate::scope::scope_permits;
use axum::http::Method;
let s = Some("file:f1~abc:R");
assert!(!scope_permits(s, &Method::GET, "/api/profiles/batch"));
assert!(!scope_permits(s, &Method::GET, "/api/profiles/alice.example.com"));
assert!(!scope_permits(s, &Method::PATCH, "/api/profiles/alice.example.com"));
assert!(!scope_permits(s, &Method::POST, "/api/profiles/alice.example.com/refresh"));
assert!(!scope_permits(s, &Method::PUT, "/api/profiles/alice.example.com"));
assert!(!scope_permits(s, &Method::GET, "/api/profiles"));
}
}