Skip to main content

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}