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| 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}