mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +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:
@@ -4,7 +4,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::auth_modules::InMemoryAuthModuleReadRepository;
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_data::repository::management_tokens::{
|
||||
InMemoryManagementTokenRepository, ManagementTokenListQuery, ManagementTokenReadRepository,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
@@ -15,9 +17,10 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, issue_test_admin_access_token, sample_endpoint,
|
||||
sample_key, sample_ldap_module_config, sample_management_token, sample_oauth_module_provider,
|
||||
sample_provider, sample_request_candidate, start_server, AppState,
|
||||
build_router_with_state, hash_management_token, issue_test_admin_access_token,
|
||||
sample_bound_key, sample_endpoint, sample_ldap_module_config, sample_management_token,
|
||||
sample_oauth_module_provider, sample_provider, sample_request_candidate, start_server,
|
||||
AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -96,7 +99,7 @@ async fn gateway_handles_admin_health_api_formats_locally_with_trusted_admin_pri
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -248,7 +251,7 @@ async fn gateway_handles_admin_health_summary_locally_with_trusted_admin_princip
|
||||
.with_health_score(0.2),
|
||||
],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-active",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -258,7 +261,7 @@ async fn gateway_handles_admin_health_summary_locally_with_trusted_admin_princip
|
||||
Some(json!({"openai:chat": {"health_score": 0.9}})),
|
||||
Some(json!({"openai:chat": {"open": false}})),
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-circuit",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -334,7 +337,7 @@ async fn gateway_handles_admin_key_health_locally_with_trusted_admin_principal()
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_rate_limit_fields(None, None, None, None, None, None, None, Some(10), Some(7))
|
||||
.with_usage_fields(Some(3), Some(2100))
|
||||
.with_health_fields(
|
||||
@@ -432,7 +435,7 @@ async fn gateway_admin_key_health_summary_treats_expired_unix_circuit_as_closed(
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {
|
||||
"health_score": 0.7,
|
||||
@@ -511,7 +514,7 @@ async fn gateway_recovers_admin_key_health_locally_with_trusted_admin_principal(
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {
|
||||
"health_score": 0.2,
|
||||
@@ -620,7 +623,7 @@ async fn gateway_recovers_all_admin_key_health_locally_with_trusted_admin_princi
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-circuit",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -630,7 +633,7 @@ async fn gateway_recovers_all_admin_key_health_locally_with_trusted_admin_princi
|
||||
Some(json!({"openai:chat": {"health_score": 0.3}})),
|
||||
Some(json!({"openai:chat": {"open": true}})),
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-healthy",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -729,7 +732,7 @@ async fn gateway_handles_admin_health_status_locally_with_trusted_admin_principa
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1308,11 +1311,11 @@ async fn gateway_handles_admin_management_tokens_locally_with_trusted_admin_prin
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_allows_full_management_token_to_fetch_permission_catalog() {
|
||||
async fn gateway_rejects_constrained_full_management_token_creating_unconstrained_child() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/management-tokens/permissions/catalog",
|
||||
"/api/admin/management-tokens",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
@@ -1341,38 +1344,116 @@ async fn gateway_allows_full_management_token_to_fetch_permission_catalog() {
|
||||
let raw_token = "ae-management-full-access";
|
||||
let mut management_token =
|
||||
sample_management_token("mt-admin-full", &admin_user.id, "management-full", true);
|
||||
management_token.token.allowed_ips = None;
|
||||
management_token.token.allowed_ips = Some(json!(["127.0.0.1"]));
|
||||
management_token.token.expires_at_unix_secs = Some(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_secs()
|
||||
+ 3_600,
|
||||
);
|
||||
management_token.token.permissions = Some(json!(all_assignable_management_token_permissions()));
|
||||
let legacy_raw_token = "ae-management-legacy-full-access";
|
||||
let mut legacy_management_token = sample_management_token(
|
||||
"mt-admin-legacy-full",
|
||||
&admin_user.id,
|
||||
"management-legacy-full",
|
||||
true,
|
||||
);
|
||||
legacy_management_token.token.allowed_ips = Some(json!(["127.0.0.1"]));
|
||||
legacy_management_token.token.expires_at_unix_secs =
|
||||
management_token.token.expires_at_unix_secs;
|
||||
legacy_management_token.token.permissions = None;
|
||||
let management_token_repository =
|
||||
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![management_token],
|
||||
vec![(
|
||||
hash_management_token(raw_token),
|
||||
"mt-admin-full".to_string(),
|
||||
)],
|
||||
vec![management_token, legacy_management_token],
|
||||
vec![
|
||||
(
|
||||
hash_management_token(raw_token),
|
||||
"mt-admin-full".to_string(),
|
||||
),
|
||||
(
|
||||
hash_management_token(legacy_raw_token),
|
||||
"mt-admin-legacy-full".to_string(),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
||||
GatewayDataState::with_management_token_repository_for_tests(management_token_repository),
|
||||
GatewayDataState::with_management_token_repository_for_tests(Arc::clone(
|
||||
&management_token_repository,
|
||||
)),
|
||||
));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/management-tokens/permissions/catalog"
|
||||
))
|
||||
.post(format!("{gateway_url}/api/admin/management-tokens"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(raw_token)
|
||||
.json(&json!({
|
||||
"name": "unconstrained-child",
|
||||
"permissions": all_assignable_management_token_permissions(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.expect("body should read");
|
||||
assert_eq!(status, StatusCode::OK, "body={body}");
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
|
||||
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||
assert!(payload["items"].is_array());
|
||||
assert_eq!(payload["detail"], "management token permission denied");
|
||||
assert_eq!(
|
||||
payload["required_permission"],
|
||||
"admin:management_tokens:admin"
|
||||
);
|
||||
|
||||
let legacy_response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/management-tokens"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(legacy_raw_token)
|
||||
.json(&json!({
|
||||
"name": "unconstrained-legacy-child",
|
||||
"permissions": all_assignable_management_token_permissions(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("legacy request should succeed");
|
||||
let legacy_status = legacy_response.status();
|
||||
let legacy_body = legacy_response.text().await.expect("body should read");
|
||||
assert_eq!(legacy_status, StatusCode::FORBIDDEN, "body={legacy_body}");
|
||||
let legacy_payload: serde_json::Value =
|
||||
serde_json::from_str(&legacy_body).expect("json body should parse");
|
||||
assert_eq!(
|
||||
legacy_payload["detail"],
|
||||
"management token permission denied"
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_payload["required_permission"],
|
||||
"admin:management_tokens:admin"
|
||||
);
|
||||
|
||||
let tokens = management_token_repository
|
||||
.list_management_tokens(&ManagementTokenListQuery {
|
||||
user_id: None,
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.expect("management token list should succeed");
|
||||
assert_eq!(
|
||||
tokens.total, 2,
|
||||
"unconstrained children must not be created"
|
||||
);
|
||||
let mut token_ids = tokens
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.token.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
token_ids.sort_unstable();
|
||||
assert_eq!(token_ids, vec!["mt-admin-full", "mt-admin-legacy-full"]);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
Reference in New Issue
Block a user