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}