1use 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#[derive(Clone, Debug)]
31pub struct Caller {
32 pub user_id: String,
36 pub source: CallerSource,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum CallerSource {
42 SessionCookie,
44 Jwt,
47 AdminApiKey,
50}
51
52impl Caller {
53 pub fn is_break_glass(&self) -> bool {
58 matches!(self.source, CallerSource::AdminApiKey)
59 }
60}
61
62pub 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 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 #[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 #[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 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 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
147pub 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 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
198pub 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
214fn 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}