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//!
30//! Copyright (c) systemprompt.io — Business Source License 1.1.
31//! See <https://systemprompt.io> for licensing details.
32
33use axum::extract::Request;
34use axum::middleware::Next;
35use axum::response::{IntoResponse, Response};
36use systemprompt_models::RequestContext;
37use systemprompt_models::api::ApiError;
38use systemprompt_models::auth::UserType;
39
40#[derive(Clone, Copy, Debug)]
41pub struct AuthzPolicy {
42 allowed: &'static [UserType],
43}
44
45impl AuthzPolicy {
46 #[must_use]
47 pub const fn public() -> Self {
48 Self {
49 allowed: &[
50 UserType::Anon,
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 authenticated() -> Self {
62 Self {
63 allowed: &[
64 UserType::User,
65 UserType::Admin,
66 UserType::A2a,
67 UserType::Mcp,
68 UserType::Service,
69 ],
70 }
71 }
72
73 #[must_use]
74 pub const fn user() -> Self {
75 Self {
76 allowed: &[UserType::User, UserType::Admin],
77 }
78 }
79
80 #[must_use]
81 pub const fn admin() -> Self {
82 Self {
83 allowed: &[UserType::Admin],
84 }
85 }
86
87 #[must_use]
88 pub const fn restricted_to(allowed: &'static [UserType]) -> Self {
89 Self { allowed }
90 }
91
92 fn permits(self, user_type: UserType) -> bool {
93 self.allowed.contains(&user_type)
94 }
95}
96
97pub async fn authz_gate(policy: AuthzPolicy, request: Request, next: Next) -> Response {
98 // Why: an absent RequestContext means the caller never authenticated;
99 // treating that as Anon means only AuthzPolicy::public admits it, so the
100 // gate fails closed rather than open.
101 let user_type = request
102 .extensions()
103 .get::<RequestContext>()
104 .map_or(UserType::Anon, RequestContext::user_type);
105
106 if policy.permits(user_type) {
107 next.run(request).await
108 } else {
109 ApiError::forbidden(format!(
110 "caller type '{}' is not authorized for this route",
111 user_type.as_str()
112 ))
113 .into_response()
114 }
115}