Skip to main content

synapse_pingora/signals/
auth_coverage.rs

1use serde::{Deserialize, Serialize};
2
3/// Response classification for auth coverage tracking
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum ResponseClass {
7    Success,      // 2xx
8    Unauthorized, // 401
9    Forbidden,    // 403
10    ClientError,  // 4xx (other)
11    ServerError,  // 5xx
12}
13
14impl ResponseClass {
15    pub fn from_status(status: u16) -> Self {
16        match status {
17            200..=299 => ResponseClass::Success,
18            401 => ResponseClass::Unauthorized,
19            403 => ResponseClass::Forbidden,
20            400..=499 => ResponseClass::ClientError,
21            _ => ResponseClass::ServerError,
22        }
23    }
24
25    pub fn is_auth_denial(&self) -> bool {
26        matches!(self, ResponseClass::Unauthorized | ResponseClass::Forbidden)
27    }
28}
29
30/// Per-endpoint counters maintained at edge
31#[derive(Debug, Default, Clone, Serialize, Deserialize)]
32pub struct EndpointCounts {
33    pub total: u64,
34    pub success: u64,
35    pub unauthorized: u64,
36    pub forbidden: u64,
37    pub other_error: u64,
38    pub with_auth: u64,
39    pub without_auth: u64,
40}
41
42/// Single endpoint's data in the summary payload
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EndpointSummary {
45    pub endpoint: String,
46    pub counts: EndpointCounts,
47}
48
49/// Summary payload shipped to Hub every flush interval
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AuthCoverageSummary {
52    pub timestamp: u64,
53    pub sensor_id: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tenant_id: Option<String>,
56    pub endpoints: Vec<EndpointSummary>,
57    #[serde(default)]
58    pub dropped_endpoints: u64,
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn test_response_class_from_status() {
67        assert_eq!(ResponseClass::from_status(200), ResponseClass::Success);
68        assert_eq!(ResponseClass::from_status(201), ResponseClass::Success);
69        assert_eq!(ResponseClass::from_status(401), ResponseClass::Unauthorized);
70        assert_eq!(ResponseClass::from_status(403), ResponseClass::Forbidden);
71        assert_eq!(ResponseClass::from_status(404), ResponseClass::ClientError);
72        assert_eq!(ResponseClass::from_status(500), ResponseClass::ServerError);
73    }
74
75    #[test]
76    fn test_is_auth_denial() {
77        assert!(ResponseClass::Unauthorized.is_auth_denial());
78        assert!(ResponseClass::Forbidden.is_auth_denial());
79        assert!(!ResponseClass::Success.is_auth_denial());
80        assert!(!ResponseClass::ClientError.is_auth_denial());
81    }
82
83    #[test]
84    fn test_summary_serialization() {
85        let summary = AuthCoverageSummary {
86            timestamp: 1234567890,
87            sensor_id: "sensor-1".to_string(),
88            tenant_id: Some("tenant-abc".to_string()),
89            endpoints: vec![EndpointSummary {
90                endpoint: "GET /api/users/{id}".to_string(),
91                counts: EndpointCounts {
92                    total: 100,
93                    success: 95,
94                    unauthorized: 3,
95                    forbidden: 2,
96                    ..Default::default()
97                },
98            }],
99            dropped_endpoints: 0,
100        };
101
102        let json = serde_json::to_string(&summary).unwrap();
103        assert!(json.contains("sensor-1"));
104        assert!(json.contains("tenant-abc"));
105    }
106}