Skip to main content

postrust_proxy/saas/
api_keys.rs

1//! API key generation and validation service.
2
3use crate::error::{ProxyError, ProxyResult};
4use crate::saas::db;
5use crate::saas::types::{ApiKey, CreateApiKeyRequest};
6use rand::Rng;
7use sha2::{Digest, Sha256};
8use sqlx::PgPool;
9use uuid::Uuid;
10
11/// API key service for generating and validating API keys.
12pub struct ApiKeyService {
13    pool: PgPool,
14}
15
16impl ApiKeyService {
17    /// Create a new API key service.
18    pub fn new(pool: PgPool) -> Self {
19        Self { pool }
20    }
21
22    /// Generate a new API key for a tenant.
23    ///
24    /// Returns the API key with the raw key value (only available at creation time).
25    pub async fn create_api_key(
26        &self,
27        tenant_id: Uuid,
28        req: CreateApiKeyRequest,
29    ) -> ProxyResult<ApiKey> {
30        // Generate a secure random key
31        let raw_key = generate_api_key();
32        let key_hash = hash_api_key(&raw_key);
33        let key_prefix = &raw_key[..8];
34
35        // Store in database
36        let row = db::create_api_key(&self.pool, tenant_id, req, &key_hash, key_prefix).await?;
37
38        // Return with the raw key (only time it's available)
39        Ok(ApiKey {
40            id: row.id,
41            tenant_id: row.tenant_id,
42            name: row.name,
43            key: Some(raw_key), // Only returned on creation
44            key_prefix: row.key_prefix,
45            scopes: row.scopes,
46            last_used_at: row.last_used_at,
47            expires_at: row.expires_at,
48            enabled: row.enabled,
49            created_at: row.created_at,
50        })
51    }
52
53    /// Validate an API key and return the tenant ID and scopes.
54    pub async fn validate_api_key(&self, raw_key: &str) -> ProxyResult<ValidatedApiKey> {
55        let key_hash = hash_api_key(raw_key);
56
57        let validation = db::validate_api_key_by_hash(&self.pool, &key_hash)
58            .await?
59            .ok_or_else(|| ProxyError::Auth("Invalid or expired API key".into()))?;
60
61        if !validation.enabled {
62            return Err(ProxyError::Auth("API key is disabled".into()));
63        }
64
65        // Update last used timestamp asynchronously
66        let pool = self.pool.clone();
67        let key_id = validation.id;
68        tokio::spawn(async move {
69            let _ = db::update_last_used(&pool, key_id).await;
70        });
71
72        Ok(ValidatedApiKey {
73            key_id: validation.id,
74            tenant_id: validation.tenant_id,
75            scopes: validation.scopes,
76        })
77    }
78
79    /// List API keys for a tenant.
80    pub async fn list_api_keys(&self, tenant_id: Uuid) -> ProxyResult<Vec<ApiKey>> {
81        db::list_api_keys(&self.pool, tenant_id).await
82    }
83
84    /// Get an API key by ID.
85    pub async fn get_api_key(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<Option<ApiKey>> {
86        db::get_api_key_for_tenant(&self.pool, id, tenant_id).await
87    }
88
89    /// Revoke (delete) an API key.
90    pub async fn revoke_api_key(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
91        db::delete_api_key(&self.pool, id, tenant_id).await
92    }
93
94    /// Disable an API key without deleting it.
95    pub async fn disable_api_key(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
96        db::disable_api_key(&self.pool, id, tenant_id).await
97    }
98
99    /// Enable a disabled API key.
100    pub async fn enable_api_key(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
101        db::enable_api_key(&self.pool, id, tenant_id).await
102    }
103}
104
105/// Result of API key validation.
106#[derive(Debug, Clone)]
107pub struct ValidatedApiKey {
108    /// Which key it was, for the audit log. Not the key itself.
109    pub key_id: Uuid,
110    /// The tenant the key authenticates.
111    pub tenant_id: Uuid,
112    /// What the key may do, as `resource:action` strings.
113    pub scopes: Vec<String>,
114}
115
116impl ValidatedApiKey {
117    /// Check if the key has a specific scope.
118    pub fn has_scope(&self, scope: &str) -> bool {
119        self.scopes.iter().any(|s| s == scope || s == "*")
120    }
121
122    /// Check if the key has read access for a resource.
123    pub fn can_read(&self, resource: &str) -> bool {
124        self.has_scope(&format!("{}:read", resource))
125            || self.has_scope(&format!("{}:write", resource))
126            || self.has_scope("*")
127    }
128
129    /// Check if the key has write access for a resource.
130    pub fn can_write(&self, resource: &str) -> bool {
131        self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
132    }
133}
134
135/// Generate a secure random API key.
136///
137/// Format: `pr_live_` + 32 random alphanumeric characters
138fn generate_api_key() -> String {
139    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
140    let mut rng = rand::rng();
141
142    let random_part: String = (0..32)
143        .map(|_| {
144            let idx = rng.random_range(0..CHARSET.len());
145            CHARSET[idx] as char
146        })
147        .collect();
148
149    format!("pr_live_{}", random_part)
150}
151
152/// Hash an API key using SHA-256.
153pub fn hash_api_key(key: &str) -> String {
154    let mut hasher = Sha256::new();
155    hasher.update(key.as_bytes());
156    hex::encode(hasher.finalize())
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_generate_api_key() {
165        let key = generate_api_key();
166        assert!(key.starts_with("pr_live_"));
167        assert_eq!(key.len(), 8 + 32); // prefix + random
168    }
169
170    #[test]
171    fn test_hash_api_key() {
172        let key = "pr_live_test123456789012345678901234";
173        let hash = hash_api_key(key);
174        assert_eq!(hash.len(), 64); // SHA-256 produces 32 bytes = 64 hex chars
175
176        // Same input should produce same hash
177        let hash2 = hash_api_key(key);
178        assert_eq!(hash, hash2);
179
180        // Different input should produce different hash
181        let hash3 = hash_api_key("pr_live_different12345678901234567");
182        assert_ne!(hash, hash3);
183    }
184
185    #[test]
186    fn test_validated_api_key_scopes() {
187        let validated = ValidatedApiKey {
188            key_id: Uuid::new_v4(),
189            tenant_id: Uuid::new_v4(),
190            scopes: vec!["domains:read".to_string(), "domains:write".to_string()],
191        };
192
193        assert!(validated.has_scope("domains:read"));
194        assert!(validated.has_scope("domains:write"));
195        assert!(!validated.has_scope("upstreams:write"));
196
197        assert!(validated.can_read("domains"));
198        assert!(validated.can_write("domains"));
199        assert!(!validated.can_read("upstreams"));
200        assert!(!validated.can_write("upstreams"));
201    }
202
203    #[test]
204    fn test_wildcard_scope() {
205        let validated = ValidatedApiKey {
206            key_id: Uuid::new_v4(),
207            tenant_id: Uuid::new_v4(),
208            scopes: vec!["*".to_string()],
209        };
210
211        assert!(validated.can_read("domains"));
212        assert!(validated.can_write("domains"));
213        assert!(validated.can_read("upstreams"));
214        assert!(validated.can_write("upstreams"));
215    }
216}