Skip to main content

soaprs_http/
policy.rs

1//! Framework-independent endpoint policies.
2
3use std::{num::NonZeroU32, num::NonZeroU64, time::Duration};
4
5use http::{HeaderName, Method, Uri};
6use soaprs_auth::AuthorizationName;
7use soaprs_core::{SoapError, SoapResult};
8
9/// Identity dimension used to derive a rate-limit key.
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub enum RateLimitScope {
12    /// One shared limit for the endpoint.
13    Global,
14    /// One limit per normalized client network address.
15    #[default]
16    ClientIp,
17    /// One limit per authenticated principal.
18    Principal,
19    /// One limit per authenticated API key.
20    ApiKey,
21    /// An adapter or application supplies a named key resolver.
22    Custom(AuthorizationName),
23}
24
25/// Declarative rate limit translated by an HTTP or rate-limiter adapter.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RateLimitPolicy {
28    /// Maximum steady-state requests in the period.
29    pub requests: NonZeroU32,
30    /// Length of the rate-limit period.
31    pub period: Duration,
32    /// Optional additional burst capacity.
33    pub burst: Option<NonZeroU32>,
34    /// Dimension used to derive the limiter key.
35    pub scope: RateLimitScope,
36}
37
38impl RateLimitPolicy {
39    /// Creates a client-IP rate limit with a non-zero period.
40    pub fn new(requests: NonZeroU32, period: Duration) -> SoapResult<Self> {
41        if period.is_zero() {
42            return Err(SoapError::validation(
43                "rate-limit period must be greater than zero",
44            ));
45        }
46        Ok(Self {
47            requests,
48            period,
49            burst: None,
50            scope: RateLimitScope::ClientIp,
51        })
52    }
53
54    /// Selects the identity dimension used to derive a limiter key.
55    #[must_use]
56    pub fn scope(mut self, scope: RateLimitScope) -> Self {
57        self.scope = scope;
58        self
59    }
60
61    /// Adds explicit burst capacity above the steady-state limit.
62    #[must_use]
63    pub fn burst(mut self, burst: NonZeroU32) -> Self {
64        self.burst = Some(burst);
65        self
66    }
67
68    /// Validates invariants after direct public-field mutation.
69    pub fn validate(&self) -> SoapResult<()> {
70        if self.period.is_zero() {
71            Err(SoapError::validation(
72                "rate-limit period must be greater than zero",
73            ))
74        } else {
75            Ok(())
76        }
77    }
78}
79
80/// Maximum accepted request body size.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct BodyLimitPolicy {
83    /// Maximum number of request body bytes before decoding.
84    pub max_bytes: NonZeroU64,
85}
86
87impl BodyLimitPolicy {
88    /// Creates a request body limit.
89    pub const fn new(max_bytes: NonZeroU64) -> Self {
90        Self { max_bytes }
91    }
92}
93
94/// Whether cross-site request-forgery protection is required.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum CsrfPolicy {
97    /// No CSRF mechanism is required by endpoint metadata.
98    #[default]
99    Disabled,
100    /// A CSRF adapter must validate the request.
101    Required,
102}
103
104/// Allowed CORS origins.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum AllowedOrigins {
107    /// Any origin is allowed. This cannot be combined with credentials.
108    Any,
109    /// Only the exact normalized origins are allowed.
110    Exact(Vec<Uri>),
111}
112
113/// Declarative cross-origin resource-sharing policy.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct CorsPolicy {
116    /// Origins permitted by the resource.
117    pub origins: AllowedOrigins,
118    /// Methods permitted for cross-origin requests.
119    pub methods: Vec<Method>,
120    /// Request headers permitted by preflight responses.
121    pub allow_headers: Vec<HeaderName>,
122    /// Response headers exposed to browser code.
123    pub expose_headers: Vec<HeaderName>,
124    /// Whether credentialed browser requests are allowed.
125    pub allow_credentials: bool,
126    /// Optional browser preflight cache duration.
127    pub max_age: Option<Duration>,
128}
129
130impl CorsPolicy {
131    /// Creates a public CORS policy without credentials.
132    pub fn any(methods: Vec<Method>) -> SoapResult<Self> {
133        validate_methods(&methods)?;
134        Ok(Self {
135            origins: AllowedOrigins::Any,
136            methods,
137            allow_headers: Vec::new(),
138            expose_headers: Vec::new(),
139            allow_credentials: false,
140            max_age: None,
141        })
142    }
143
144    /// Creates an exact-origin CORS policy.
145    pub fn exact<I, S>(origins: I, methods: Vec<Method>) -> SoapResult<Self>
146    where
147        I: IntoIterator<Item = S>,
148        S: AsRef<str>,
149    {
150        validate_methods(&methods)?;
151        let origins = origins
152            .into_iter()
153            .map(|origin| validate_origin(origin.as_ref()))
154            .collect::<SoapResult<Vec<_>>>()?;
155        if origins.is_empty() {
156            return Err(SoapError::validation("CORS origins cannot be empty"));
157        }
158        Ok(Self {
159            origins: AllowedOrigins::Exact(origins),
160            methods,
161            allow_headers: Vec::new(),
162            expose_headers: Vec::new(),
163            allow_credentials: false,
164            max_age: None,
165        })
166    }
167
168    /// Allows browser credentials for an exact-origin policy.
169    pub fn allow_credentials(mut self) -> SoapResult<Self> {
170        if matches!(self.origins, AllowedOrigins::Any) {
171            return Err(SoapError::validation(
172                "credentialed CORS cannot allow every origin",
173            ));
174        }
175        self.allow_credentials = true;
176        Ok(self)
177    }
178
179    /// Sets headers accepted in cross-origin requests.
180    #[must_use]
181    pub fn allow_headers(mut self, headers: Vec<HeaderName>) -> Self {
182        self.allow_headers = headers;
183        self
184    }
185
186    /// Sets headers exposed to browser code.
187    #[must_use]
188    pub fn expose_headers(mut self, headers: Vec<HeaderName>) -> Self {
189        self.expose_headers = headers;
190        self
191    }
192
193    /// Sets a non-zero browser preflight cache duration.
194    pub fn max_age(mut self, max_age: Duration) -> SoapResult<Self> {
195        if max_age.is_zero() {
196            return Err(SoapError::validation(
197                "CORS max age must be greater than zero",
198            ));
199        }
200        self.max_age = Some(max_age);
201        Ok(self)
202    }
203
204    /// Validates invariants after direct public-field mutation.
205    pub fn validate(&self) -> SoapResult<()> {
206        validate_methods(&self.methods)?;
207        match &self.origins {
208            AllowedOrigins::Any if self.allow_credentials => {
209                return Err(SoapError::validation(
210                    "credentialed CORS cannot allow every origin",
211                ));
212            }
213            AllowedOrigins::Exact(origins) if origins.is_empty() => {
214                return Err(SoapError::validation("CORS origins cannot be empty"));
215            }
216            AllowedOrigins::Exact(origins) => {
217                origins
218                    .iter()
219                    .try_for_each(|origin| validate_origin(&origin.to_string()).map(|_| ()))?;
220            }
221            AllowedOrigins::Any => {}
222        }
223        if self.max_age.is_some_and(|max_age| max_age.is_zero()) {
224            return Err(SoapError::validation(
225                "CORS max age must be greater than zero",
226            ));
227        }
228        Ok(())
229    }
230}
231
232fn validate_methods(methods: &[Method]) -> SoapResult<()> {
233    if methods.is_empty() {
234        Err(SoapError::validation("CORS methods cannot be empty"))
235    } else {
236        Ok(())
237    }
238}
239
240fn validate_origin(origin: &str) -> SoapResult<Uri> {
241    let uri = origin
242        .parse::<Uri>()
243        .map_err(|_| SoapError::validation(format!("invalid CORS origin `{origin}`")))?;
244    let has_root_or_empty_path = uri.path().is_empty() || uri.path() == "/";
245    if !matches!(uri.scheme_str(), Some("http" | "https"))
246        || uri.authority().is_none()
247        || uri
248            .authority()
249            .is_some_and(|authority| authority.as_str().contains('@'))
250        || !has_root_or_empty_path
251        || uri.query().is_some()
252    {
253        return Err(SoapError::validation(format!(
254            "invalid CORS origin `{origin}`"
255        )));
256    }
257    Ok(uri)
258}
259
260/// Browser frame embedding policy.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum FrameOptions {
263    /// The resource cannot be embedded in a frame.
264    Deny,
265    /// Only the same origin may embed the resource.
266    SameOrigin,
267}
268
269/// Referrer information sent by compatible clients.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum ReferrerPolicy {
272    /// Do not send referrer information.
273    NoReferrer,
274    /// Send origin only when crossing origins.
275    StrictOriginWhenCrossOrigin,
276}
277
278/// HTTP Strict Transport Security declaration.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub struct HstsPolicy {
281    /// Browser HSTS duration.
282    pub max_age: Duration,
283    /// Whether the policy covers subdomains.
284    pub include_subdomains: bool,
285    /// Whether the site requests browser preload registration.
286    pub preload: bool,
287}
288
289impl HstsPolicy {
290    /// Creates a non-zero HSTS policy.
291    pub fn new(max_age: Duration) -> SoapResult<Self> {
292        if max_age.is_zero() {
293            return Err(SoapError::validation(
294                "HSTS max age must be greater than zero",
295            ));
296        }
297        Ok(Self {
298            max_age,
299            include_subdomains: false,
300            preload: false,
301        })
302    }
303
304    /// Applies HSTS to subdomains.
305    #[must_use]
306    pub const fn include_subdomains(mut self) -> Self {
307        self.include_subdomains = true;
308        self
309    }
310
311    /// Requests browser HSTS preload registration.
312    #[must_use]
313    pub const fn preload(mut self) -> Self {
314        self.preload = true;
315        self
316    }
317}
318
319/// Security response-header policy translated by framework adapters.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct SecurityHeadersPolicy {
322    /// Emit `X-Content-Type-Options: nosniff`.
323    pub no_sniff: bool,
324    /// Frame embedding restriction.
325    pub frame_options: Option<FrameOptions>,
326    /// Browser referrer policy.
327    pub referrer_policy: Option<ReferrerPolicy>,
328    /// Optional application-defined Content Security Policy.
329    pub content_security_policy: Option<String>,
330    /// Optional HTTPS-only HSTS policy.
331    pub hsts: Option<HstsPolicy>,
332}
333
334impl SecurityHeadersPolicy {
335    /// Returns conservative defaults that are safe for most API responses.
336    pub const fn secure_defaults() -> Self {
337        Self {
338            no_sniff: true,
339            frame_options: Some(FrameOptions::Deny),
340            referrer_policy: Some(ReferrerPolicy::NoReferrer),
341            content_security_policy: None,
342            hsts: None,
343        }
344    }
345
346    /// Sets an application-specific Content Security Policy without controls.
347    pub fn content_security_policy(mut self, policy: impl Into<String>) -> SoapResult<Self> {
348        let policy = policy.into();
349        if policy.trim().is_empty() || policy.chars().any(char::is_control) {
350            return Err(SoapError::validation(
351                "content security policy is empty or contains control characters",
352            ));
353        }
354        self.content_security_policy = Some(policy);
355        Ok(self)
356    }
357
358    /// Enables HSTS. Applications should do this only when HTTPS is guaranteed.
359    #[must_use]
360    pub const fn hsts(mut self, policy: HstsPolicy) -> Self {
361        self.hsts = Some(policy);
362        self
363    }
364
365    /// Validates header values after direct public-field mutation.
366    pub fn validate(&self) -> SoapResult<()> {
367        if self
368            .content_security_policy
369            .as_ref()
370            .is_some_and(|policy| policy.trim().is_empty() || policy.chars().any(char::is_control))
371        {
372            return Err(SoapError::validation(
373                "content security policy is empty or contains control characters",
374            ));
375        }
376        if self.hsts.is_some_and(|policy| policy.max_age.is_zero()) {
377            return Err(SoapError::validation(
378                "HSTS max age must be greater than zero",
379            ));
380        }
381        Ok(())
382    }
383}
384
385impl Default for SecurityHeadersPolicy {
386    fn default() -> Self {
387        Self::secure_defaults()
388    }
389}
390
391/// HTTP response cache visibility.
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum CacheVisibility {
394    /// Do not store the response.
395    NoStore,
396    /// Only private client caches may store the response.
397    Private,
398    /// Shared and private caches may store the response.
399    Public,
400}
401
402/// Declarative HTTP response caching policy, distinct from application data caching.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct ResponseCachePolicy {
405    /// Cache visibility.
406    pub visibility: CacheVisibility,
407    /// Optional freshness lifetime.
408    pub max_age: Option<Duration>,
409    /// Request headers that vary the selected representation.
410    pub vary: Vec<HeaderName>,
411}
412
413impl ResponseCachePolicy {
414    /// Prevents every compliant cache from storing the response.
415    pub const fn no_store() -> Self {
416        Self {
417            visibility: CacheVisibility::NoStore,
418            max_age: None,
419            vary: Vec::new(),
420        }
421    }
422
423    /// Creates a private response cache policy with non-zero freshness.
424    pub fn private(max_age: Duration) -> SoapResult<Self> {
425        Self::cacheable(CacheVisibility::Private, max_age)
426    }
427
428    /// Creates a shared response cache policy with non-zero freshness.
429    pub fn public(max_age: Duration) -> SoapResult<Self> {
430        Self::cacheable(CacheVisibility::Public, max_age)
431    }
432
433    fn cacheable(visibility: CacheVisibility, max_age: Duration) -> SoapResult<Self> {
434        if max_age.is_zero() {
435            return Err(SoapError::validation(
436                "response cache max age must be greater than zero",
437            ));
438        }
439        Ok(Self {
440            visibility,
441            max_age: Some(max_age),
442            vary: Vec::new(),
443        })
444    }
445
446    /// Sets request headers included in the cache key.
447    #[must_use]
448    pub fn vary(mut self, vary: Vec<HeaderName>) -> Self {
449        self.vary = vary;
450        self
451    }
452
453    /// Validates cache semantics after direct public-field mutation.
454    pub fn validate(&self) -> SoapResult<()> {
455        match (self.visibility, self.max_age) {
456            (CacheVisibility::NoStore, Some(_)) => Err(SoapError::validation(
457                "no-store response cache policy cannot declare max age",
458            )),
459            (CacheVisibility::Private | CacheVisibility::Public, None) => Err(
460                SoapError::validation("cacheable response policy requires max age"),
461            ),
462            (_, Some(max_age)) if max_age.is_zero() => Err(SoapError::validation(
463                "response cache max age must be greater than zero",
464            )),
465            _ => Ok(()),
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use std::{num::NonZeroU32, time::Duration};
473
474    use http::Method;
475
476    use soaprs_auth::AuthorizationPolicy;
477
478    use super::{
479        CacheVisibility, CorsPolicy, RateLimitPolicy, RateLimitScope, ResponseCachePolicy,
480        SecurityHeadersPolicy,
481    };
482
483    #[test]
484    fn authorization_names_and_lists_are_validated() {
485        assert!(AuthorizationPolicy::strategy("jwt").is_ok());
486        assert!(AuthorizationPolicy::optional_strategy("session").is_ok());
487        assert!(AuthorizationPolicy::all_permissions(["orders:read", "orders:write"]).is_ok());
488        assert!(AuthorizationPolicy::any_role(Vec::<String>::new()).is_err());
489        assert!(AuthorizationPolicy::named("bad policy name").is_err());
490        assert!(!AuthorizationPolicy::Optional.requires_identity());
491        assert!(AuthorizationPolicy::Optional.authenticates_when_present());
492        assert!(!AuthorizationPolicy::Optional.allows_public_response_cache());
493    }
494
495    #[test]
496    fn rate_limits_include_scope_and_burst_without_choosing_storage() {
497        let Some(requests) = NonZeroU32::new(100) else {
498            panic!("non-zero fixture");
499        };
500        let result = RateLimitPolicy::new(requests, Duration::from_secs(60)).map(|policy| {
501            policy
502                .scope(RateLimitScope::Principal)
503                .burst(NonZeroU32::MIN)
504        });
505        assert!(result.is_ok());
506    }
507
508    #[test]
509    fn cors_rejects_wildcard_credentials_and_non_origins() {
510        let wildcard = CorsPolicy::any(vec![Method::GET]);
511        assert!(wildcard.and_then(CorsPolicy::allow_credentials).is_err());
512        assert!(CorsPolicy::exact(["https://example.com"], vec![Method::GET]).is_ok());
513        assert!(CorsPolicy::exact(["https://example.com/path"], vec![Method::GET]).is_err());
514        assert!(CorsPolicy::exact(["ftp://example.com"], vec![Method::GET]).is_err());
515        assert!(CorsPolicy::exact(["https://user@example.com"], vec![Method::GET]).is_err());
516    }
517
518    #[test]
519    fn public_policy_fields_are_revalidated_before_registration() {
520        let Some(requests) = NonZeroU32::new(10) else {
521            panic!("non-zero fixture");
522        };
523        let Some(mut rate_limit) = RateLimitPolicy::new(requests, Duration::from_secs(1)).ok()
524        else {
525            panic!("valid rate limit");
526        };
527        rate_limit.period = Duration::ZERO;
528        assert!(rate_limit.validate().is_err());
529
530        let mut cors = CorsPolicy::any(vec![Method::GET]).unwrap_or_else(|error| {
531            panic!("valid CORS fixture failed: {error}");
532        });
533        cors.allow_credentials = true;
534        assert!(cors.validate().is_err());
535
536        let mut cache = ResponseCachePolicy::no_store();
537        cache.visibility = CacheVisibility::Public;
538        assert!(cache.validate().is_err());
539
540        let mut headers = SecurityHeadersPolicy::secure_defaults();
541        headers.content_security_policy = Some("\r\ninjected: true".to_owned());
542        assert!(headers.validate().is_err());
543    }
544}