Skip to main content

termivar_scanner/
api_gateway.rs

1//! API gateway policy records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** host/library records only; no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental/scaffold.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! These types describe host-supplied routes, rate-limit policies, quotas, and
13//! observations. They do not authenticate callers, route requests, enforce a
14//! policy, refill a bucket, or reset a quota. A host that consumes these records
15//! must supply and enforce those behaviors itself.
16
17use serde::{Deserialize, Serialize};
18
19/// Rate-limit algorithm requested by a host policy.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum RateLimitStrategy {
22    #[serde(rename = "token_bucket")]
23    TokenBucket,
24    #[serde(rename = "sliding_window")]
25    SlidingWindow,
26    #[serde(rename = "fixed_window")]
27    FixedWindow,
28    #[serde(rename = "leaky_bucket")]
29    LeakyBucket,
30}
31
32impl RateLimitStrategy {
33    #[must_use]
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::TokenBucket => "token_bucket",
37            Self::SlidingWindow => "sliding_window",
38            Self::FixedWindow => "fixed_window",
39            Self::LeakyBucket => "leaky_bucket",
40        }
41    }
42}
43
44/// Host-supplied rate-limit policy record.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RateLimitPolicy {
47    pub policy_id: String,
48    pub name: String,
49    pub strategy: RateLimitStrategy,
50    pub requests_per_second: u32,
51    pub burst_size: u32,
52    pub window_size_secs: u32,
53    pub enabled: bool,
54}
55
56/// Host-observed rate-limit state.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct RateLimitStatus {
59    pub client_id: String,
60    pub policy_id: String,
61    pub requests_allowed: u32,
62    pub requests_used: u32,
63    pub requests_remaining: u32,
64    pub reset_time_secs: u64,
65    pub quota_exceeded: bool,
66}
67
68/// Host-supplied quota and usage record.
69///
70/// The model deliberately has no default constructor: limits, identifiers, and
71/// reset timestamps must come from the integrating host rather than from
72/// fabricated product tiers.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ApiQuota {
75    pub quota_id: String,
76    pub client_id: String,
77    pub requests_per_day: u64,
78    pub requests_used_today: u64,
79    pub scan_credits: u64,
80    pub scan_credits_used: u64,
81    pub api_calls_this_hour: u64,
82    pub last_reset_time: u64,
83}
84
85impl ApiQuota {
86    #[must_use]
87    pub fn requests_remaining(&self) -> u64 {
88        self.requests_per_day
89            .saturating_sub(self.requests_used_today)
90    }
91
92    #[must_use]
93    pub fn credits_remaining(&self) -> u64 {
94        self.scan_credits.saturating_sub(self.scan_credits_used)
95    }
96
97    #[must_use]
98    pub fn quota_exceeded(&self) -> bool {
99        self.requests_used_today >= self.requests_per_day
100            || self.scan_credits_used >= self.scan_credits
101    }
102}
103
104/// Route requirements supplied to a separate host implementation.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct RouteConfig {
107    pub route_id: String,
108    pub path: String,
109    pub method: String,
110    pub rate_limit_policy_id: Option<String>,
111    pub requires_auth: bool,
112    pub timeout_secs: u32,
113    pub allowed_roles: Vec<String>,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn explicit_quota() -> ApiQuota {
121        ApiQuota {
122            quota_id: "host-quota-1".to_owned(),
123            client_id: "client-1".to_owned(),
124            requests_per_day: 8,
125            requests_used_today: 3,
126            scan_credits: 5,
127            scan_credits_used: 2,
128            api_calls_this_hour: 1,
129            last_reset_time: 1_700_000_000,
130        }
131    }
132
133    #[test]
134    fn quota_math_saturates_when_host_usage_exceeds_limits() {
135        let mut quota = explicit_quota();
136        quota.requests_used_today = u64::MAX;
137        quota.scan_credits_used = u64::MAX;
138
139        assert_eq!(quota.requests_remaining(), 0);
140        assert_eq!(quota.credits_remaining(), 0);
141        assert!(quota.quota_exceeded());
142    }
143
144    #[test]
145    fn route_record_preserves_auth_and_policy_requirements() {
146        let route = RouteConfig {
147            route_id: "admin".to_owned(),
148            path: "/admin".to_owned(),
149            method: "POST".to_owned(),
150            rate_limit_policy_id: Some("strict".to_owned()),
151            requires_auth: true,
152            timeout_secs: 30,
153            allowed_roles: vec!["admin".to_owned()],
154        };
155
156        let encoded = serde_json::to_value(&route).unwrap();
157        assert_eq!(encoded["requires_auth"], true);
158        assert_eq!(encoded["rate_limit_policy_id"], "strict");
159        assert_eq!(encoded["allowed_roles"][0], "admin");
160    }
161
162    #[test]
163    fn rate_limit_status_is_scoped_to_a_policy() {
164        let status = RateLimitStatus {
165            client_id: "client-1".to_owned(),
166            policy_id: "strict".to_owned(),
167            requests_allowed: 10,
168            requests_used: 10,
169            requests_remaining: 0,
170            reset_time_secs: 60,
171            quota_exceeded: true,
172        };
173
174        assert_eq!(status.policy_id, "strict");
175        assert!(status.quota_exceeded);
176    }
177
178    #[test]
179    fn strategy_names_are_stable_data_values() {
180        assert_eq!(RateLimitStrategy::TokenBucket.as_str(), "token_bucket");
181        assert_eq!(RateLimitStrategy::SlidingWindow.as_str(), "sliding_window");
182        assert_eq!(RateLimitStrategy::FixedWindow.as_str(), "fixed_window");
183        assert_eq!(RateLimitStrategy::LeakyBucket.as_str(), "leaky_bucket");
184    }
185}