use std::str::FromStr;
use cheers_core::yah_scopes;
use std::sync::Arc;
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use cheers_core::{PrincipalId, PrincipalKind};
use cheers_server::{AuditCursor, AuditQuery, AuditRecord, AuditRow, AuditStore};
use crate::error::RouteError;
use crate::mcp::{McpAuthState, McpClaimsExt, authenticate_mcp};
pub struct AuditState<A> {
pub mcp: Arc<McpAuthState>,
pub store: Arc<A>,
}
impl<A> std::fmt::Debug for AuditState<A> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditState").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(transparent)]
pub struct AuditIngestBody(pub Vec<AuditRecord>);
#[derive(Debug, Clone, Serialize)]
pub struct AuditIngestResponse {
pub rows: Vec<AuditRow>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AuditReadQuery {
#[serde(default)]
pub since: Option<i64>,
#[serde(default, rename = "method-prefix", alias = "method_prefix")]
pub method_prefix: Option<String>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditPageResponse {
pub rows: Vec<AuditRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
pub fn router<A>(state: Arc<AuditState<A>>) -> Router
where
A: AuditStore + 'static,
{
Router::new()
.route("/audit/ingest", post(ingest::<A>))
.route(
"/audit/by-on-behalf-of/{user}",
get(by_on_behalf_of::<A>),
)
.with_state(state)
}
pub async fn ingest<A>(
State(state): State<Arc<AuditState<A>>>,
headers: HeaderMap,
Json(AuditIngestBody(records)): Json<AuditIngestBody>,
) -> Result<(StatusCode, Json<AuditIngestResponse>), RouteError>
where
A: AuditStore,
{
let now = now_unix();
let claims = authenticate_mcp(&headers, &state.mcp, now).await?;
claims.require_scope(yah_scopes::AUDIT_WRITE)?;
for rec in &records {
rec.validate()?;
}
let rows = state.store.insert_batch(&records, now).await?;
Ok((StatusCode::CREATED, Json(AuditIngestResponse { rows })))
}
pub async fn by_on_behalf_of<A>(
State(state): State<Arc<AuditState<A>>>,
headers: HeaderMap,
Path(user): Path<String>,
Query(params): Query<AuditReadQuery>,
) -> Result<Json<AuditPageResponse>, RouteError>
where
A: AuditStore,
{
let now = now_unix();
let claims = authenticate_mcp(&headers, &state.mcp, now).await?;
claims.require_scope(yah_scopes::AUDIT_READ)?;
let target = PrincipalId::from_str(&user)
.map_err(|e| RouteError::InvalidAuditQuery(format!("path principal: {e}")))?;
authorize_audit_subject(&claims.sub, &target)?;
let mut query = AuditQuery::new(target)
.map_err(|e| RouteError::InvalidAuditQuery(e.to_string()))?;
if let Some(since) = params.since {
query = query.with_since(since);
}
if let Some(prefix) = params.method_prefix {
query = query.with_method_prefix(prefix);
}
if let Some(cursor) = params.cursor.as_deref() {
let cursor = AuditCursor::from_wire(cursor)
.map_err(|e| RouteError::InvalidAuditQuery(e.to_string()))?;
query = query.with_cursor(cursor);
}
if let Some(limit) = params.limit {
query = query.with_limit(limit);
}
let page = state.store.query_by_on_behalf_of(&query).await?;
Ok(Json(AuditPageResponse {
rows: page.rows,
next_cursor: page.next_cursor.as_ref().map(AuditCursor::to_wire),
}))
}
pub fn authorize_audit_subject(
caller: &PrincipalId,
target: &PrincipalId,
) -> Result<(), RouteError> {
match caller.kind {
PrincipalKind::Service => Ok(()),
PrincipalKind::User if caller == target => Ok(()),
_ => Err(RouteError::AuditSubjectForbidden),
}
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use cheers_server::AuditValidationError;
use serde_urlencoded;
#[test]
fn audit_invalid_responds_400_with_stable_code() {
let err: RouteError = AuditValidationError::EmptyAud.into();
let (status, code) = err.status_and_code();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(code, "audit_invalid");
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn audit_subject_forbidden_responds_403_with_stable_code() {
let err = RouteError::AuditSubjectForbidden;
let (status, code) = err.status_and_code();
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(code, "audit_subject_forbidden");
assert_eq!(err.into_response().status(), StatusCode::FORBIDDEN);
}
#[test]
fn invalid_audit_query_responds_400_with_stable_code() {
let err = RouteError::InvalidAuditQuery("bad cursor".into());
let (status, code) = err.status_and_code();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(code, "invalid_audit_query");
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
}
#[test]
fn service_principals_may_read_any_users_audit() {
let dashboard = PrincipalId::service("w127-dashboard");
authorize_audit_subject(&dashboard, &PrincipalId::user("alice"))
.expect("the dashboard service reads any user");
authorize_audit_subject(&dashboard, &PrincipalId::user("bob"))
.expect("including a second user");
}
#[test]
fn users_may_read_only_their_own_audit() {
let alice = PrincipalId::user("alice");
authorize_audit_subject(&alice, &alice).expect("self-query is allowed");
assert!(matches!(
authorize_audit_subject(&alice, &PrincipalId::user("bob")),
Err(RouteError::AuditSubjectForbidden),
));
assert!(matches!(
authorize_audit_subject(&alice, &PrincipalId::service("alice")),
Err(RouteError::AuditSubjectForbidden),
));
}
#[test]
fn camp_principals_are_denied_rather_than_resolved_to_their_bound_user() {
assert!(matches!(
authorize_audit_subject(&PrincipalId::camp("camp-a"), &PrincipalId::user("alice")),
Err(RouteError::AuditSubjectForbidden),
));
}
#[test]
fn read_query_accepts_both_method_prefix_spellings() {
let hyphen: AuditReadQuery =
serde_urlencoded::from_str("since=5&method-prefix=cloud.deploy&limit=3")
.expect("hyphen spelling parses");
assert_eq!(hyphen.since, Some(5));
assert_eq!(hyphen.method_prefix.as_deref(), Some("cloud.deploy"));
assert_eq!(hyphen.limit, Some(3));
let underscore: AuditReadQuery =
serde_urlencoded::from_str("method_prefix=cloud.deploy")
.expect("underscore alias parses");
assert_eq!(underscore.method_prefix.as_deref(), Some("cloud.deploy"));
let empty: AuditReadQuery = serde_urlencoded::from_str("").expect("no params is valid");
assert!(empty.since.is_none() && empty.method_prefix.is_none());
assert!(empty.cursor.is_none() && empty.limit.is_none());
}
}