Skip to main content

perfgate_auth/
lib.rs

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