mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -23,7 +23,7 @@ pub(crate) fn extract_requested_model(
|
||||
body: &Bytes,
|
||||
) -> Option<String> {
|
||||
if decision.route_family.as_deref() == Some("gemini") {
|
||||
if let Some(model) = extract_gemini_model_from_path(uri.path()) {
|
||||
if let Some(model) = extract_gemini_requested_model_from_path(uri.path()) {
|
||||
return Some(model);
|
||||
}
|
||||
}
|
||||
@@ -43,23 +43,46 @@ pub(crate) fn extract_requested_model(
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn extract_gemini_requested_model_from_path(path: &str) -> Option<String> {
|
||||
let model = extract_gemini_model_from_path(path)?;
|
||||
Some(
|
||||
model
|
||||
.split_once("/operations/")
|
||||
.map(|(model, _)| model)
|
||||
.unwrap_or(model.as_str())
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn extract_request_credentials(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: &str,
|
||||
) -> GatewayExtractedCredentials {
|
||||
extract_request_credentials_with_trusted_auth(headers, uri, auth_endpoint_signature, cfg!(test))
|
||||
}
|
||||
|
||||
pub(super) fn extract_request_credentials_with_trusted_auth(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: &str,
|
||||
trusted_auth_verified: bool,
|
||||
) -> GatewayExtractedCredentials {
|
||||
let bundle = GatewayCredentialBundle {
|
||||
authorization_bearer: header_value_str(headers, http::header::AUTHORIZATION.as_str())
|
||||
.as_deref()
|
||||
.and_then(extract_bearer_token)
|
||||
.map(ToOwned::to_owned),
|
||||
authorization_bearer: unique_header_value_str(
|
||||
headers,
|
||||
http::header::AUTHORIZATION.as_str(),
|
||||
)
|
||||
.as_deref()
|
||||
.and_then(extract_bearer_token)
|
||||
.map(ToOwned::to_owned),
|
||||
x_api_key: header_value_str(headers, "x-api-key"),
|
||||
api_key: header_value_str(headers, "api-key"),
|
||||
x_goog_api_key: header_value_str(headers, "x-goog-api-key"),
|
||||
query_key: extract_query_api_key(uri),
|
||||
cookie_header: header_value_str(headers, http::header::COOKIE.as_str()),
|
||||
};
|
||||
let trusted_headers = extract_trusted_auth_headers(headers);
|
||||
let trusted_headers = extract_trusted_auth_headers(headers, trusted_auth_verified);
|
||||
let trusted_admin_headers = extract_trusted_admin_headers(headers);
|
||||
let primary = select_primary_credential(auth_endpoint_signature, &bundle);
|
||||
|
||||
@@ -71,6 +94,20 @@ pub(super) fn extract_request_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_header_value_str(headers: &http::HeaderMap, key: &str) -> Option<String> {
|
||||
let mut values = headers.get_all(key).iter();
|
||||
let value = values.next()?;
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(in crate::control) fn resolve_gateway_credential_carrier(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
@@ -85,6 +122,7 @@ pub(in crate::control) fn resolve_gateway_credential_carrier(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn has_trusted_gateway_marker(headers: &http::HeaderMap) -> bool {
|
||||
header_value_str(headers, crate::constants::GATEWAY_HEADER)
|
||||
.unwrap_or_default()
|
||||
@@ -97,13 +135,32 @@ pub(super) fn build_auth_context_cache_key(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: &str,
|
||||
) -> Option<String> {
|
||||
build_auth_context_cache_key_with_trusted_auth(
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
cfg!(test),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_auth_context_cache_key_with_trusted_auth(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: &str,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Option<String> {
|
||||
let signature = auth_endpoint_signature.trim();
|
||||
if signature.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let extracted = extract_request_credentials(headers, uri, signature);
|
||||
let extracted = extract_request_credentials_with_trusted_auth(
|
||||
headers,
|
||||
uri,
|
||||
signature,
|
||||
trusted_auth_verified,
|
||||
);
|
||||
let trusted_headers = extracted.trusted_headers;
|
||||
let bundle = extracted.bundle;
|
||||
if bundle.authorization_bearer.is_none()
|
||||
@@ -135,7 +192,7 @@ pub(super) fn build_auth_context_cache_key(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(format!(
|
||||
let raw_cache_identity = format!(
|
||||
"{signature}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||
bundle.authorization_bearer.unwrap_or_default(),
|
||||
bundle.x_api_key.unwrap_or_default(),
|
||||
@@ -147,11 +204,26 @@ pub(super) fn build_auth_context_cache_key(
|
||||
trusted_api_key_id,
|
||||
trusted_balance_remaining,
|
||||
trusted_access_allowed,
|
||||
))
|
||||
);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw_cache_identity.as_bytes());
|
||||
Some(format!("auth-context:sha256:{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn extract_trusted_auth_headers(headers: &http::HeaderMap) -> Option<GatewayTrustedAuthHeaders> {
|
||||
if !has_trusted_gateway_marker(headers) {
|
||||
fn extract_trusted_auth_headers(
|
||||
headers: &http::HeaderMap,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Option<GatewayTrustedAuthHeaders> {
|
||||
if !trusted_auth_verified {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if !header_value_str(headers, crate::constants::GATEWAY_HEADER)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("rust-phase3")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let user_id = header_value_str(headers, crate::constants::TRUSTED_AUTH_USER_ID_HEADER)
|
||||
@@ -387,7 +459,7 @@ fn extract_bearer_token(value: &str) -> Option<&str> {
|
||||
return None;
|
||||
}
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
if token.is_empty() || token.chars().any(char::is_whitespace) {
|
||||
None
|
||||
} else {
|
||||
Some(token)
|
||||
@@ -472,6 +544,46 @@ mod tests {
|
||||
assert_eq!(requested_model.as_deref(), Some("gpt-5.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_requested_model_handles_gemini_generation_and_operation_paths() {
|
||||
let generation_decision = GatewayControlDecision::synthetic(
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
Some("ai_public".to_string()),
|
||||
Some("gemini".to_string()),
|
||||
Some("generate_content".to_string()),
|
||||
Some("gemini:generate_content".to_string()),
|
||||
);
|
||||
let operation_decision = GatewayControlDecision::synthetic(
|
||||
"/v1beta/models/veo-3/operations/task-123:cancel",
|
||||
Some("ai_public".to_string()),
|
||||
Some("gemini".to_string()),
|
||||
Some("video".to_string()),
|
||||
Some("gemini:video".to_string()),
|
||||
);
|
||||
let headers = http::HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
extract_requested_model(
|
||||
&generation_decision,
|
||||
&uri("/v1beta/models/gemini-2.5-pro:generateContent"),
|
||||
&headers,
|
||||
&Bytes::new(),
|
||||
)
|
||||
.as_deref(),
|
||||
Some("gemini-2.5-pro")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_requested_model(
|
||||
&operation_decision,
|
||||
&uri("/v1beta/models/veo-3/operations/task-123:cancel"),
|
||||
&headers,
|
||||
&Bytes::new(),
|
||||
)
|
||||
.as_deref(),
|
||||
Some("veo-3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_openai_bearer_as_provider_api_key() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
@@ -491,6 +603,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_or_combined_authorization_credentials() {
|
||||
let mut duplicate = http::HeaderMap::new();
|
||||
duplicate.append(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer first-token".parse().unwrap(),
|
||||
);
|
||||
duplicate.append(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer second-token".parse().unwrap(),
|
||||
);
|
||||
let extracted =
|
||||
extract_request_credentials(&duplicate, &uri("/api/admin/system"), "admin:operational");
|
||||
assert!(extracted.bundle.authorization_bearer.is_none());
|
||||
assert!(extracted.primary.is_none());
|
||||
|
||||
let mut combined = http::HeaderMap::new();
|
||||
combined.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer first-token, Bearer second-token".parse().unwrap(),
|
||||
);
|
||||
let extracted =
|
||||
extract_request_credentials(&combined, &uri("/api/admin/system"), "admin:operational");
|
||||
assert!(extracted.bundle.authorization_bearer.is_none());
|
||||
assert!(extracted.primary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_codex_live_bearer_as_provider_api_key() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
@@ -608,7 +747,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_includes_cookie_header() {
|
||||
fn cache_key_hashes_cookie_header_instead_of_retaining_session_secret() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::COOKIE, "session=abc123".parse().unwrap());
|
||||
|
||||
@@ -618,7 +757,8 @@ mod tests {
|
||||
"internal:session",
|
||||
)
|
||||
.expect("cache key should exist");
|
||||
assert!(cache_key.contains("session=abc123"));
|
||||
assert!(cache_key.starts_with("auth-context:sha256:"));
|
||||
assert!(!cache_key.contains("session=abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -669,12 +809,10 @@ mod tests {
|
||||
.expect("trusted cache key should exist");
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert!(first.contains("user-1"));
|
||||
assert!(first.contains("key-1"));
|
||||
assert!(first.contains("1.5"));
|
||||
assert!(first.contains("true"));
|
||||
assert!(second.contains("user-2"));
|
||||
assert!(second.contains("false"));
|
||||
for raw_identity in ["user-1", "key-1", "1.5", "user-2"] {
|
||||
assert!(!first.contains(raw_identity));
|
||||
assert!(!second.contains(raw_identity));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -224,7 +224,7 @@ fn wallet_finite_available_usd(
|
||||
Some(wallet.balance.max(0.0) + wallet.gift_balance.max(0.0))
|
||||
}
|
||||
|
||||
async fn estimate_execution_plan_cost_upper_bound_usd(
|
||||
pub(crate) async fn estimate_execution_plan_cost_upper_bound_usd(
|
||||
state: &AppState,
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
@@ -925,6 +925,7 @@ mod tests {
|
||||
local_rejection: None,
|
||||
allowed_models: Some(allowed_models),
|
||||
ip_rules: None,
|
||||
verified_api_key_hash: None,
|
||||
});
|
||||
decision
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@ mod types;
|
||||
pub(crate) use credentials::extract_requested_model;
|
||||
pub(super) use credentials::resolve_gateway_credential_carrier;
|
||||
pub(crate) use gate::{
|
||||
execution_plan_balance_capacity_rejection, request_model_local_rejection,
|
||||
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayLocalAuthRejection,
|
||||
estimate_execution_plan_cost_upper_bound_usd, execution_plan_balance_capacity_rejection,
|
||||
request_model_local_rejection, should_buffer_request_for_local_auth,
|
||||
trusted_auth_local_rejection, GatewayLocalAuthRejection,
|
||||
};
|
||||
pub(crate) use resolution::{
|
||||
refresh_execution_runtime_auth_context, refresh_execution_runtime_auth_context_with_snapshot,
|
||||
resolve_execution_runtime_auth_context, GatewayAdminPrincipalContext,
|
||||
GatewayControlAuthContext,
|
||||
resolve_execution_runtime_auth_context, resolve_local_admin_session_principal,
|
||||
GatewayAdminPrincipalContext, GatewayControlAuthContext,
|
||||
};
|
||||
pub(super) use resolution::{
|
||||
resolve_control_decision_auth_with_trusted_auth, ControlDecisionAuthResolution,
|
||||
};
|
||||
pub(super) use resolution::{resolve_control_decision_auth, ControlDecisionAuthResolution};
|
||||
pub(crate) use types::GatewayCredentialCarrier;
|
||||
|
||||
@@ -4,11 +4,9 @@ use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use axum::http::Uri;
|
||||
use base64::Engine as _;
|
||||
use hmac::Mac;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::wallet_runtime::{
|
||||
local_rejection_from_wallet_access, resolve_wallet_auth_gate_uncached,
|
||||
@@ -17,7 +15,8 @@ use crate::{AppState, GatewayError};
|
||||
|
||||
use super::super::GatewayControlDecision;
|
||||
use super::credentials::{
|
||||
build_auth_context_cache_key, current_unix_secs, extract_request_credentials,
|
||||
build_auth_context_cache_key, build_auth_context_cache_key_with_trusted_auth,
|
||||
current_unix_secs, extract_request_credentials, extract_request_credentials_with_trusted_auth,
|
||||
extract_trusted_admin_headers, hash_api_key,
|
||||
};
|
||||
use super::gate::GatewayLocalAuthRejection;
|
||||
@@ -27,6 +26,9 @@ use super::types::{
|
||||
};
|
||||
use crate::cache::{AuthContextCacheGeneration, AuthContextInflightRegistration};
|
||||
use crate::headers::header_value_str;
|
||||
use crate::local_auth_token::{
|
||||
decode_local_auth_token, local_auth_token_identity_matches_user, LocalAuthTokenType,
|
||||
};
|
||||
|
||||
const AUTH_CONTEXT_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const AUTH_CONTEXT_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
|
||||
@@ -93,6 +95,30 @@ pub(crate) struct GatewayControlAuthContext {
|
||||
pub(crate) allowed_models: Option<Vec<String>>,
|
||||
#[serde(skip)]
|
||||
pub(crate) ip_rules: Option<Vec<String>>,
|
||||
/// Credential verifier that established this API-key identity. Long-lived
|
||||
/// executions use it to prove that a later row with the same IDs is still
|
||||
/// the record authenticated by the original request.
|
||||
#[serde(skip)]
|
||||
pub(crate) verified_api_key_hash: Option<VerifiedApiKeyHash>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct VerifiedApiKeyHash(String);
|
||||
|
||||
impl VerifiedApiKeyHash {
|
||||
fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VerifiedApiKeyHash {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("VerifiedApiKeyHash([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -109,11 +135,30 @@ pub(in super::super) enum ControlDecisionAuthResolution {
|
||||
}
|
||||
|
||||
pub(in super::super) async fn resolve_control_decision_auth(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
trace_id: &str,
|
||||
decision: GatewayControlDecision,
|
||||
) -> Result<ControlDecisionAuthResolution, GatewayError> {
|
||||
resolve_control_decision_auth_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
trace_id,
|
||||
decision,
|
||||
cfg!(test),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in super::super) async fn resolve_control_decision_auth_with_trusted_auth(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
trace_id: &str,
|
||||
mut decision: GatewayControlDecision,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Result<ControlDecisionAuthResolution, GatewayError> {
|
||||
if let Some(admin_principal) =
|
||||
resolve_trusted_admin_principal(headers, decision.auth_endpoint_signature.as_deref())
|
||||
@@ -132,10 +177,18 @@ pub(in super::super) async fn resolve_control_decision_auth(
|
||||
decision.admin_principal = Some(admin_principal);
|
||||
}
|
||||
|
||||
let auth_context_cache_key = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.and_then(|signature| build_auth_context_cache_key(headers, uri, signature));
|
||||
let auth_context_cache_key =
|
||||
decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.and_then(|signature| {
|
||||
build_auth_context_cache_key_with_trusted_auth(
|
||||
headers,
|
||||
uri,
|
||||
signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
});
|
||||
|
||||
let mut resolved_auth_context = None;
|
||||
if let Some(cache_key) = auth_context_cache_key.as_deref() {
|
||||
@@ -149,6 +202,7 @@ pub(in super::super) async fn resolve_control_decision_auth(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
headers,
|
||||
uri,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@@ -168,6 +222,7 @@ pub(in super::super) async fn resolve_control_decision_auth(
|
||||
uri,
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
true,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -336,7 +391,7 @@ async fn resolve_local_admin_principal(
|
||||
let Some(access_token) = extracted.bundle.authorization_bearer.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let claims = match decode_local_auth_token(access_token, "access") {
|
||||
let claims = match decode_local_auth_token(access_token, LocalAuthTokenType::Access) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
@@ -351,6 +406,14 @@ async fn resolve_local_admin_principal(
|
||||
resolve_local_admin_principal_from_claims(state, headers, uri, &claims).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_local_admin_session_principal(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
) -> Result<Option<GatewayAdminPrincipalContext>, GatewayError> {
|
||||
resolve_local_admin_principal(state, headers, uri, Some("admin:operational")).await
|
||||
}
|
||||
|
||||
async fn resolve_local_admin_principal_from_claims(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
@@ -373,6 +436,9 @@ async fn resolve_local_admin_principal_from_claims(
|
||||
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !local_auth_token_identity_matches_user(claims, &user) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let Some(session) = state.find_user_session(user_id, session_id).await? else {
|
||||
@@ -380,6 +446,7 @@ async fn resolve_local_admin_principal_from_claims(
|
||||
};
|
||||
if session.is_revoked()
|
||||
|| session.is_expired(now)
|
||||
|| session.security_version != user.security_version
|
||||
|| session.client_device_id != client_device_id
|
||||
{
|
||||
return Ok(None);
|
||||
@@ -431,68 +498,6 @@ fn local_admin_user_agent(headers: &http::HeaderMap) -> Option<String> {
|
||||
.map(|value| value.chars().take(1000).collect())
|
||||
}
|
||||
|
||||
fn local_auth_secret() -> String {
|
||||
std::env::var("JWT_SECRET_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "aether-rust-dev-jwt-secret".to_string())
|
||||
}
|
||||
|
||||
fn decode_local_auth_token(
|
||||
token: &str,
|
||||
expected_type: &str,
|
||||
) -> Result<serde_json::Map<String, Value>, String> {
|
||||
let mut parts = token.split('.');
|
||||
let Some(header_segment) = parts.next() else {
|
||||
return Err("invalid token".to_string());
|
||||
};
|
||||
let Some(payload_segment) = parts.next() else {
|
||||
return Err("invalid token".to_string());
|
||||
};
|
||||
let Some(signature_segment) = parts.next() else {
|
||||
return Err("invalid token".to_string());
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err("invalid token".to_string());
|
||||
}
|
||||
|
||||
let signing_input = format!("{header_segment}.{payload_segment}");
|
||||
let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(signature_segment)
|
||||
.map_err(|_| "invalid token".to_string())?;
|
||||
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(local_auth_secret().as_bytes())
|
||||
.map_err(|_| "invalid token".to_string())?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
mac.verify_slice(&signature)
|
||||
.map_err(|_| "invalid token".to_string())?;
|
||||
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_segment)
|
||||
.map_err(|_| "invalid token".to_string())?;
|
||||
let payload =
|
||||
serde_json::from_slice::<Value>(&payload_bytes).map_err(|_| "invalid token".to_string())?;
|
||||
let payload = payload
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| "invalid token".to_string())?;
|
||||
let actual_type = payload
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if actual_type != expected_type {
|
||||
return Err("invalid token".to_string());
|
||||
}
|
||||
let exp = payload
|
||||
.get("exp")
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| "invalid token".to_string())?;
|
||||
if exp <= chrono::Utc::now().timestamp() {
|
||||
return Err("expired token".to_string());
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
@@ -525,6 +530,7 @@ pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
Some(auth_endpoint_signature),
|
||||
headers,
|
||||
uri,
|
||||
cfg!(test),
|
||||
)
|
||||
.await
|
||||
.map(Some);
|
||||
@@ -539,6 +545,7 @@ pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
uri,
|
||||
Some(auth_endpoint_signature),
|
||||
true,
|
||||
cfg!(test),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -558,6 +565,7 @@ async fn revalidate_cached_auth_context(
|
||||
auth_endpoint_signature: Option<&str>,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Result<GatewayControlAuthContext, GatewayError> {
|
||||
if is_negative_auth_context(&auth_context)
|
||||
|| !auth_context.access_allowed
|
||||
@@ -581,6 +589,7 @@ async fn revalidate_cached_auth_context(
|
||||
uri,
|
||||
auth_context.clone(),
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -622,6 +631,7 @@ async fn revalidate_cached_auth_context(
|
||||
uri,
|
||||
auth_context,
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await;
|
||||
if refreshed.is_err() {
|
||||
@@ -639,9 +649,16 @@ async fn resolve_security_fresh_auth_context(
|
||||
uri: &Uri,
|
||||
stale: GatewayControlAuthContext,
|
||||
auth_endpoint_signature: Option<&str>,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Result<GatewayControlAuthContext, GatewayError> {
|
||||
if let Some(refreshed) =
|
||||
resolve_data_backed_auth_context(state, headers, uri, auth_endpoint_signature).await?
|
||||
if let Some(refreshed) = resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(refreshed);
|
||||
}
|
||||
@@ -660,19 +677,27 @@ async fn resolve_data_backed_auth_context_cached(
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: Option<&str>,
|
||||
cache_negative: bool,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Result<Option<GatewayControlAuthContext>, GatewayError> {
|
||||
let Some(cache_key) = cache_key else {
|
||||
return resolve_data_backed_auth_context(state, headers, uri, auth_endpoint_signature)
|
||||
.await;
|
||||
return resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
loop {
|
||||
match state.auth_context_cache.register_inflight(cache_key) {
|
||||
AuthContextInflightRegistration::Leader(guard) => {
|
||||
let resolved = match resolve_data_backed_auth_context(
|
||||
let resolved = match resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -708,11 +733,12 @@ async fn resolve_data_backed_auth_context_cached(
|
||||
}
|
||||
}
|
||||
AuthContextInflightRegistration::Bypass => {
|
||||
return resolve_data_backed_auth_context(
|
||||
return resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
trusted_auth_verified,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -768,28 +794,39 @@ pub(crate) async fn refresh_execution_runtime_auth_context_with_snapshot(
|
||||
return Ok((auth_context, None));
|
||||
}
|
||||
|
||||
let verified_api_key_hash = auth_context.verified_api_key_hash.clone();
|
||||
let snapshot = {
|
||||
let _permit = state.acquire_auth_snapshot_load_gate().await?;
|
||||
state
|
||||
.data
|
||||
.read_auth_api_key_snapshot_strong(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
if let Some(key_hash) = verified_api_key_hash.as_ref() {
|
||||
state
|
||||
.data
|
||||
.read_auth_api_key_snapshot_by_key_hash_strong(
|
||||
key_hash.as_str(),
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
} else {
|
||||
state
|
||||
.data
|
||||
.read_auth_api_key_snapshot_strong(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
}
|
||||
};
|
||||
let Some(snapshot) = snapshot else {
|
||||
let mut denied = auth_context;
|
||||
denied.access_allowed = false;
|
||||
denied.local_rejection = Some(GatewayLocalAuthRejection::InvalidApiKey);
|
||||
denied.balance_remaining = None;
|
||||
return Ok((denied, None));
|
||||
return Ok((deny_refreshed_auth_context(auth_context), None));
|
||||
};
|
||||
if snapshot.user_id != auth_context.user_id || snapshot.api_key_id != auth_context.api_key_id {
|
||||
return Ok((deny_refreshed_auth_context(auth_context), None));
|
||||
};
|
||||
|
||||
let wallet_access = resolve_wallet_auth_gate_uncached(state, &snapshot).await?;
|
||||
let refreshed = build_data_backed_auth_context(
|
||||
let mut refreshed = build_data_backed_auth_context(
|
||||
state,
|
||||
snapshot.clone(),
|
||||
auth_endpoint_signature,
|
||||
@@ -798,9 +835,19 @@ pub(crate) async fn refresh_execution_runtime_auth_context_with_snapshot(
|
||||
wallet_access,
|
||||
)
|
||||
.await;
|
||||
refreshed.verified_api_key_hash = verified_api_key_hash;
|
||||
Ok((refreshed, Some(snapshot)))
|
||||
}
|
||||
|
||||
fn deny_refreshed_auth_context(
|
||||
mut auth_context: GatewayControlAuthContext,
|
||||
) -> GatewayControlAuthContext {
|
||||
auth_context.access_allowed = false;
|
||||
auth_context.local_rejection = Some(GatewayLocalAuthRejection::InvalidApiKey);
|
||||
auth_context.balance_remaining = None;
|
||||
auth_context
|
||||
}
|
||||
|
||||
fn put_cached_auth_context(
|
||||
state: &AppState,
|
||||
cache_key: String,
|
||||
@@ -913,6 +960,23 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: Option<&str>,
|
||||
) -> Result<Option<GatewayControlAuthContext>, GatewayError> {
|
||||
resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
auth_endpoint_signature,
|
||||
cfg!(test),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_data_backed_auth_context_with_trusted_auth(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
auth_endpoint_signature: Option<&str>,
|
||||
trusted_auth_verified: bool,
|
||||
) -> Result<Option<GatewayControlAuthContext>, GatewayError> {
|
||||
let Some(signature) = auth_endpoint_signature
|
||||
.map(str::trim)
|
||||
@@ -923,7 +987,12 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
if !state.has_auth_api_key_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
let extracted = extract_request_credentials(headers, uri, signature);
|
||||
let extracted = extract_request_credentials_with_trusted_auth(
|
||||
headers,
|
||||
uri,
|
||||
signature,
|
||||
trusted_auth_verified,
|
||||
);
|
||||
let principal = derive_principal_candidate(&extracted);
|
||||
let now_unix_secs = current_unix_secs();
|
||||
|
||||
@@ -955,6 +1024,7 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
|
||||
allowed_models: None,
|
||||
ip_rules: None,
|
||||
verified_api_key_hash: None,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -963,17 +1033,17 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
.await;
|
||||
|
||||
let wallet_access = resolve_wallet_auth_gate_uncached(state, &snapshot).await?;
|
||||
Ok(Some(
|
||||
build_data_backed_auth_context(
|
||||
state,
|
||||
snapshot,
|
||||
signature,
|
||||
None,
|
||||
None,
|
||||
wallet_access,
|
||||
)
|
||||
.await,
|
||||
))
|
||||
let mut auth_context = build_data_backed_auth_context(
|
||||
state,
|
||||
snapshot,
|
||||
signature,
|
||||
None,
|
||||
None,
|
||||
wallet_access,
|
||||
)
|
||||
.await;
|
||||
auth_context.verified_api_key_hash = Some(VerifiedApiKeyHash::new(key_hash));
|
||||
Ok(Some(auth_context))
|
||||
}
|
||||
Some(GatewayPrincipalCandidate::DeferredBearerToken { raw, carrier }) => {
|
||||
if let Some(auth_context) = resolve_antigravity_bearer_bridge_auth_context(
|
||||
@@ -1068,6 +1138,7 @@ async fn resolve_antigravity_bearer_bridge_auth_context(
|
||||
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
|
||||
allowed_models: None,
|
||||
ip_rules: None,
|
||||
verified_api_key_hash: None,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1127,6 +1198,7 @@ async fn resolve_trusted_auth_context(
|
||||
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
|
||||
allowed_models: None,
|
||||
ip_rules: None,
|
||||
verified_api_key_hash: None,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1158,9 +1230,7 @@ async fn build_data_backed_auth_context(
|
||||
let invalid_api_key = !snapshot.user_is_active
|
||||
|| snapshot.user_is_deleted
|
||||
|| !snapshot.api_key_is_active
|
||||
|| snapshot
|
||||
.api_key_expires_at_unix_secs
|
||||
.is_some_and(|expires_at| expires_at < current_unix_secs());
|
||||
|| api_key_is_expired(snapshot.api_key_expires_at_unix_secs, current_unix_secs());
|
||||
let locked_api_key = snapshot.api_key_is_locked && !snapshot.api_key_is_standalone;
|
||||
let key_access_allowed = header_access_allowed
|
||||
.map(|value| value && snapshot.currently_usable)
|
||||
@@ -1225,9 +1295,14 @@ async fn build_data_backed_auth_context(
|
||||
local_rejection,
|
||||
allowed_models,
|
||||
ip_rules: snapshot.api_key_ip_rules,
|
||||
verified_api_key_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_is_expired(expires_at_unix_secs: Option<u64>, now_unix_secs: u64) -> bool {
|
||||
expires_at_unix_secs.is_some_and(|expires_at| expires_at <= now_unix_secs)
|
||||
}
|
||||
|
||||
fn contains_api_format_or_alias(items: &[String], target: &str) -> bool {
|
||||
items.iter().any(|item| api_format_matches(item, target))
|
||||
}
|
||||
@@ -1282,18 +1357,21 @@ async fn auth_snapshot_allows_requested_provider(
|
||||
return true;
|
||||
}
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return true;
|
||||
debug!(
|
||||
"deny requested provider {}: provider catalog is unavailable for allowlist resolution",
|
||||
requested_provider
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let providers = match state.list_provider_catalog_providers(true).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"skip local provider auth gate for requested provider {}: provider catalog lookup failed: {:?}",
|
||||
requested_provider,
|
||||
err
|
||||
warn!(
|
||||
"deny requested provider {}: provider catalog lookup failed: {:?}",
|
||||
requested_provider, err
|
||||
);
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1331,11 +1409,11 @@ async fn auth_snapshot_allows_requested_provider(
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"skip local provider auth gate for requested provider {}: provider endpoint lookup failed: {:?}",
|
||||
warn!(
|
||||
"deny requested provider {}: provider endpoint lookup failed: {:?}",
|
||||
requested_provider, err
|
||||
);
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1426,7 +1504,8 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data::repository::auth::{
|
||||
AuthApiKeyWriteRepository, InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
||||
AuthApiKeyWriteRepository, CreateUserApiKeyRecord, InMemoryAuthApiKeySnapshotRepository,
|
||||
StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::wallet::{
|
||||
@@ -1441,9 +1520,10 @@ mod tests {
|
||||
use futures_util::future::join_all;
|
||||
|
||||
use super::{
|
||||
get_cached_auth_context, resolve_control_decision_auth, resolve_data_backed_auth_context,
|
||||
resolve_execution_runtime_auth_context, ControlDecisionAuthResolution,
|
||||
GatewayLocalAuthRejection,
|
||||
api_key_is_expired, get_cached_auth_context,
|
||||
refresh_execution_runtime_auth_context_with_snapshot, resolve_control_decision_auth,
|
||||
resolve_data_backed_auth_context, resolve_execution_runtime_auth_context,
|
||||
ControlDecisionAuthResolution, GatewayLocalAuthRejection,
|
||||
};
|
||||
use crate::control::auth::credentials::{build_auth_context_cache_key, hash_api_key};
|
||||
use crate::control::GatewayControlDecision;
|
||||
@@ -1481,6 +1561,14 @@ mod tests {
|
||||
path.parse().expect("uri should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_expiry_is_inclusive_at_the_declared_second() {
|
||||
assert!(!api_key_is_expired(None, 100));
|
||||
assert!(!api_key_is_expired(Some(101), 100));
|
||||
assert!(api_key_is_expired(Some(100), 100));
|
||||
assert!(api_key_is_expired(Some(99), 100));
|
||||
}
|
||||
|
||||
fn sample_provider(id: &str, name: &str, provider_type: &str) -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
id.to_string(),
|
||||
@@ -1769,6 +1857,97 @@ mod tests {
|
||||
assert_eq!(repository.touch_count("key-1"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_lived_refresh_rejects_same_ids_recreated_with_a_different_credential() {
|
||||
let old_api_key = "sk-old-websocket-credential";
|
||||
let new_api_key = "sk-new-websocket-credential";
|
||||
let old_key_hash = hash_api_key(old_api_key);
|
||||
let new_key_hash = hash_api_key(new_api_key);
|
||||
let mut old_snapshot = sample_snapshot("key-stable-id", "user-stable-id");
|
||||
old_snapshot.user_allowed_api_formats = Some(vec!["openai:responses".to_string()]);
|
||||
old_snapshot.api_key_allowed_api_formats = Some(vec!["openai:responses".to_string()]);
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(old_key_hash.clone()),
|
||||
old_snapshot,
|
||||
)]));
|
||||
let data = GatewayDataState::with_auth_api_key_repository_for_tests(repository.clone());
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
format!("Bearer {old_api_key}").parse().unwrap(),
|
||||
);
|
||||
|
||||
let original = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/responses"),
|
||||
Some("openai:responses"),
|
||||
)
|
||||
.await
|
||||
.expect("initial auth resolution should succeed")
|
||||
.expect("the old API key should authenticate");
|
||||
assert!(original.access_allowed);
|
||||
assert!(original.verified_api_key_hash.is_some());
|
||||
assert!(
|
||||
!format!("{original:?}").contains(&old_key_hash),
|
||||
"the credential verifier must stay redacted from Debug output"
|
||||
);
|
||||
|
||||
assert!(repository
|
||||
.delete_user_api_key("user-stable-id", "key-stable-id")
|
||||
.await
|
||||
.expect("old API key deletion should succeed"));
|
||||
repository
|
||||
.create_user_api_key(CreateUserApiKeyRecord {
|
||||
user_id: "user-stable-id".to_string(),
|
||||
api_key_id: "key-stable-id".to_string(),
|
||||
key_hash: new_key_hash,
|
||||
key_encrypted: None,
|
||||
name: Some("restored-with-new-secret".to_string()),
|
||||
allowed_providers: Some(vec!["openai".to_string()]),
|
||||
allowed_api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
allowed_models: Some(vec!["gpt-4.1".to_string()]),
|
||||
ip_rules: None,
|
||||
rate_limit: 60,
|
||||
concurrent_limit: Some(5),
|
||||
force_capabilities: None,
|
||||
feature_settings: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: Some(4_102_444_800),
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: 0.0,
|
||||
})
|
||||
.await
|
||||
.expect("same-ID API key recreation should resolve")
|
||||
.expect("same-ID API key recreation should persist");
|
||||
|
||||
let (refreshed, snapshot) = refresh_execution_runtime_auth_context_with_snapshot(
|
||||
&state,
|
||||
original,
|
||||
Some("openai:responses"),
|
||||
)
|
||||
.await
|
||||
.expect("long-lived auth refresh should resolve");
|
||||
|
||||
assert!(!refreshed.access_allowed);
|
||||
assert_eq!(
|
||||
refreshed.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||
);
|
||||
assert!(snapshot.is_none());
|
||||
assert_eq!(repository.key_hash_lookup_count(&old_key_hash), 1);
|
||||
assert_eq!(
|
||||
repository.snapshot_lookup_count("key-stable-id"),
|
||||
0,
|
||||
"a bound long-lived credential must not fall back to identity-only lookup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_auth_context_singleflights_concurrent_cache_misses() {
|
||||
let api_key = "sk-test-concurrent-auth-miss";
|
||||
@@ -2396,6 +2575,44 @@ mod tests {
|
||||
assert_eq!(auth_context.local_rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_denies_unresolved_provider_id_without_catalog_reader() {
|
||||
let api_key = "sk-test-provider-no-catalog";
|
||||
let mut snapshot = sample_snapshot("key-no-catalog", "user-no-catalog");
|
||||
snapshot.user_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
|
||||
snapshot.api_key_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
|
||||
snapshot.user_allowed_api_formats = None;
|
||||
snapshot.api_key_allowed_api_formats = None;
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(api_key)),
|
||||
snapshot,
|
||||
)]));
|
||||
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", api_key.parse().unwrap());
|
||||
|
||||
let auth_context = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/messages"),
|
||||
Some("claude:messages"),
|
||||
)
|
||||
.await
|
||||
.expect("resolution should succeed")
|
||||
.expect("auth context should exist");
|
||||
|
||||
assert!(!auth_context.access_allowed);
|
||||
assert_eq!(
|
||||
auth_context.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
|
||||
provider: "claude".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn due_antigravity_bearer_refresh_observes_cross_node_allowlist_revocation() {
|
||||
let raw_bearer = "google-oauth-access-token-revoked-cross-node";
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(super) struct GatewayTrustedAdminHeaders {
|
||||
pub(super) management_token_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub(super) struct GatewayCredentialBundle {
|
||||
pub(super) authorization_bearer: Option<String>,
|
||||
pub(super) x_api_key: Option<String>,
|
||||
@@ -54,7 +54,25 @@ pub(super) struct GatewayCredentialBundle {
|
||||
pub(super) cookie_header: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
impl std::fmt::Debug for GatewayCredentialBundle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let redacted = |value: &Option<String>| value.as_ref().map(|_| "[REDACTED]");
|
||||
formatter
|
||||
.debug_struct("GatewayCredentialBundle")
|
||||
.field(
|
||||
"authorization_bearer",
|
||||
&redacted(&self.authorization_bearer),
|
||||
)
|
||||
.field("x_api_key", &redacted(&self.x_api_key))
|
||||
.field("api_key", &redacted(&self.api_key))
|
||||
.field("x_goog_api_key", &redacted(&self.x_goog_api_key))
|
||||
.field("query_key", &redacted(&self.query_key))
|
||||
.field("cookie_header", &redacted(&self.cookie_header))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(super) enum GatewayPrimaryCredential {
|
||||
ProviderApiKey {
|
||||
raw: String,
|
||||
@@ -70,6 +88,21 @@ pub(super) enum GatewayPrimaryCredential {
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GatewayPrimaryCredential {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let (variant, carrier) = match self {
|
||||
Self::ProviderApiKey { carrier, .. } => ("ProviderApiKey", carrier),
|
||||
Self::BearerToken { carrier, .. } => ("BearerToken", carrier),
|
||||
Self::CookieHeader { carrier, .. } => ("CookieHeader", carrier),
|
||||
};
|
||||
formatter
|
||||
.debug_struct(variant)
|
||||
.field("raw", &"[REDACTED]")
|
||||
.field("carrier", carrier)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(super) struct GatewayExtractedCredentials {
|
||||
pub(super) trusted_headers: Option<GatewayTrustedAuthHeaders>,
|
||||
@@ -78,7 +111,7 @@ pub(super) struct GatewayExtractedCredentials {
|
||||
pub(super) primary: Option<GatewayPrimaryCredential>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(super) enum GatewayPrincipalCandidate {
|
||||
TrustedHeaders(GatewayTrustedAuthHeaders),
|
||||
ApiKeyHash {
|
||||
@@ -94,3 +127,58 @@ pub(super) enum GatewayPrincipalCandidate {
|
||||
carrier: GatewayCredentialCarrier,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GatewayPrincipalCandidate {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::TrustedHeaders(headers) => formatter
|
||||
.debug_tuple("TrustedHeaders")
|
||||
.field(headers)
|
||||
.finish(),
|
||||
Self::ApiKeyHash { carrier, .. } => formatter
|
||||
.debug_struct("ApiKeyHash")
|
||||
.field("key_hash", &"[REDACTED]")
|
||||
.field("carrier", carrier)
|
||||
.finish(),
|
||||
Self::DeferredBearerToken { carrier, .. } => formatter
|
||||
.debug_struct("DeferredBearerToken")
|
||||
.field("raw", &"[REDACTED]")
|
||||
.field("carrier", carrier)
|
||||
.finish(),
|
||||
Self::DeferredCookieHeader { carrier, .. } => formatter
|
||||
.debug_struct("DeferredCookieHeader")
|
||||
.field("raw", &"[REDACTED]")
|
||||
.field("carrier", carrier)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod debug_redaction_tests {
|
||||
use super::{GatewayCredentialBundle, GatewayCredentialCarrier, GatewayPrimaryCredential};
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_debug_output_redacts_raw_authorization_values() {
|
||||
let bundle = GatewayCredentialBundle {
|
||||
authorization_bearer: Some("bundle-bearer-canary".to_string()),
|
||||
api_key: Some("bundle-api-key-canary".to_string()),
|
||||
cookie_header: Some("bundle-cookie-canary".to_string()),
|
||||
..GatewayCredentialBundle::default()
|
||||
};
|
||||
let primary = GatewayPrimaryCredential::ProviderApiKey {
|
||||
raw: "primary-api-key-canary".to_string(),
|
||||
carrier: GatewayCredentialCarrier::ApiKey,
|
||||
};
|
||||
let debug = format!("{bundle:?} {primary:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
for secret in [
|
||||
"bundle-bearer-canary",
|
||||
"bundle-api-key-canary",
|
||||
"bundle-cookie-canary",
|
||||
"primary-api-key-canary",
|
||||
] {
|
||||
assert!(!debug.contains(secret), "debug output leaked {secret}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user