Skip to main content

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
20use axum::extract::Request;
21use axum::middleware::Next;
22use axum::response::{IntoResponse, Response};
23use systemprompt_models::RequestContext;
24use systemprompt_models::api::ApiError;
25use systemprompt_models::auth::UserType;
26
27#[derive(Clone, Copy, Debug)]
28pub struct AuthzPolicy {
29    allowed: &'static [UserType],
30}
31
32impl AuthzPolicy {
33    #[must_use]
34    pub const fn public() -> Self {
35        Self {
36            allowed: &[
37                UserType::Anon,
38                UserType::User,
39                UserType::Admin,
40                UserType::A2a,
41                UserType::Mcp,
42                UserType::Service,
43            ],
44        }
45    }
46
47    #[must_use]
48    pub const fn authenticated() -> Self {
49        Self {
50            allowed: &[
51                UserType::User,
52                UserType::Admin,
53                UserType::A2a,
54                UserType::Mcp,
55                UserType::Service,
56            ],
57        }
58    }
59
60    #[must_use]
61    pub const fn user() -> Self {
62        Self {
63            allowed: &[UserType::User, UserType::Admin],
64        }
65    }
66
67    #[must_use]
68    pub const fn admin() -> Self {
69        Self {
70            allowed: &[UserType::Admin],
71        }
72    }
73
74    #[must_use]
75    pub const fn restricted_to(allowed: &'static [UserType]) -> Self {
76        Self { allowed }
77    }
78
79    fn permits(self, user_type: UserType) -> bool {
80        self.allowed.contains(&user_type)
81    }
82}
83
84pub async fn authz_gate(policy: AuthzPolicy, request: Request, next: Next) -> Response {
85    // Why: an absent RequestContext means the caller never authenticated;
86    // treating that as Anon means only AuthzPolicy::public admits it, so the
87    // gate fails closed rather than open.
88    let user_type = request
89        .extensions()
90        .get::<RequestContext>()
91        .map_or(UserType::Anon, RequestContext::user_type);
92
93    if policy.permits(user_type) {
94        next.run(request).await
95    } else {
96        ApiError::forbidden(format!(
97            "caller type '{}' is not authorized for this route",
98            user_type.as_str()
99        ))
100        .into_response()
101    }
102}