Skip to main content

assay_auth/
gate.rs

1//! Unified authentication + authorization gate.
2//!
3//! [`extract_caller`] resolves a [`Caller`] from the request headers in
4//! a fixed order: admin api-key (break-glass) → session cookie → JWT
5//! bearer. Failure returns a ready-to-send `401 Unauthorized` response.
6//!
7//! [`require_role`] performs a coarse-grained Zanzibar role check on a
8//! resolved caller. `AdminApiKey` callers bypass — the api-key list is
9//! the operator's break-glass and is treated as carrying universal
10//! authority by definition.
11//!
12//! [`require_role_for`] composes the two for the common case where the
13//! caller doesn't need to be inspected separately.
14//!
15//! Used by:
16//!
17//! - [`crate::admin`] (`auth#system#admin`)
18//! - [`crate::oidc_provider::admin`] (`auth#system#admin`)
19//! - `assay_engine::engine_api` (`engine#core#admin`)
20//! - `assay_engine::server`'s workflow gate middleware (`workflow#<ns>#access`)
21
22use axum::http::{HeaderMap, StatusCode, header};
23use axum::response::{IntoResponse, Json, Response};
24use serde_json::json;
25
26use crate::ctx::AuthCtx;
27use crate::state::AdminApiKeys;
28
29/// An authenticated caller, produced by [`extract_caller`].
30#[derive(Clone, Debug)]
31pub struct Caller {
32    /// Stable identifier for this caller. For session and JWT callers
33    /// this is the user's id. For admin api-key callers this is a
34    /// non-reversible token tail (e.g. `admin:****abc123`) safe to log.
35    pub user_id: String,
36    pub source: CallerSource,
37}
38
39/// Where the caller's identity proof came from.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum CallerSource {
42    /// `assay_session` cookie resolved via [`crate::store::SessionStore`].
43    SessionCookie,
44    /// `Authorization: Bearer <jwt>` verified against the configured
45    /// issuer's JWKS.
46    Jwt,
47    /// `Authorization: Bearer <key>` matched a configured admin
48    /// api-key. Break-glass — bypasses Zanzibar role checks.
49    AdminApiKey,
50}
51
52impl Caller {
53    /// `true` iff the caller authenticated via the admin api-key
54    /// fallback. Used by [`require_role`] to skip the Zanzibar lookup —
55    /// admin api-keys are operator-controlled break-glass and carry
56    /// universal authority by construction.
57    pub fn is_break_glass(&self) -> bool {
58        matches!(self.source, CallerSource::AdminApiKey)
59    }
60}
61
62/// Resolve a [`Caller`] from the request headers.
63///
64/// Resolution order is fixed:
65///
66/// 1. `Authorization: Bearer <token>` matches a configured admin
67///    api-key → [`CallerSource::AdminApiKey`] (break-glass).
68/// 2. `Cookie: assay_session=<id>` resolves to a live session →
69///    [`CallerSource::SessionCookie`].
70/// 3. `Authorization: Bearer <jwt>` parses + verifies →
71///    [`CallerSource::Jwt`].
72/// 4. Otherwise → `Err(401)`.
73///
74/// The error variant is a boxed `Response` so callers can just
75/// `return *r;` on failure without re-wrapping.
76pub async fn extract_caller(
77    headers: &HeaderMap,
78    #[cfg_attr(
79        not(any(feature = "auth-session", feature = "auth-jwt")),
80        allow(unused_variables)
81    )]
82    ctx: &AuthCtx,
83    keys: &AdminApiKeys,
84) -> Result<Caller, Box<Response>> {
85    // 1. Admin api-key — operator break-glass. Checked first so the
86    //    expensive session/JWT round-trips are skipped when an admin is
87    //    on the call.
88    if let Some(token) = bearer_token(headers)
89        && keys.enabled()
90        && keys.check(token)
91    {
92        return Ok(Caller {
93            user_id: short_admin_actor(token),
94            source: CallerSource::AdminApiKey,
95        });
96    }
97
98    // 2. Session cookie.
99    #[cfg(feature = "auth-session")]
100    if let Some(sid) = cookie_value(headers, crate::session::SESSION_COOKIE) {
101        let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
102        if let Ok(Some(s)) = mgr.resolve(&sid).await {
103            return Ok(Caller {
104                user_id: s.user_id,
105                source: CallerSource::SessionCookie,
106            });
107        }
108    }
109
110    // 3. JWT bearer.
111    #[cfg(feature = "auth-jwt")]
112    if let Some(token) = bearer_token(headers) {
113        #[derive(serde::Deserialize)]
114        struct SubClaim {
115            sub: String,
116        }
117
118        // 3a. Internal issuer first — fastest path, no network
119        //     dependency. Engine-issued sessions / OIDC-provider tokens
120        //     fall here.
121        if let Some(jwt) = ctx.jwt.as_ref()
122            && let Ok(td) = jwt.verify::<SubClaim>(token)
123        {
124            return Ok(Caller {
125                user_id: td.claims.sub,
126                source: CallerSource::Jwt,
127            });
128        }
129
130        // 3b. External issuers (Hydra, Keycloak, Auth0, …). Routed by
131        //     `iss` claim so we don't burn CPU verifying against
132        //     mismatched key sets.
133        if let Some(result) =
134            crate::external_jwt::verify_with_any::<SubClaim>(ctx.external_issuers(), token)
135            && let Ok(td) = result
136        {
137            return Ok(Caller {
138                user_id: td.claims.sub,
139                source: CallerSource::Jwt,
140            });
141        }
142    }
143
144    Err(unauthorized("authentication required"))
145}
146
147/// Enforce a coarse-grained Zanzibar role check on `caller`.
148///
149/// `(object_type, object_id)` identifies the resource and `permission`
150/// is the relation/permission name. `AdminApiKey` callers bypass.
151///
152/// Returns `Err(403)` on a denied check, `Err(500)` on a Zanzibar
153/// backend error. With `auth-zanzibar` disabled at compile time every
154/// non-break-glass caller fails closed with `403`.
155pub async fn require_role(
156    caller: &Caller,
157    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] ctx: &AuthCtx,
158    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] object_type: &str,
159    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] object_id: &str,
160    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] permission: &str,
161) -> Result<(), Box<Response>> {
162    if caller.is_break_glass() {
163        return Ok(());
164    }
165    #[cfg(feature = "auth-zanzibar")]
166    {
167        use crate::zanzibar::{CheckResult, Consistency, ObjectRef, SubjectRef};
168        let Some(store) = ctx.zanzibar.as_ref() else {
169            // Zanzibar feature compiled in but not wired into AuthCtx —
170            // fail closed so a misconfigured boot doesn't silently
171            // grant access.
172            return Err(forbidden("zanzibar store not configured"));
173        };
174        let resource = ObjectRef {
175            object_type: object_type.to_string(),
176            object_id: object_id.to_string(),
177        };
178        let subject = SubjectRef {
179            subject_type: "user".to_string(),
180            subject_id: caller.user_id.clone(),
181            subject_rel: String::new(),
182        };
183        match store
184            .check(&resource, permission, &subject, Consistency::Minimum)
185            .await
186        {
187            Ok(CheckResult::Allowed { .. }) => Ok(()),
188            Ok(_) => Err(forbidden("permission denied")),
189            Err(e) => Err(internal(&format!("zanzibar check: {e}"))),
190        }
191    }
192    #[cfg(not(feature = "auth-zanzibar"))]
193    {
194        Err(forbidden("authorization not compiled in"))
195    }
196}
197
198/// Resolve caller + check role in one call. Returns the resolved
199/// caller on success so handlers that want it for audit logging can
200/// pluck it out.
201pub async fn require_role_for(
202    headers: &HeaderMap,
203    ctx: &AuthCtx,
204    keys: &AdminApiKeys,
205    object_type: &str,
206    object_id: &str,
207    permission: &str,
208) -> Result<Caller, Box<Response>> {
209    let caller = extract_caller(headers, ctx, keys).await?;
210    require_role(&caller, ctx, object_type, object_id, permission).await?;
211    Ok(caller)
212}
213
214// =====================================================================
215//   helpers
216// =====================================================================
217
218fn bearer_token(headers: &HeaderMap) -> Option<&str> {
219    headers
220        .get(header::AUTHORIZATION)
221        .and_then(|v| v.to_str().ok())
222        .and_then(|s| {
223            s.strip_prefix("Bearer ")
224                .or_else(|| s.strip_prefix("bearer "))
225        })
226        .map(str::trim)
227}
228
229#[cfg(feature = "auth-session")]
230fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
231    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
232    for kv in raw.split(';') {
233        let kv = kv.trim();
234        if let Some((k, v)) = kv.split_once('=')
235            && k == name
236        {
237            return Some(v.to_string());
238        }
239    }
240    None
241}
242
243fn short_admin_actor(token: &str) -> String {
244    let t = token.trim();
245    if t.len() <= 6 {
246        return format!("admin:****{t}");
247    }
248    let tail = &t[t.len() - 6..];
249    format!("admin:****{tail}")
250}
251
252fn unauthorized(msg: &str) -> Box<Response> {
253    Box::new(
254        (
255            StatusCode::UNAUTHORIZED,
256            Json(json!({"error": "unauthorized", "error_description": msg})),
257        )
258            .into_response(),
259    )
260}
261
262fn forbidden(msg: &str) -> Box<Response> {
263    Box::new(
264        (
265            StatusCode::FORBIDDEN,
266            Json(json!({"error": "forbidden", "error_description": msg})),
267        )
268            .into_response(),
269    )
270}
271
272fn internal(msg: &str) -> Box<Response> {
273    Box::new(
274        (
275            StatusCode::INTERNAL_SERVER_ERROR,
276            Json(json!({"error": "server_error", "error_description": msg})),
277        )
278            .into_response(),
279    )
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn caller_break_glass_only_for_api_key() {
288        let c = Caller {
289            user_id: "x".into(),
290            source: CallerSource::AdminApiKey,
291        };
292        assert!(c.is_break_glass());
293        let c = Caller {
294            user_id: "x".into(),
295            source: CallerSource::SessionCookie,
296        };
297        assert!(!c.is_break_glass());
298        let c = Caller {
299            user_id: "x".into(),
300            source: CallerSource::Jwt,
301        };
302        assert!(!c.is_break_glass());
303    }
304
305    #[test]
306    fn short_admin_actor_truncates_long_tokens() {
307        assert_eq!(short_admin_actor("abcdef0123456789"), "admin:****456789");
308    }
309
310    #[test]
311    fn short_admin_actor_handles_short_tokens() {
312        assert_eq!(short_admin_actor("abc"), "admin:****abc");
313    }
314}