systemprompt_api/services/middleware/authz.rs
1//! Static, compile-time-enforced per-route authorization.
2//!
3//! Authentication (the `RouterExt::with_auth` extension) builds a
4//! [`RequestContext`]; this layer then decides whether that caller may reach
5//! the route group at all.
6//!
7//! The guarantee: attaching the auth middleware to a route group is only
8//! possible via `with_auth`, which *requires* an [`AuthzPolicy`]. There is no
9//! way to authenticate a route group without also declaring its authorization
10//! tier — omitting the policy is a compile error.
11//!
12//! This is a COARSE gate — "may this kind of caller reach this route group".
13//! It does NOT replace per-resource ownership checks (e.g. "does this user own
14//! task X"); those remain the handler/repository layer's responsibility.
15//!
16//! One route group authenticates by bespoke means and deliberately does not
17//! use `with_auth`: the AI gateway (`/v1/messages`, its own credential
18//! extraction accepting `x-api-key`).
19//!
20//! `UserType::Anon` is a real, reachable principal — it is NOT true that every
21//! request carries a human user. An anonymous token is minted by
22//! `POST /oauth/session` and admitted only by [`AuthzPolicy::public`]. The
23//! public surface deliberately includes a few unauthenticated writes: the
24//! OAuth auth-establishment endpoints (token / authorize / webauthn, each
25//! gated by its own protocol) and append-only engagement telemetry ingestion.
26//! Every other public route is read-only; any new public-group handler that
27//! mutates state must enforce its own per-resource ownership check, because
28//! this gate will admit `Anon`.
29
30use axum::extract::Request;
31use axum::middleware::Next;
32use axum::response::{IntoResponse, Response};
33use systemprompt_models::RequestContext;
34use systemprompt_models::api::ApiError;
35use systemprompt_models::auth::UserType;
36
37#[derive(Clone, Copy, Debug)]
38pub struct AuthzPolicy {
39 allowed: &'static [UserType],
40}
41
42impl AuthzPolicy {
43 #[must_use]
44 pub const fn public() -> Self {
45 Self {
46 allowed: &[
47 UserType::Anon,
48 UserType::User,
49 UserType::Admin,
50 UserType::A2a,
51 UserType::Mcp,
52 UserType::Service,
53 ],
54 }
55 }
56
57 #[must_use]
58 pub const fn authenticated() -> Self {
59 Self {
60 allowed: &[
61 UserType::User,
62 UserType::Admin,
63 UserType::A2a,
64 UserType::Mcp,
65 UserType::Service,
66 ],
67 }
68 }
69
70 #[must_use]
71 pub const fn user() -> Self {
72 Self {
73 allowed: &[UserType::User, UserType::Admin],
74 }
75 }
76
77 #[must_use]
78 pub const fn admin() -> Self {
79 Self {
80 allowed: &[UserType::Admin],
81 }
82 }
83
84 #[must_use]
85 pub const fn restricted_to(allowed: &'static [UserType]) -> Self {
86 Self { allowed }
87 }
88
89 fn permits(self, user_type: UserType) -> bool {
90 self.allowed.contains(&user_type)
91 }
92}
93
94pub async fn authz_gate(policy: AuthzPolicy, request: Request, next: Next) -> Response {
95 // Why: an absent RequestContext means the caller never authenticated;
96 // treating that as Anon means only AuthzPolicy::public admits it, so the
97 // gate fails closed rather than open.
98 let user_type = request
99 .extensions()
100 .get::<RequestContext>()
101 .map_or(UserType::Anon, RequestContext::user_type);
102
103 if policy.permits(user_type) {
104 next.run(request).await
105 } else {
106 ApiError::forbidden(format!(
107 "caller type '{}' is not authorized for this route",
108 user_type.as_str()
109 ))
110 .into_response()
111 }
112}