aa_auth/lib.rs
1//! Shared HTTP authentication and authorization framework for Agent Assembly.
2//!
3//! This leaf crate holds the transport-agnostic auth primitives that the API
4//! presentation layer (`aa-api`) — and, in a follow-up, the gateway — build on:
5//! API-key and JWT credential validation, scope levels, per-key rate limiting,
6//! and the deny-by-default authentication gate. It depends only on `axum`,
7//! `http`, `serde`, and the credential primitives, never on `aa-core`,
8//! `aa-gateway`, `aa-runtime`, or `aa-api`, so it stays a true leaf.
9//!
10//! Auth is handled via Axum `FromRequestParts` extractors, not middleware
11//! layers. The [`AuthenticatedCaller`] extractor validates API keys or JWTs
12//! and enforces per-key rate limits. [`scope::RequireScope`] checks scope levels.
13
14pub mod api_key;
15pub mod config;
16pub mod gate;
17pub mod jwt;
18pub mod rate_limit;
19pub mod scope;
20
21mod error;
22pub use error::ProblemDetail;
23
24use std::sync::Arc;
25
26use axum::extract::FromRequestParts;
27use axum::http::request::Parts;
28use axum::http::StatusCode;
29use axum::response::{IntoResponse, Response};
30
31use self::api_key::{ApiKeyStore, KeyNotValid};
32use self::config::{AuthConfig, AuthMode};
33use self::jwt::JwtVerifier;
34use self::rate_limit::RateLimiter;
35use self::scope::Scope;
36
37/// Authentication / authorization errors returned by extractors.
38#[derive(Debug)]
39pub enum AuthError {
40 /// No `Authorization` header was present.
41 MissingHeader,
42 /// The token could not be validated (bad format, wrong signature, etc.).
43 InvalidToken(String),
44 /// The token signature was valid but the token has expired.
45 ExpiredToken,
46 /// The caller has exceeded the per-key rate limit.
47 RateLimited {
48 /// Seconds until the next request may be accepted.
49 retry_after_secs: u64,
50 },
51 /// The caller's scopes do not satisfy the required scope.
52 InsufficientScope {
53 /// The scope level that was required.
54 required: Scope,
55 },
56}
57
58impl IntoResponse for AuthError {
59 fn into_response(self) -> Response {
60 match self {
61 AuthError::MissingHeader => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
62 .with_detail("Missing Authorization header")
63 .into_response(),
64
65 AuthError::InvalidToken(reason) => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
66 .with_detail(format!("Invalid token: {reason}"))
67 .into_response(),
68
69 AuthError::ExpiredToken => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
70 .with_detail("Token has expired")
71 .into_response(),
72
73 AuthError::RateLimited { retry_after_secs } => {
74 let problem = ProblemDetail::from_status(StatusCode::TOO_MANY_REQUESTS)
75 .with_detail(format!("Rate limit exceeded. Retry after {retry_after_secs} seconds"));
76 let mut response = problem.into_response();
77 response.headers_mut().insert(
78 "retry-after",
79 retry_after_secs
80 .to_string()
81 .parse()
82 .expect("integer is valid header value"),
83 );
84 response
85 }
86
87 AuthError::InsufficientScope { required } => ProblemDetail::from_status(StatusCode::FORBIDDEN)
88 .with_detail(format!("Insufficient scope: requires '{required}'"))
89 .into_response(),
90 }
91 }
92}
93
94/// The authenticated identity of a request caller.
95///
96/// The tenant a caller is scoped to (AAASM-3139).
97///
98/// A caller with a `team_id` (or `org_id`) is confined to that tenant for
99/// per-tenant data endpoints. An empty `Tenant` (both `None`) means "no tenant
100/// scope" — such a caller can only see cross-tenant data if it also holds
101/// `Scope::Admin`. The synthetic bypass-mode caller and admin callers are not
102/// confined by tenant.
103#[derive(Debug, Clone, Default)]
104pub struct Tenant {
105 /// The team this caller is scoped to, if any.
106 pub team_id: Option<String>,
107 /// The org this caller is scoped to, if any.
108 pub org_id: Option<String>,
109}
110
111/// Populated by the `FromRequestParts` implementation, which validates
112/// either an API key (`aa_…`) or a JWT bearer token.
113#[derive(Debug, Clone)]
114pub struct AuthenticatedCaller {
115 /// The API key ID or JWT subject that identifies this caller.
116 pub key_id: String,
117 /// Scopes granted to this caller.
118 pub scopes: Vec<Scope>,
119 /// The tenant this caller is confined to for per-tenant data (AAASM-3139).
120 pub tenant: Tenant,
121}
122
123impl AuthenticatedCaller {
124 /// Whether this caller may see data for `team` without a separate admin gate.
125 ///
126 /// AAASM-3139: an admin sees every tenant; a tenant-scoped caller sees only
127 /// its own team. A caller with no team scope (and no admin) sees no
128 /// per-tenant data.
129 pub fn can_access_team(&self, team: &str) -> bool {
130 if self.scopes.contains(&Scope::Admin) {
131 return true;
132 }
133 self.tenant.team_id.as_deref() == Some(team)
134 }
135
136 /// Whether this caller may see data for `org` without a separate admin gate.
137 ///
138 /// AAASM-3483: the org-tier analogue of [`Self::can_access_team`], used by
139 /// the topology and audit-log surfaces. An admin sees every org; a
140 /// tenant-scoped caller sees only its own org. A caller with no org scope
141 /// (and no admin) sees no per-org data.
142 pub fn can_access_org(&self, org: &str) -> bool {
143 if self.scopes.contains(&Scope::Admin) {
144 return true;
145 }
146 self.tenant.org_id.as_deref() == Some(org)
147 }
148
149 /// The verified tenant org that scopes this caller's storage access, if any.
150 ///
151 /// AAASM-3596: this is the single, honest source for the `app.tenant_id` GUC
152 /// the storage layer's Row-Level Security filters on (AAASM-3595). It is
153 /// taken *only* from [`Self::tenant`] — the verified JWT `org_id` claim or
154 /// the authenticated API-key entry — and never from a request `Query`,
155 /// header, or body. A client cannot redirect it by sending a different
156 /// `?org_id` / `X-Org-Id`, because nothing here reads request input.
157 ///
158 /// Returns `None` for a caller with no tenant scope (an admin / cross-tenant
159 /// caller). Such a caller must run the storage connection with NO
160 /// `app.tenant_id` GUC — i.e. via a dedicated RLS-bypass admin DB role — and
161 /// must *never* synthesize a tenant from a client-chosen value. Feeding a
162 /// `None` here into the GUC seam yields a fail-closed (zero-row) connection,
163 /// not a full-table read.
164 pub fn storage_tenant_org(&self) -> Option<&str> {
165 self.tenant.org_id.as_deref()
166 }
167}
168
169/// Prefix used by API keys (`aa_`).
170const API_KEY_PREFIX: &str = "aa_";
171
172impl<S> FromRequestParts<S> for AuthenticatedCaller
173where
174 S: Send + Sync,
175{
176 type Rejection = AuthError;
177
178 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
179 // 1. Read auth config from extensions.
180 let auth_config = parts
181 .extensions
182 .get::<Arc<AuthConfig>>()
183 .expect("AuthConfig extension missing — did you forget to add it in build_app?");
184
185 // Bypass mode: return synthetic admin caller.
186 if auth_config.mode == AuthMode::Off {
187 return Ok(AuthenticatedCaller {
188 key_id: "__bypass__".to_string(),
189 scopes: vec![Scope::Read, Scope::Write, Scope::Admin],
190 // Bypass mode is admin — not confined to any tenant.
191 tenant: Tenant::default(),
192 });
193 }
194
195 // 2. Parse `Authorization: Bearer <token>` header.
196 let header_value = parts
197 .headers
198 .get(axum::http::header::AUTHORIZATION)
199 .and_then(|v| v.to_str().ok())
200 .ok_or(AuthError::MissingHeader)?;
201
202 let token = header_value
203 .strip_prefix("Bearer ")
204 .ok_or_else(|| AuthError::InvalidToken("expected 'Bearer <token>' format".into()))?;
205
206 // 3. Determine credential type and validate.
207 let caller = if token.starts_with(API_KEY_PREFIX) {
208 // API key path.
209 let key_store = parts
210 .extensions
211 .get::<Arc<ApiKeyStore>>()
212 .expect("ApiKeyStore extension missing");
213
214 let entry = match key_store.validate_detailed(token) {
215 Ok(e) => e,
216 Err(KeyNotValid::Revoked) => {
217 return Err(AuthError::InvalidToken("revoked API key".into()));
218 }
219 Err(KeyNotValid::NotFound) => {
220 return Err(AuthError::InvalidToken("invalid API key".into()));
221 }
222 };
223
224 AuthenticatedCaller {
225 key_id: entry.id.clone(),
226 scopes: entry.scopes.clone(),
227 tenant: Tenant {
228 team_id: entry.team_id.clone(),
229 org_id: entry.org_id.clone(),
230 },
231 }
232 } else {
233 // JWT path.
234 let jwt_verifier = parts
235 .extensions
236 .get::<Arc<JwtVerifier>>()
237 .expect("JwtVerifier extension missing");
238
239 let claims = jwt_verifier.verify(token).map_err(|e| {
240 let msg = e.to_string();
241 if msg.contains("ExpiredSignature") {
242 AuthError::ExpiredToken
243 } else {
244 AuthError::InvalidToken(msg)
245 }
246 })?;
247
248 AuthenticatedCaller {
249 key_id: claims.sub,
250 scopes: claims.scope,
251 tenant: Tenant {
252 team_id: claims.team_id,
253 org_id: claims.org_id,
254 },
255 }
256 };
257
258 // 4. Check rate limit.
259 let rate_limiter = parts
260 .extensions
261 .get::<Arc<RateLimiter>>()
262 .expect("RateLimiter extension missing");
263
264 rate_limiter
265 .check(&caller.key_id)
266 .map_err(|retry_after_secs| AuthError::RateLimited { retry_after_secs })?;
267
268 Ok(caller)
269 }
270}
271
272#[cfg(test)]
273mod tenant_guard_tests {
274 use super::*;
275
276 fn caller_with_org(org: Option<&str>, scopes: Vec<Scope>) -> AuthenticatedCaller {
277 AuthenticatedCaller {
278 key_id: "key-1".to_string(),
279 scopes,
280 tenant: Tenant {
281 team_id: None,
282 org_id: org.map(str::to_string),
283 },
284 }
285 }
286
287 /// AAASM-3596: the value feeding the storage `app.tenant_id` GUC is the
288 /// caller's verified org and nothing else.
289 #[test]
290 fn storage_tenant_org_is_the_verified_org() {
291 let caller = caller_with_org(Some("org-verified"), vec![Scope::Read]);
292 assert_eq!(caller.storage_tenant_org(), Some("org-verified"));
293 }
294
295 /// AAASM-3596 (the spoof case): a request might carry any `?org_id` /
296 /// `X-Org-Id`, but `storage_tenant_org` reads none of that — it only ever
297 /// returns the verified tenant, so a client-supplied org cannot redirect or
298 /// widen a caller's storage scope.
299 #[test]
300 fn client_supplied_org_cannot_redirect_storage_scope() {
301 // The caller is verified as org-A. A spoofed request body/header asking
302 // for "org-victim" is irrelevant: the seam never consults request input.
303 let caller = caller_with_org(Some("org-A"), vec![Scope::Read]);
304 let spoofed_client_org = "org-victim";
305 assert_ne!(
306 caller.storage_tenant_org(),
307 Some(spoofed_client_org),
308 "a client-chosen org must not become the storage tenant"
309 );
310 assert_eq!(
311 caller.storage_tenant_org(),
312 Some("org-A"),
313 "the storage tenant is provably the verified org only"
314 );
315 }
316
317 /// A caller with no verified tenant scope yields None — which the storage
318 /// GUC seam maps to a fail-closed (zero-row) connection, never a synthesized
319 /// client-chosen tenant.
320 #[test]
321 fn no_tenant_scope_yields_none_not_a_client_value() {
322 let caller = caller_with_org(None, vec![Scope::Read, Scope::Admin]);
323 assert_eq!(caller.storage_tenant_org(), None);
324 }
325}