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| s.strip_prefix("Bearer ").or_else(|| s.strip_prefix("bearer ")))
223        .map(str::trim)
224}
225
226#[cfg(feature = "auth-session")]
227fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
228    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
229    for kv in raw.split(';') {
230        let kv = kv.trim();
231        if let Some((k, v)) = kv.split_once('=')
232            && k == name
233        {
234            return Some(v.to_string());
235        }
236    }
237    None
238}
239
240fn short_admin_actor(token: &str) -> String {
241    let t = token.trim();
242    if t.len() <= 6 {
243        return format!("admin:****{t}");
244    }
245    let tail = &t[t.len() - 6..];
246    format!("admin:****{tail}")
247}
248
249fn unauthorized(msg: &str) -> Box<Response> {
250    Box::new(
251        (
252            StatusCode::UNAUTHORIZED,
253            Json(json!({"error": "unauthorized", "error_description": msg})),
254        )
255            .into_response(),
256    )
257}
258
259fn forbidden(msg: &str) -> Box<Response> {
260    Box::new(
261        (
262            StatusCode::FORBIDDEN,
263            Json(json!({"error": "forbidden", "error_description": msg})),
264        )
265            .into_response(),
266    )
267}
268
269fn internal(msg: &str) -> Box<Response> {
270    Box::new(
271        (
272            StatusCode::INTERNAL_SERVER_ERROR,
273            Json(json!({"error": "server_error", "error_description": msg})),
274        )
275            .into_response(),
276    )
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn caller_break_glass_only_for_api_key() {
285        let c = Caller {
286            user_id: "x".into(),
287            source: CallerSource::AdminApiKey,
288        };
289        assert!(c.is_break_glass());
290        let c = Caller {
291            user_id: "x".into(),
292            source: CallerSource::SessionCookie,
293        };
294        assert!(!c.is_break_glass());
295        let c = Caller {
296            user_id: "x".into(),
297            source: CallerSource::Jwt,
298        };
299        assert!(!c.is_break_glass());
300    }
301
302    #[test]
303    fn short_admin_actor_truncates_long_tokens() {
304        assert_eq!(short_admin_actor("abcdef0123456789"), "admin:****456789");
305    }
306
307    #[test]
308    fn short_admin_actor_handles_short_tokens() {
309        assert_eq!(short_admin_actor("abc"), "admin:****abc");
310    }
311}