Skip to main content

perfgate_auth/
lib.rs

1//! Authentication and authorization types for perfgate.
2
3use chrono::{DateTime, Utc};
4use perfgate_error::AuthError;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8/// API key prefix for live keys.
9pub const API_KEY_PREFIX_LIVE: &str = "pg_live_";
10
11/// API key prefix for test keys.
12pub const API_KEY_PREFIX_TEST: &str = "pg_test_";
13
14/// Permission scope for API operations.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum Scope {
18    /// Read-only access
19    Read,
20    /// Write/upload access
21    Write,
22    /// Promote baselines
23    Promote,
24    /// Delete baselines
25    Delete,
26    /// Admin operations
27    Admin,
28}
29
30impl std::fmt::Display for Scope {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Scope::Read => write!(f, "read"),
34            Scope::Write => write!(f, "write"),
35            Scope::Promote => write!(f, "promote"),
36            Scope::Delete => write!(f, "delete"),
37            Scope::Admin => write!(f, "admin"),
38        }
39    }
40}
41
42/// Role-based access control.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "snake_case")]
45pub enum Role {
46    /// Read-only access
47    Viewer,
48    /// Can upload and read baselines
49    Contributor,
50    /// Can promote baselines to production
51    Promoter,
52    /// Full access including delete
53    Admin,
54}
55
56impl Role {
57    /// Returns the scopes allowed for this role.
58    pub fn allowed_scopes(&self) -> Vec<Scope> {
59        match self {
60            Role::Viewer => vec![Scope::Read],
61            Role::Contributor => vec![Scope::Read, Scope::Write],
62            Role::Promoter => vec![Scope::Read, Scope::Write, Scope::Promote],
63            Role::Admin => vec![
64                Scope::Read,
65                Scope::Write,
66                Scope::Promote,
67                Scope::Delete,
68                Scope::Admin,
69            ],
70        }
71    }
72
73    /// Checks if this role has a specific scope.
74    pub fn has_scope(&self, scope: Scope) -> bool {
75        self.allowed_scopes().contains(&scope)
76    }
77
78    /// Infers the closest built-in role from a set of scopes.
79    pub fn from_scopes(scopes: &[Scope]) -> Self {
80        if scopes.contains(&Scope::Admin) || scopes.contains(&Scope::Delete) {
81            Self::Admin
82        } else if scopes.contains(&Scope::Promote) {
83            Self::Promoter
84        } else if scopes.contains(&Scope::Write) {
85            Self::Contributor
86        } else {
87            Self::Viewer
88        }
89    }
90}
91
92impl std::fmt::Display for Role {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Role::Viewer => write!(f, "viewer"),
96            Role::Contributor => write!(f, "contributor"),
97            Role::Promoter => write!(f, "promoter"),
98            Role::Admin => write!(f, "admin"),
99        }
100    }
101}
102
103/// Represents an authenticated API key.
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct ApiKey {
106    /// Unique key identifier
107    pub id: String,
108
109    /// Key name/description
110    pub name: String,
111
112    /// Project this key belongs to
113    pub project_id: String,
114
115    /// Granted scopes
116    pub scopes: Vec<Scope>,
117
118    /// Role (for easier permission checks)
119    pub role: Role,
120
121    /// Optional regex to restrict access to specific benchmarks
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub benchmark_regex: Option<String>,
124
125    /// Expiration timestamp
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub expires_at: Option<DateTime<Utc>>,
128
129    /// Creation timestamp
130    pub created_at: DateTime<Utc>,
131
132    /// Last usage timestamp
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub last_used_at: Option<DateTime<Utc>>,
135}
136
137impl ApiKey {
138    /// Creates a new API key with the given role.
139    pub fn new(id: String, name: String, project_id: String, role: Role) -> Self {
140        Self {
141            id,
142            name,
143            project_id,
144            scopes: role.allowed_scopes(),
145            role,
146            benchmark_regex: None,
147            expires_at: None,
148            created_at: Utc::now(),
149            last_used_at: None,
150        }
151    }
152
153    /// Checks if the key has expired.
154    pub fn is_expired(&self) -> bool {
155        if let Some(exp) = self.expires_at {
156            return exp < Utc::now();
157        }
158        false
159    }
160
161    /// Checks if the key has a specific scope.
162    pub fn has_scope(&self, scope: Scope) -> bool {
163        self.scopes.contains(&scope)
164    }
165}
166
167/// JWT claims accepted by the server.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
169pub struct JwtClaims {
170    /// Subject identifier.
171    pub sub: String,
172
173    /// Project this token belongs to.
174    pub project_id: String,
175
176    /// Granted scopes.
177    pub scopes: Vec<Scope>,
178
179    /// Expiration timestamp (seconds since Unix epoch).
180    pub exp: u64,
181
182    /// Issued-at timestamp.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub iat: Option<u64>,
185
186    /// Optional issuer.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub iss: Option<String>,
189
190    /// Optional audience.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub aud: Option<String>,
193}
194
195/// Validates API key format.
196pub fn validate_key_format(key: &str) -> Result<(), AuthError> {
197    if key.starts_with(API_KEY_PREFIX_LIVE) || key.starts_with(API_KEY_PREFIX_TEST) {
198        let remainder = key
199            .strip_prefix(API_KEY_PREFIX_LIVE)
200            .or_else(|| key.strip_prefix(API_KEY_PREFIX_TEST))
201            .unwrap();
202
203        // Check that the remainder is at least 32 characters
204        if remainder.len() >= 32 && remainder.chars().all(|c| c.is_alphanumeric()) {
205            return Ok(());
206        }
207    }
208
209    Err(AuthError::InvalidKeyFormat)
210}
211
212/// Creates a new API key string.
213pub fn generate_api_key(test: bool) -> String {
214    let prefix = if test {
215        API_KEY_PREFIX_TEST
216    } else {
217        API_KEY_PREFIX_LIVE
218    };
219    let random: String = uuid::Uuid::new_v4()
220        .simple()
221        .to_string()
222        .chars()
223        .take(32)
224        .collect();
225    format!("{}{}", prefix, random)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_validate_key_format() {
234        assert!(validate_key_format("pg_live_abcdefghijklmnopqrstuvwxyz123456").is_ok());
235        assert!(validate_key_format("pg_test_abcdefghijklmnopqrstuvwxyz123456").is_ok());
236        assert!(validate_key_format("invalid_abcdefghijklmnopqrstuvwxyz123456").is_err());
237        assert!(validate_key_format("pg_live_short").is_err());
238        assert!(validate_key_format("pg_live_abcdefghijklmnopqrstuvwxyz12345!@").is_err());
239    }
240
241    #[test]
242    fn test_role_scopes() {
243        let viewer = Role::Viewer;
244        assert!(viewer.has_scope(Scope::Read));
245        assert!(!viewer.has_scope(Scope::Write));
246
247        let contributor = Role::Contributor;
248        assert!(contributor.has_scope(Scope::Read));
249        assert!(contributor.has_scope(Scope::Write));
250        assert!(!contributor.has_scope(Scope::Promote));
251
252        let promoter = Role::Promoter;
253        assert!(promoter.has_scope(Scope::Promote));
254        assert!(!promoter.has_scope(Scope::Delete));
255
256        let admin = Role::Admin;
257        assert!(admin.has_scope(Scope::Delete));
258        assert!(admin.has_scope(Scope::Admin));
259    }
260
261    #[test]
262    fn test_generate_api_key() {
263        let live_key = generate_api_key(false);
264        assert!(live_key.starts_with(API_KEY_PREFIX_LIVE));
265        assert!(live_key.len() >= 40);
266
267        let test_key = generate_api_key(true);
268        assert!(test_key.starts_with(API_KEY_PREFIX_TEST));
269        assert!(test_key.len() >= 40);
270    }
271}