use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use super::middleware::AuthenticatedUser;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrincipalKind {
User,
Agent,
}
#[derive(Clone, Debug)]
pub struct Principal {
pub user_id: i64,
pub is_superuser: bool,
pub tenant: Option<String>,
pub kind: PrincipalKind,
pub agent_id: Option<i64>,
}
impl Principal {
#[must_use]
pub fn user(user_id: i64, is_superuser: bool, tenant: Option<String>) -> Self {
Self {
user_id,
is_superuser,
tenant,
kind: PrincipalKind::User,
agent_id: None,
}
}
#[must_use]
pub fn admin(user_id: i64) -> Self {
Self {
is_superuser: true,
..Self::user(user_id, true, None)
}
}
#[must_use]
pub fn from_parts(parts: &Parts) -> Option<Self> {
if let Some(p) = parts.extensions.get::<Self>() {
return Some(p.clone());
}
if let Some(u) = parts.extensions.get::<AuthenticatedUser>() {
return Some(Self {
user_id: u.id,
is_superuser: u.is_superuser,
tenant: parts
.extensions
.get::<TenantSlug>()
.map(|TenantSlug(s)| s.clone()),
kind: PrincipalKind::User,
agent_id: None,
});
}
#[cfg(feature = "mcp")]
if let Some(agent) = parts.extensions.get::<crate::mcp::auth::McpAgent>() {
return agent.user_id.map(|uid| Self {
user_id: uid,
is_superuser: false,
tenant: Some(agent.tenant.clone()),
kind: PrincipalKind::Agent,
agent_id: Some(agent.agent_id),
});
}
None
}
#[must_use]
pub fn may_access(&self, owner_id: i64, superuser_sees_all: bool) -> bool {
self.user_id == owner_id || (superuser_sees_all && self.is_superuser)
}
}
#[derive(Clone, Debug)]
pub struct TenantSlug(pub String);
#[derive(Debug)]
pub struct Unauthenticated;
impl IntoResponse for Unauthenticated {
fn into_response(self) -> Response {
crate::api_errors::ApiError::unauthorized("authentication required").into_response()
}
}
impl<S: Send + Sync> FromRequestParts<S> for Principal {
type Rejection = Unauthenticated;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Self::from_parts(parts).ok_or(Unauthenticated)
}
}
#[derive(Clone, Debug)]
pub struct OptionalPrincipal(pub Option<Principal>);
impl<S: Send + Sync> FromRequestParts<S> for OptionalPrincipal {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self(Principal::from_parts(parts)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parts() -> Parts {
axum::http::Request::builder()
.body(())
.expect("request")
.into_parts()
.0
}
#[test]
fn unauthenticated_requests_have_no_principal() {
assert!(Principal::from_parts(&parts()).is_none());
}
#[tokio::test]
async fn the_rejection_is_an_api_error() {
let r = Unauthenticated.into_response();
assert_eq!(r.status(), axum::http::StatusCode::UNAUTHORIZED);
let b = axum::body::to_bytes(r.into_body(), 1 << 16).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["error"], "unauthorized");
}
#[test]
fn an_authenticated_user_becomes_a_user_principal() {
let mut p = parts();
p.extensions.insert(AuthenticatedUser {
id: 7,
username: "ada".into(),
is_superuser: true,
});
p.extensions.insert(TenantSlug("acme".into()));
let principal = Principal::from_parts(&p).expect("principal");
assert_eq!(principal.user_id, 7);
assert!(principal.is_superuser);
assert_eq!(principal.tenant.as_deref(), Some("acme"));
assert_eq!(principal.kind, PrincipalKind::User);
}
#[test]
fn an_explicit_principal_wins_over_a_derived_one() {
let mut p = parts();
p.extensions.insert(AuthenticatedUser {
id: 7,
username: "ada".into(),
is_superuser: false,
});
p.extensions
.insert(Principal::user(99, false, Some("acme".into())));
assert_eq!(Principal::from_parts(&p).expect("principal").user_id, 99);
}
#[test]
fn access_is_ownership_unless_superusers_are_let_through() {
let owner = Principal::user(7, false, None);
assert!(owner.may_access(7, false));
assert!(!owner.may_access(8, false));
let admin = Principal::user(1, true, None);
assert!(!admin.may_access(8, false), "opt-in, not automatic");
assert!(admin.may_access(8, true));
}
}