Skip to main content

assay_auth/
gate.rs

1//! Authentication gate for engine module HTTP boundaries.
2//!
3//! [`require_admin_bearer`] is the canonical wire-boundary check: each
4//! engine module (auth-admin, vault, workflow) accepts ONLY a configured
5//! admin api-key bearer token. Per-user authentication and policy live
6//! upstream of the engine — a dashboard, BFF, or API gateway intermediates
7//! session/JWT identity, decides if the user is allowed, and then calls
8//! the engine using its own admin bearer. The engine itself does not
9//! resolve sessions or check zanzibar at request time.
10//!
11//! [`extract_caller`] / [`require_role`] / [`require_role_for`] remain
12//! available for callers that still need full session+JWT+zanzibar
13//! resolution — primarily the admin-zanzibar endpoints used BY the
14//! dashboard to make its own policy decisions. They are no longer used
15//! to gate engine routes.
16//!
17//! Used by:
18//!
19//! - [`crate::admin`] — admin endpoints (`require_admin_bearer`)
20//! - [`crate::oidc_provider::admin`] — OIDC provider admin
21//!   (`require_admin_bearer`)
22//! - `assay_engine::engine_api` — engine-core admin
23//!   (`require_admin_bearer`)
24//! - `assay_engine::server` — module routers wrapped with
25//!   [`admin_bearer_layer`]-equivalent middleware
26
27use axum::http::{HeaderMap, StatusCode, header};
28use axum::response::{IntoResponse, Json, Response};
29use serde_json::json;
30
31use crate::ctx::AuthCtx;
32use crate::state::AdminApiKeys;
33
34/// An authenticated caller, produced by [`extract_caller`].
35#[derive(Clone, Debug)]
36pub struct Caller {
37    /// Stable identifier for this caller. For session and JWT callers
38    /// this is the user's id. For admin api-key callers this is a
39    /// non-reversible token tail (e.g. `admin:****abc123`) safe to log.
40    pub user_id: String,
41    pub source: CallerSource,
42}
43
44/// Where the caller's identity proof came from.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum CallerSource {
47    /// `assay_session` cookie resolved via [`crate::store::SessionStore`].
48    SessionCookie,
49    /// `Authorization: Bearer <jwt>` verified against the configured
50    /// issuer's JWKS.
51    Jwt,
52    /// `Authorization: Bearer <key>` matched a configured admin
53    /// api-key. Break-glass — bypasses Zanzibar role checks.
54    AdminApiKey,
55}
56
57impl Caller {
58    /// `true` iff the caller authenticated via the admin api-key
59    /// fallback. Used by [`require_role`] to skip the Zanzibar lookup —
60    /// admin api-keys are operator-controlled break-glass and carry
61    /// universal authority by construction.
62    pub fn is_break_glass(&self) -> bool {
63        matches!(self.source, CallerSource::AdminApiKey)
64    }
65}
66
67/// Resolve a [`Caller`] from the request headers.
68///
69/// Resolution order is fixed:
70///
71/// 1. `Authorization: Bearer <token>` matches a configured admin
72///    api-key → [`CallerSource::AdminApiKey`] (break-glass).
73/// 2. `Cookie: assay_session=<id>` resolves to a live session →
74///    [`CallerSource::SessionCookie`].
75/// 3. `Authorization: Bearer <jwt>` parses + verifies →
76///    [`CallerSource::Jwt`].
77/// 4. Otherwise → `Err(401)`.
78///
79/// The error variant is a boxed `Response` so callers can just
80/// `return *r;` on failure without re-wrapping.
81pub async fn extract_caller(
82    headers: &HeaderMap,
83    #[cfg_attr(
84        not(any(feature = "auth-session", feature = "auth-jwt")),
85        allow(unused_variables)
86    )]
87    ctx: &AuthCtx,
88    keys: &AdminApiKeys,
89) -> Result<Caller, Box<Response>> {
90    // 1. Admin api-key — operator break-glass. Checked first so the
91    //    expensive session/JWT round-trips are skipped when an admin is
92    //    on the call.
93    if let Some(token) = bearer_token(headers)
94        && keys.enabled()
95        && keys.check(token)
96    {
97        return Ok(Caller {
98            user_id: short_admin_actor(token),
99            source: CallerSource::AdminApiKey,
100        });
101    }
102
103    // 2. Session cookie.
104    #[cfg(feature = "auth-session")]
105    if let Some(sid) = cookie_value(headers, crate::session::SESSION_COOKIE) {
106        let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
107        if let Ok(Some(s)) = mgr.resolve(&sid).await {
108            return Ok(Caller {
109                user_id: s.user_id,
110                source: CallerSource::SessionCookie,
111            });
112        }
113    }
114
115    // 3. JWT bearer.
116    #[cfg(feature = "auth-jwt")]
117    if let Some(token) = bearer_token(headers) {
118        #[derive(serde::Deserialize)]
119        struct SubClaim {
120            sub: String,
121        }
122
123        // 3a. Internal issuer first — fastest path, no network
124        //     dependency. Engine-issued sessions / OIDC-provider tokens
125        //     fall here.
126        if let Some(jwt) = ctx.jwt.as_ref()
127            && let Ok(td) = jwt.verify::<SubClaim>(token)
128        {
129            return Ok(Caller {
130                user_id: td.claims.sub,
131                source: CallerSource::Jwt,
132            });
133        }
134
135        // 3b. External issuers (Hydra, Keycloak, Auth0, …). Routed by
136        //     `iss` claim so we don't burn CPU verifying against
137        //     mismatched key sets.
138        if let Some(result) =
139            crate::external_jwt::verify_with_any::<SubClaim>(ctx.external_issuers(), token)
140            && let Ok(td) = result
141        {
142            return Ok(Caller {
143                user_id: td.claims.sub,
144                source: CallerSource::Jwt,
145            });
146        }
147    }
148
149    Err(unauthorized("authentication required"))
150}
151
152/// Enforce a coarse-grained Zanzibar role check on `caller`.
153///
154/// `(object_type, object_id)` identifies the resource and `permission`
155/// is the relation/permission name. `AdminApiKey` callers bypass.
156///
157/// Returns `Err(403)` on a denied check, `Err(500)` on a Zanzibar
158/// backend error. With `auth-zanzibar` disabled at compile time every
159/// non-break-glass caller fails closed with `403`.
160pub async fn require_role(
161    caller: &Caller,
162    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] ctx: &AuthCtx,
163    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] object_type: &str,
164    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] object_id: &str,
165    #[cfg_attr(not(feature = "auth-zanzibar"), allow(unused_variables))] permission: &str,
166) -> Result<(), Box<Response>> {
167    if caller.is_break_glass() {
168        return Ok(());
169    }
170    #[cfg(feature = "auth-zanzibar")]
171    {
172        use crate::zanzibar::{CheckResult, Consistency, ObjectRef, SubjectRef};
173        let Some(store) = ctx.zanzibar.as_ref() else {
174            // Zanzibar feature compiled in but not wired into AuthCtx —
175            // fail closed so a misconfigured boot doesn't silently
176            // grant access.
177            return Err(forbidden("zanzibar store not configured"));
178        };
179        let resource = ObjectRef {
180            object_type: object_type.to_string(),
181            object_id: object_id.to_string(),
182        };
183        let subject = SubjectRef {
184            subject_type: "user".to_string(),
185            subject_id: caller.user_id.clone(),
186            subject_rel: String::new(),
187        };
188        match store
189            .check(&resource, permission, &subject, Consistency::Minimum)
190            .await
191        {
192            Ok(CheckResult::Allowed { .. }) => Ok(()),
193            Ok(_) => Err(forbidden("permission denied")),
194            Err(e) => Err(internal(&format!("zanzibar check: {e}"))),
195        }
196    }
197    #[cfg(not(feature = "auth-zanzibar"))]
198    {
199        Err(forbidden("authorization not compiled in"))
200    }
201}
202
203/// Resolve caller + check role in one call. Returns the resolved
204/// caller on success so handlers that want it for audit logging can
205/// pluck it out.
206pub async fn require_role_for(
207    headers: &HeaderMap,
208    ctx: &AuthCtx,
209    keys: &AdminApiKeys,
210    object_type: &str,
211    object_id: &str,
212    permission: &str,
213) -> Result<Caller, Box<Response>> {
214    let caller = extract_caller(headers, ctx, keys).await?;
215    require_role(&caller, ctx, object_type, object_id, permission).await?;
216    Ok(caller)
217}
218
219/// Strict admin-bearer-only check.
220///
221/// Each engine module accepts only a configured admin api-key at its
222/// HTTP boundary. Per-user authentication and policy are the upstream
223/// consumer's concern. No session, no JWT, no zanzibar lookup — just
224/// `Authorization: Bearer <admin-key>` against the configured key list.
225///
226/// Returns `Err(401)` on absence or mismatch. Sync (no DB or network
227/// round-trip).
228pub fn require_admin_bearer(headers: &HeaderMap, keys: &AdminApiKeys) -> Result<(), Box<Response>> {
229    if let Some(token) = bearer_token(headers)
230        && keys.enabled()
231        && keys.check(token)
232    {
233        return Ok(());
234    }
235    Err(unauthorized("admin bearer token required"))
236}
237
238/// Resource-server check: accept either the operator admin api-key
239/// (service-to-service / break-glass) OR a JWT from a configured
240/// trusted issuer (per-user identity).
241///
242/// On success returns the resolved identity:
243///   * `Ok(None)` — admin api-key was used; no per-user identity.
244///   * `Ok(Some(sub))` — JWT was used; `sub` is the user identifier
245///     from the JWT's claims.
246///
247/// Used by engine module middleware to support both the service-to-
248/// service pattern (dashboard / BFF calls the engine with its bearer)
249/// AND the resource-server pattern (external client presents a JWT
250/// minted by any trusted IdP — assay-auth, Hydra, Auth0, …).
251///
252/// No zanzibar lookup. Policy still lives upstream — but per-user
253/// identity is available at the engine boundary when a JWT was used,
254/// for handlers that want it (audit, per-user features in Phase D).
255pub async fn require_admin_or_jwt(
256    headers: &HeaderMap,
257    #[cfg_attr(not(feature = "auth-jwt"), allow(unused_variables))] ctx: &AuthCtx,
258    keys: &AdminApiKeys,
259) -> Result<Option<String>, Box<Response>> {
260    // 1. Admin api-key — operator break-glass / service-to-service.
261    if let Some(token) = bearer_token(headers)
262        && keys.enabled()
263        && keys.check(token)
264    {
265        return Ok(None);
266    }
267
268    // 2. JWT bearer — per-user resource-server identity.
269    #[cfg(feature = "auth-jwt")]
270    if let Some(token) = bearer_token(headers) {
271        #[derive(serde::Deserialize)]
272        struct SubClaim {
273            sub: String,
274        }
275
276        // Internal issuer first (assay-auth's own JWTs) — fastest path.
277        if let Some(jwt) = ctx.jwt.as_ref()
278            && let Ok(td) = jwt.verify::<SubClaim>(token)
279        {
280            return Ok(Some(td.claims.sub));
281        }
282
283        // External trusted issuers (Hydra, Keycloak, Auth0, …).
284        if let Some(result) =
285            crate::external_jwt::verify_with_any::<SubClaim>(ctx.external_issuers(), token)
286            && let Ok(td) = result
287        {
288            return Ok(Some(td.claims.sub));
289        }
290    }
291
292    Err(unauthorized("admin bearer or trusted JWT required"))
293}
294
295// =====================================================================
296//   helpers
297// =====================================================================
298
299fn bearer_token(headers: &HeaderMap) -> Option<&str> {
300    headers
301        .get(header::AUTHORIZATION)
302        .and_then(|v| v.to_str().ok())
303        .and_then(|s| {
304            s.strip_prefix("Bearer ")
305                .or_else(|| s.strip_prefix("bearer "))
306        })
307        .map(str::trim)
308}
309
310#[cfg(feature = "auth-session")]
311fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
312    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
313    for kv in raw.split(';') {
314        let kv = kv.trim();
315        if let Some((k, v)) = kv.split_once('=')
316            && k == name
317        {
318            return Some(v.to_string());
319        }
320    }
321    None
322}
323
324fn short_admin_actor(token: &str) -> String {
325    let t = token.trim();
326    if t.len() <= 6 {
327        return format!("admin:****{t}");
328    }
329    let tail = &t[t.len() - 6..];
330    format!("admin:****{tail}")
331}
332
333fn unauthorized(msg: &str) -> Box<Response> {
334    Box::new(
335        (
336            StatusCode::UNAUTHORIZED,
337            Json(json!({"error": "unauthorized", "error_description": msg})),
338        )
339            .into_response(),
340    )
341}
342
343fn forbidden(msg: &str) -> Box<Response> {
344    Box::new(
345        (
346            StatusCode::FORBIDDEN,
347            Json(json!({"error": "forbidden", "error_description": msg})),
348        )
349            .into_response(),
350    )
351}
352
353fn internal(msg: &str) -> Box<Response> {
354    Box::new(
355        (
356            StatusCode::INTERNAL_SERVER_ERROR,
357            Json(json!({"error": "server_error", "error_description": msg})),
358        )
359            .into_response(),
360    )
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn caller_break_glass_only_for_api_key() {
369        let c = Caller {
370            user_id: "x".into(),
371            source: CallerSource::AdminApiKey,
372        };
373        assert!(c.is_break_glass());
374        let c = Caller {
375            user_id: "x".into(),
376            source: CallerSource::SessionCookie,
377        };
378        assert!(!c.is_break_glass());
379        let c = Caller {
380            user_id: "x".into(),
381            source: CallerSource::Jwt,
382        };
383        assert!(!c.is_break_glass());
384    }
385
386    #[test]
387    fn short_admin_actor_truncates_long_tokens() {
388        assert_eq!(short_admin_actor("abcdef0123456789"), "admin:****456789");
389    }
390
391    #[test]
392    fn short_admin_actor_handles_short_tokens() {
393        assert_eq!(short_admin_actor("abc"), "admin:****abc");
394    }
395}