mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
444 lines
15 KiB
Rust
444 lines
15 KiB
Rust
use aether_data::repository::wallet::StoredWalletSnapshot;
|
|||
|
|
use aether_wallet::{
|
||
|
|
WalletAccessDecision, WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus,
|
||
|
|
};
|
||
|
|
|
||
use crate::control::GatewayLocalAuthRejection;
|
|||
|
|
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||
|
|
use crate::{AppState, GatewayError};
|
||
|
|||
const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01;
|
|||
|
|
|
||
pub(crate) async fn resolve_wallet_auth_gate(
|
|||
|
|
state: &AppState,
|
||
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
|||
) -> Result<Option<WalletAccessDecision>, GatewayError> {
|
|||
|
|
resolve_wallet_auth_gate_with_cache(state, auth_snapshot, true).await
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) async fn resolve_wallet_auth_gate_uncached(
|
||
|
|
state: &AppState,
|
||
|
|
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||
|
|
) -> Result<Option<WalletAccessDecision>, GatewayError> {
|
||
|
|
resolve_wallet_auth_gate_with_cache(state, auth_snapshot, false).await
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn resolve_wallet_auth_gate_with_cache(
|
||
|
|
state: &AppState,
|
||
|
|
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||
|
|
use_cache: bool,
|
||
) -> Result<Option<WalletAccessDecision>, GatewayError> {
|
|||
|
|
if !state.has_wallet_data_reader() {
|
||
|
|
return Ok(None);
|
||
|
|
}
|
||
|
|
|
||
let wallet = if use_cache {
|
|||
|
|
state
|
||
|
|
.read_wallet_snapshot_for_auth(
|
||
|
|
&auth_snapshot.user_id,
|
||
|
|
&auth_snapshot.api_key_id,
|
||
|
|
auth_snapshot.api_key_is_standalone,
|
||
|
|
)
|
||
|
|
.await?
|
||
|
|
} else {
|
||
|
|
state
|
||
|
|
.read_wallet_snapshot_for_auth_uncached(
|
||
|
|
&auth_snapshot.user_id,
|
||
|
|
&auth_snapshot.api_key_id,
|
||
|
|
auth_snapshot.api_key_is_standalone,
|
||
|
|
)
|
||
|
|
.await?
|
||
|
|
};
|
||
|
|||
let decision = match wallet.as_ref() {
|
|||
Some(wallet) => map_wallet_snapshot(wallet).access_decision(false),
|
|||
None => WalletAccessDecision::wallet_unavailable(None),
|
|||
};
|
|||
|
|
if !auth_snapshot.api_key_is_standalone {
|
||
let wallet_is_unlimited = wallet
|
|||
|
|
.as_ref()
|
||
|
|
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||
let quota = if use_cache {
|
|||
|
|
state
|
||
|
|
.find_user_daily_quota_availability_for_auth(&auth_snapshot.user_id)
|
||
|
|
.await?
|
||
|
|
} else {
|
||
|
|
state
|
||
|
|
.find_user_daily_quota_availability_for_auth_uncached(&auth_snapshot.user_id)
|
||
|
|
.await?
|
||
|
|
};
|
||
|
|
if let Some(quota) = quota.filter(|quota| quota.has_active_daily_quota) {
|
||
let has_remaining_quota = quota.remaining_usd > DAILY_QUOTA_EPSILON_USD;
|
|||
|
|
if decision.failure == Some(WalletAccessFailure::BalanceDenied) && has_remaining_quota {
|
||
|
|
return Ok(Some(WalletAccessDecision::allowed(Some(
|
||
|
|
quota.remaining_usd,
|
||
|
|
))));
|
||
|
|
}
|
||
if !wallet_is_unlimited
|
|||
|
|
&& decision.failure.is_none()
|
||
|
|
&& !quota.allow_wallet_overage
|
||
|
|
&& !has_remaining_quota
|
||
|
|
{
|
||
return Ok(Some(WalletAccessDecision::balance_denied(Some(0.0))));
|
|||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(Some(decision))
|
||
}
|
|||
|
|
|
||
|
|
pub(crate) fn local_rejection_from_wallet_access(
|
||
|
|
decision: &WalletAccessDecision,
|
||
|
|
) -> Option<GatewayLocalAuthRejection> {
|
||
|
|
match decision.failure.as_ref() {
|
||
|
|
Some(WalletAccessFailure::WalletUnavailable) => {
|
||
|
|
Some(GatewayLocalAuthRejection::WalletUnavailable)
|
||
|
|
}
|
||
|
|
Some(WalletAccessFailure::BalanceDenied) => {
|
||
|
|
Some(GatewayLocalAuthRejection::BalanceDenied {
|
||
|
|
remaining: decision.remaining,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
None => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn map_wallet_snapshot(snapshot: &StoredWalletSnapshot) -> WalletSnapshot {
|
||
|
|
WalletSnapshot {
|
||
|
|
wallet_id: snapshot.id.clone(),
|
||
|
|
user_id: snapshot.user_id.clone(),
|
||
|
|
api_key_id: snapshot.api_key_id.clone(),
|
||
|
|
recharge_balance: snapshot.balance,
|
||
|
|
gift_balance: snapshot.gift_balance,
|
||
|
|
limit_mode: WalletLimitMode::parse(&snapshot.limit_mode),
|
||
|
|
currency: snapshot.currency.clone(),
|
||
|
|
status: WalletStatus::parse(&snapshot.status),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
use std::sync::Arc;
|
|||
use std::time::Duration;
|
|||
|
|||
|
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||
|
|
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
||
|
|
use aether_data_contracts::repository::billing::{
|
||
|
|
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
||
|
|
};
|
||
|
|
use aether_data_contracts::DataLayerError;
|
||
use aether_runtime::ConcurrencyGate;
|
|||
use aether_wallet::{WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus};
|
|||
use async_trait::async_trait;
|
|||
|
|||
use super::{
|
|||
local_rejection_from_wallet_access, map_wallet_snapshot, resolve_wallet_auth_gate,
|
|||
};
|
|||
use crate::control::GatewayLocalAuthRejection;
|
|||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
|||
|
|
use crate::data::GatewayDataState;
|
||
|
|
use crate::AppState;
|
||
|
|
|
||
|
|
#[derive(Debug)]
|
||
|
|
struct FixedQuotaBillingReadRepository {
|
||
|
|
quota: Option<UserDailyQuotaAvailabilityRecord>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl BillingReadRepository for FixedQuotaBillingReadRepository {
|
||
|
|
async fn find_model_context(
|
||
|
|
&self,
|
||
|
|
_provider_id: &str,
|
||
|
|
_provider_api_key_id: Option<&str>,
|
||
|
|
_global_model_name: &str,
|
||
|
|
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||
|
|
Ok(None)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn find_user_daily_quota_availability(
|
||
|
|
&self,
|
||
|
|
_user_id: &str,
|
||
|
|
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
|
||
|
|
Ok(self.quota.clone())
|
||
|
|
}
|
||
|
|
}
|
||
|
|||
|
|
#[test]
|
||
|
|
fn maps_wallet_snapshot_and_derives_balance_denied() {
|
||
|
|
let stored = StoredWalletSnapshot::new(
|
||
|
|
"wallet-1".to_string(),
|
||
|
|
Some("user-1".to_string()),
|
||
|
|
None,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
"finite".to_string(),
|
||
|
|
"USD".to_string(),
|
||
|
|
"active".to_string(),
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
100,
|
||
|
|
)
|
||
|
|
.expect("wallet should build");
|
||
|
|
|
||
|
|
let decision = map_wallet_snapshot(&stored).access_decision(false);
|
||
|
|
assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied));
|
||
|
|
assert_eq!(
|
||
|
|
local_rejection_from_wallet_access(&decision),
|
||
|
|
Some(GatewayLocalAuthRejection::BalanceDenied {
|
||
|
|
remaining: Some(0.0),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn unlimited_admin_wallet_gate_allows_without_remaining() {
|
||
|
|
let decision = WalletSnapshot {
|
||
|
|
wallet_id: "wallet-1".to_string(),
|
||
|
|
user_id: Some("user-1".to_string()),
|
||
|
|
api_key_id: None,
|
||
|
|
recharge_balance: 0.0,
|
||
|
|
gift_balance: 0.0,
|
||
|
|
limit_mode: WalletLimitMode::Unlimited,
|
||
|
|
currency: "USD".to_string(),
|
||
|
|
status: WalletStatus::Active,
|
||
|
|
}
|
||
|
|
.access_decision(true);
|
||
|
|
|
||
|
|
assert!(decision.allowed);
|
||
|
|
assert_eq!(decision.remaining, None);
|
||
|
|
}
|
||
|
|||
|
|
#[test]
|
||
fn admin_user_with_empty_finite_wallet_is_balance_denied() {
|
|||
|
|
let stored = StoredWalletSnapshot::new(
|
||
|
|
"wallet-1".to_string(),
|
||
|
|
Some("admin-1".to_string()),
|
||
|
|
None,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
"finite".to_string(),
|
||
|
|
"USD".to_string(),
|
||
|
|
"active".to_string(),
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
100,
|
||
|
|
)
|
||
|
|
.expect("wallet should build");
|
||
|
|
|
||
|
|
let decision = map_wallet_snapshot(&stored).access_decision(true);
|
||
|
|
|
||
|
|
assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn ordinary_user_key_without_quota_denies_empty_wallet() {
|
||
|
|
let state = state_with_wallet_and_quota(empty_user_wallet(), None);
|
||
|
|
let auth_snapshot = ordinary_user_api_key_snapshot();
|
||
|
|
|
||
|
|
let decision = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||
|
|
.await
|
||
|
|
.expect("wallet gate should resolve")
|
||
|
|
.expect("wallet gate should return a decision");
|
||
|
|
|
||
|
|
assert!(!decision.allowed);
|
||
|
|
assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied));
|
||
|
|
assert_eq!(
|
||
|
|
local_rejection_from_wallet_access(&decision),
|
||
|
|
Some(GatewayLocalAuthRejection::BalanceDenied {
|
||
|
|
remaining: Some(0.0),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn ordinary_user_key_with_remaining_quota_allows_empty_wallet() {
|
||
|
|
let state = state_with_wallet_and_quota(
|
||
|
|
empty_user_wallet(),
|
||
|
|
Some(quota_availability(10.0, 4.0, false)),
|
||
|
|
);
|
||
|
|
let auth_snapshot = ordinary_user_api_key_snapshot();
|
||
|
|
|
||
|
|
let decision = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||
|
|
.await
|
||
|
|
.expect("wallet gate should resolve")
|
||
|
|
.expect("wallet gate should return a decision");
|
||
|
|
|
||
|
|
assert!(decision.allowed);
|
||
|
|
assert_eq!(decision.failure, None);
|
||
|
|
assert_eq!(decision.remaining, Some(4.0));
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn unlimited_wallet_ignores_exhausted_non_overage_quota() {
|
||
|
|
let mut wallet = empty_user_wallet();
|
||
|
|
wallet.limit_mode = "unlimited".to_string();
|
||
|
|
let state = state_with_wallet_and_quota(wallet, Some(quota_availability(10.0, 0.0, false)));
|
||
|
|
let auth_snapshot = ordinary_user_api_key_snapshot();
|
||
|
|
|
||
|
|
let decision = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||
|
|
.await
|
||
|
|
.expect("wallet gate should resolve")
|
||
|
|
.expect("wallet gate should return a decision");
|
||
|
|
|
||
|
|
assert!(decision.allowed);
|
||
|
|
assert_eq!(decision.failure, None);
|
||
|
|
assert_eq!(decision.remaining, None);
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn disabled_auth_capacity_cache_still_gates_wallet_reads() {
|
||
|
|
let mut state = state_with_wallet_and_quota(empty_user_wallet(), None);
|
||
|
|
let mut guard_config = (*state.frontdoor_runtime_guards).clone();
|
||
|
|
guard_config.auth_capacity_cache_ttl = Duration::ZERO;
|
||
|
|
state = state.with_frontdoor_runtime_guard_config_for_tests(guard_config);
|
||
|
|
state.auth_snapshot_load_gate =
|
||
|
|
Some(Arc::new(ConcurrencyGate::new("test_auth_wallet_load", 1)));
|
||
|
|
let held = state
|
||
|
|
.acquire_auth_snapshot_load_gate()
|
||
|
|
.await
|
||
|
|
.expect("auth gate acquisition should succeed")
|
||
|
|
.expect("auth gate should be configured");
|
||
|
|
|
||
|
|
let blocked = tokio::time::timeout(
|
||
|
|
Duration::from_millis(25),
|
||
|
|
state.read_wallet_snapshot_for_auth("user-1", "api-key-1", false),
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
assert!(
|
||
|
|
blocked.is_err(),
|
||
|
|
"zero-TTL wallet reads must wait for the auth DB gate"
|
||
|
|
);
|
||
|
|
|
||
|
|
drop(held);
|
||
|
|
let wallet = tokio::time::timeout(
|
||
|
|
Duration::from_secs(1),
|
||
|
|
state.read_wallet_snapshot_for_auth("user-1", "api-key-1", false),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.expect("wallet read should resume after releasing the auth gate")
|
||
|
|
.expect("wallet read should succeed");
|
||
|
|
assert!(wallet.is_some());
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn admin_wallet_recharge_invalidates_cached_auth_capacity_state() {
|
||
|
|
let wallet = empty_user_wallet();
|
||
|
|
let state =
|
||
|
|
state_with_wallet_and_quota(wallet.clone(), None).with_auth_wallets_for_tests([wallet]);
|
||
|
|
let auth_snapshot = ordinary_user_api_key_snapshot();
|
||
|
|
|
||
|
|
let denied = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||
|
|
.await
|
||
|
|
.expect("wallet gate should resolve")
|
||
|
|
.expect("wallet gate should return a decision");
|
||
|
|
|
||
|
|
assert!(!denied.allowed);
|
||
|
|
assert_eq!(denied.failure, Some(WalletAccessFailure::BalanceDenied));
|
||
|
|
|
||
|
|
let recharge = state
|
||
|
|
.admin_create_manual_wallet_recharge(
|
||
|
|
"wallet-user-1",
|
||
|
|
10.0,
|
||
|
|
"admin_manual",
|
||
|
|
Some("admin-1"),
|
||
|
|
Some("manual recharge"),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.expect("wallet recharge should complete");
|
||
|
|
|
||
|
|
assert!(recharge.is_some());
|
||
|
|
|
||
|
|
let refreshed = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||
|
|
.await
|
||
|
|
.expect("wallet gate should resolve after recharge")
|
||
|
|
.expect("wallet gate should return a decision after recharge");
|
||
|
|
|
||
|
|
assert!(refreshed.allowed);
|
||
|
|
assert_eq!(refreshed.failure, None);
|
||
|
|
assert_eq!(refreshed.remaining, Some(10.0));
|
||
|
|
}
|
||
|
|
|
||
fn state_with_wallet_and_quota(
|
|||
|
|
wallet: StoredWalletSnapshot,
|
||
|
|
quota: Option<UserDailyQuotaAvailabilityRecord>,
|
||
|
|
) -> AppState {
|
||
|
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||
|
|
let billing_repository: Arc<dyn BillingReadRepository> =
|
||
|
|
Arc::new(FixedQuotaBillingReadRepository { quota });
|
||
|
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
|
||
|
|
let data = GatewayDataState::with_usage_billing_and_wallet_for_tests(
|
||
|
|
usage_repository,
|
||
|
|
billing_repository,
|
||
|
|
wallet_repository,
|
||
|
|
);
|
||
|
|
AppState::new()
|
||
|
|
.expect("state should build")
|
||
|
|
.with_data_state_for_tests(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn empty_user_wallet() -> StoredWalletSnapshot {
|
||
|
|
StoredWalletSnapshot::new(
|
||
|
|
"wallet-user-1".to_string(),
|
||
|
|
Some("user-1".to_string()),
|
||
|
|
None,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
"finite".to_string(),
|
||
|
|
"USD".to_string(),
|
||
|
|
"active".to_string(),
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
0.0,
|
||
|
|
100,
|
||
|
|
)
|
||
|
|
.expect("wallet should build")
|
||
|
|
}
|
||
|
|
|
||
|
|
fn quota_availability(
|
||
|
|
total_quota_usd: f64,
|
||
|
|
remaining_usd: f64,
|
||
|
|
allow_wallet_overage: bool,
|
||
|
|
) -> UserDailyQuotaAvailabilityRecord {
|
||
|
|
UserDailyQuotaAvailabilityRecord {
|
||
|
|
has_active_daily_quota: true,
|
||
|
|
total_quota_usd,
|
||
|
|
used_usd: total_quota_usd - remaining_usd,
|
||
|
|
remaining_usd,
|
||
|
|
allow_wallet_overage,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn ordinary_user_api_key_snapshot() -> GatewayAuthApiKeySnapshot {
|
||
|
|
GatewayAuthApiKeySnapshot {
|
||
|
|
user_id: "user-1".to_string(),
|
||
|
|
username: "ordinary-user".to_string(),
|
||
|
|
email: Some("[email protected]".to_string()),
|
||
|
|
user_role: "user".to_string(),
|
||
|
|
user_auth_source: "local".to_string(),
|
||
|
|
user_is_active: true,
|
||
|
|
user_is_deleted: false,
|
||
|
|
user_rate_limit: None,
|
||
|
|
user_allowed_providers: None,
|
||
|
|
user_allowed_api_formats: None,
|
||
|
|
user_allowed_models: None,
|
||
|
|
api_key_id: "api-key-1".to_string(),
|
||
|
|
api_key_name: Some("admin-created-key".to_string()),
|
||
|
|
api_key_is_active: true,
|
||
|
|
api_key_is_locked: false,
|
||
|
|
api_key_is_standalone: false,
|
||
|
|
api_key_rate_limit: None,
|
||
|
|
api_key_concurrent_limit: None,
|
||
|
|
api_key_expires_at_unix_secs: None,
|
||
|
|
api_key_allowed_providers: None,
|
||
|
|
api_key_allowed_api_formats: None,
|
||
|
|
api_key_allowed_models: None,
|
||
api_key_ip_rules: None,
|
|||
currently_usable: true,
|
|||
|
|
}
|
||
}
|
|||
}
|