oxify_model/
secret.rs

1//! Secret management types
2//!
3//! Secure storage and access control for sensitive credentials
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9pub type SecretId = Uuid;
10
11/// Secret metadata and encrypted value
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14pub struct Secret {
15    /// Unique secret identifier
16    #[cfg_attr(feature = "openapi", schema(value_type = String))]
17    pub id: SecretId,
18
19    /// Secret name (must be unique per user/workspace)
20    pub name: String,
21
22    /// Optional description
23    pub description: Option<String>,
24
25    /// Encrypted secret value
26    #[serde(skip_serializing)]
27    pub encrypted_value: Vec<u8>,
28
29    /// Encryption metadata
30    pub encryption: EncryptionMetadata,
31
32    /// Tags for categorization
33    pub tags: Vec<String>,
34
35    /// User or workspace ID that owns this secret
36    #[cfg_attr(feature = "openapi", schema(value_type = String))]
37    pub owner_id: Uuid,
38
39    /// Creation timestamp
40    #[cfg_attr(feature = "openapi", schema(value_type = String))]
41    pub created_at: DateTime<Utc>,
42
43    /// Last updated timestamp
44    #[cfg_attr(feature = "openapi", schema(value_type = String))]
45    pub updated_at: DateTime<Utc>,
46
47    /// Last accessed timestamp
48    #[cfg_attr(feature = "openapi", schema(value_type = String))]
49    pub last_accessed_at: Option<DateTime<Utc>>,
50
51    /// Expiration date (optional)
52    #[cfg_attr(feature = "openapi", schema(value_type = String))]
53    pub expires_at: Option<DateTime<Utc>>,
54
55    /// Access control rules
56    pub access_control: AccessControl,
57}
58
59/// Encryption metadata
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62pub struct EncryptionMetadata {
63    /// Algorithm used (e.g., "AES-256-GCM")
64    pub algorithm: String,
65
66    /// Key derivation function
67    pub kdf: String,
68
69    /// Salt for key derivation (base64)
70    pub salt: String,
71
72    /// Initialization vector (base64)
73    pub iv: String,
74
75    /// Key version for rotation
76    pub key_version: u32,
77}
78
79/// Access control for secrets
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[derive(Default)]
83pub struct AccessControl {
84    /// Allowed workflow IDs (empty means all)
85    pub allowed_workflows: Vec<Uuid>,
86
87    /// Allowed user IDs (empty means owner only)
88    pub allowed_users: Vec<Uuid>,
89
90    /// IP whitelist (empty means no restriction)
91    pub ip_whitelist: Vec<String>,
92
93    /// Require MFA for access
94    pub require_mfa: bool,
95}
96
97impl Secret {
98    /// Create a new secret (value must be encrypted before)
99    pub fn new(
100        name: String,
101        encrypted_value: Vec<u8>,
102        encryption: EncryptionMetadata,
103        owner_id: Uuid,
104    ) -> Self {
105        let now = Utc::now();
106        Self {
107            id: Uuid::new_v4(),
108            name,
109            description: None,
110            encrypted_value,
111            encryption,
112            tags: Vec::new(),
113            owner_id,
114            created_at: now,
115            updated_at: now,
116            last_accessed_at: None,
117            expires_at: None,
118            access_control: AccessControl::default(),
119        }
120    }
121
122    /// Check if secret is expired
123    pub fn is_expired(&self) -> bool {
124        if let Some(expires_at) = self.expires_at {
125            Utc::now() > expires_at
126        } else {
127            false
128        }
129    }
130
131    /// Check if workflow has access
132    pub fn can_access_workflow(&self, workflow_id: &Uuid) -> bool {
133        if self.access_control.allowed_workflows.is_empty() {
134            return true;
135        }
136        self.access_control.allowed_workflows.contains(workflow_id)
137    }
138
139    /// Check if user has access
140    pub fn can_access_user(&self, user_id: &Uuid) -> bool {
141        if user_id == &self.owner_id {
142            return true;
143        }
144        if self.access_control.allowed_users.is_empty() {
145            return false;
146        }
147        self.access_control.allowed_users.contains(user_id)
148    }
149
150    /// Update last accessed timestamp
151    pub fn mark_accessed(&mut self) {
152        self.last_accessed_at = Some(Utc::now());
153    }
154
155    /// Create a safe view without sensitive data
156    pub fn to_safe_view(&self) -> SecretView {
157        SecretView {
158            id: self.id,
159            name: self.name.clone(),
160            description: self.description.clone(),
161            tags: self.tags.clone(),
162            owner_id: self.owner_id,
163            created_at: self.created_at,
164            updated_at: self.updated_at,
165            last_accessed_at: self.last_accessed_at,
166            expires_at: self.expires_at,
167            is_expired: self.is_expired(),
168        }
169    }
170}
171
172/// Safe view of a secret (no encrypted value)
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175pub struct SecretView {
176    #[cfg_attr(feature = "openapi", schema(value_type = String))]
177    pub id: SecretId,
178    pub name: String,
179    pub description: Option<String>,
180    pub tags: Vec<String>,
181    #[cfg_attr(feature = "openapi", schema(value_type = String))]
182    pub owner_id: Uuid,
183    #[cfg_attr(feature = "openapi", schema(value_type = String))]
184    pub created_at: DateTime<Utc>,
185    #[cfg_attr(feature = "openapi", schema(value_type = String))]
186    pub updated_at: DateTime<Utc>,
187    #[cfg_attr(feature = "openapi", schema(value_type = String))]
188    pub last_accessed_at: Option<DateTime<Utc>>,
189    #[cfg_attr(feature = "openapi", schema(value_type = String))]
190    pub expires_at: Option<DateTime<Utc>>,
191    pub is_expired: bool,
192}
193
194/// Secret reference in workflow context
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SecretReference {
197    /// Secret ID or name
198    pub identifier: String,
199
200    /// Whether identifier is ID or name
201    pub is_id: bool,
202
203    /// Target variable name in context
204    pub target_variable: String,
205}
206
207/// Audit log entry for secret access
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
210pub struct SecretAuditLog {
211    #[cfg_attr(feature = "openapi", schema(value_type = String))]
212    pub id: Uuid,
213
214    #[cfg_attr(feature = "openapi", schema(value_type = String))]
215    pub secret_id: SecretId,
216
217    #[cfg_attr(feature = "openapi", schema(value_type = String))]
218    pub user_id: Option<Uuid>,
219
220    #[cfg_attr(feature = "openapi", schema(value_type = String))]
221    pub workflow_id: Option<Uuid>,
222
223    pub action: SecretAction,
224
225    pub ip_address: Option<String>,
226
227    pub success: bool,
228
229    pub error_message: Option<String>,
230
231    #[cfg_attr(feature = "openapi", schema(value_type = String))]
232    pub timestamp: DateTime<Utc>,
233}
234
235/// Actions that can be performed on secrets
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
238pub enum SecretAction {
239    Create,
240    Read,
241    Update,
242    Delete,
243    List,
244    Rotate,
245}
246
247impl std::fmt::Display for SecretAction {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            SecretAction::Create => write!(f, "CREATE"),
251            SecretAction::Read => write!(f, "READ"),
252            SecretAction::Update => write!(f, "UPDATE"),
253            SecretAction::Delete => write!(f, "DELETE"),
254            SecretAction::List => write!(f, "LIST"),
255            SecretAction::Rotate => write!(f, "ROTATE"),
256        }
257    }
258}
259
260/// Request to create a secret
261#[derive(Debug, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263pub struct CreateSecretRequest {
264    pub name: String,
265    pub value: String,
266    pub description: Option<String>,
267    pub tags: Vec<String>,
268    #[cfg_attr(feature = "openapi", schema(value_type = String))]
269    pub expires_at: Option<DateTime<Utc>>,
270}
271
272/// Request to update a secret
273#[derive(Debug, Serialize, Deserialize)]
274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
275pub struct UpdateSecretRequest {
276    pub value: Option<String>,
277    pub description: Option<String>,
278    pub tags: Option<Vec<String>>,
279    #[cfg_attr(feature = "openapi", schema(value_type = String))]
280    pub expires_at: Option<DateTime<Utc>>,
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use chrono::Duration;
287
288    fn create_test_secret() -> Secret {
289        let encryption = EncryptionMetadata {
290            algorithm: "AES-256-GCM".to_string(),
291            kdf: "PBKDF2".to_string(),
292            salt: "base64salt".to_string(),
293            iv: "base64iv".to_string(),
294            key_version: 1,
295        };
296
297        Secret::new(
298            "test_secret".to_string(),
299            vec![1, 2, 3, 4, 5],
300            encryption,
301            Uuid::new_v4(),
302        )
303    }
304
305    #[test]
306    fn test_secret_creation() {
307        let secret = create_test_secret();
308        assert_eq!(secret.name, "test_secret");
309        assert_eq!(secret.encrypted_value, vec![1, 2, 3, 4, 5]);
310        assert_eq!(secret.encryption.algorithm, "AES-256-GCM");
311        assert_eq!(secret.tags.len(), 0);
312        assert_eq!(secret.description, None);
313        assert_eq!(secret.last_accessed_at, None);
314        assert_eq!(secret.expires_at, None);
315    }
316
317    #[test]
318    fn test_secret_not_expired_when_no_expiration() {
319        let secret = create_test_secret();
320        assert!(!secret.is_expired());
321    }
322
323    #[test]
324    fn test_secret_not_expired_when_future_expiration() {
325        let mut secret = create_test_secret();
326        secret.expires_at = Some(Utc::now() + Duration::days(1));
327        assert!(!secret.is_expired());
328    }
329
330    #[test]
331    fn test_secret_expired_when_past_expiration() {
332        let mut secret = create_test_secret();
333        secret.expires_at = Some(Utc::now() - Duration::days(1));
334        assert!(secret.is_expired());
335    }
336
337    #[test]
338    fn test_workflow_access_allowed_when_empty_list() {
339        let secret = create_test_secret();
340        let workflow_id = Uuid::new_v4();
341        assert!(secret.can_access_workflow(&workflow_id));
342    }
343
344    #[test]
345    fn test_workflow_access_allowed_when_in_list() {
346        let mut secret = create_test_secret();
347        let workflow_id = Uuid::new_v4();
348        secret.access_control.allowed_workflows.push(workflow_id);
349        assert!(secret.can_access_workflow(&workflow_id));
350    }
351
352    #[test]
353    fn test_workflow_access_denied_when_not_in_list() {
354        let mut secret = create_test_secret();
355        let allowed_workflow = Uuid::new_v4();
356        let other_workflow = Uuid::new_v4();
357        secret
358            .access_control
359            .allowed_workflows
360            .push(allowed_workflow);
361        assert!(!secret.can_access_workflow(&other_workflow));
362    }
363
364    #[test]
365    fn test_user_access_allowed_for_owner() {
366        let owner_id = Uuid::new_v4();
367        let encryption = EncryptionMetadata {
368            algorithm: "AES-256-GCM".to_string(),
369            kdf: "PBKDF2".to_string(),
370            salt: "base64salt".to_string(),
371            iv: "base64iv".to_string(),
372            key_version: 1,
373        };
374
375        let secret = Secret::new(
376            "test_secret".to_string(),
377            vec![1, 2, 3, 4, 5],
378            encryption,
379            owner_id,
380        );
381
382        assert!(secret.can_access_user(&owner_id));
383    }
384
385    #[test]
386    fn test_user_access_denied_for_non_owner_when_empty_list() {
387        let secret = create_test_secret();
388        let other_user = Uuid::new_v4();
389        assert!(!secret.can_access_user(&other_user));
390    }
391
392    #[test]
393    fn test_user_access_allowed_when_in_list() {
394        let mut secret = create_test_secret();
395        let user_id = Uuid::new_v4();
396        secret.access_control.allowed_users.push(user_id);
397        assert!(secret.can_access_user(&user_id));
398    }
399
400    #[test]
401    fn test_user_access_denied_when_not_in_list() {
402        let mut secret = create_test_secret();
403        let allowed_user = Uuid::new_v4();
404        let other_user = Uuid::new_v4();
405        secret.access_control.allowed_users.push(allowed_user);
406        assert!(!secret.can_access_user(&other_user));
407    }
408
409    #[test]
410    fn test_mark_accessed_updates_timestamp() {
411        let mut secret = create_test_secret();
412        assert_eq!(secret.last_accessed_at, None);
413
414        secret.mark_accessed();
415        assert!(secret.last_accessed_at.is_some());
416
417        let first_access = secret.last_accessed_at.unwrap();
418        std::thread::sleep(std::time::Duration::from_millis(10));
419
420        secret.mark_accessed();
421        let second_access = secret.last_accessed_at.unwrap();
422        assert!(second_access > first_access);
423    }
424
425    #[test]
426    fn test_safe_view_excludes_encrypted_value() {
427        let mut secret = create_test_secret();
428        secret.description = Some("Test description".to_string());
429        secret.tags.push("tag1".to_string());
430        secret.tags.push("tag2".to_string());
431
432        let view = secret.to_safe_view();
433
434        assert_eq!(view.id, secret.id);
435        assert_eq!(view.name, secret.name);
436        assert_eq!(view.description, secret.description);
437        assert_eq!(view.tags, secret.tags);
438        assert_eq!(view.owner_id, secret.owner_id);
439        assert_eq!(view.created_at, secret.created_at);
440        assert_eq!(view.updated_at, secret.updated_at);
441        assert_eq!(view.last_accessed_at, secret.last_accessed_at);
442        assert_eq!(view.expires_at, secret.expires_at);
443        assert_eq!(view.is_expired, secret.is_expired());
444    }
445
446    #[test]
447    fn test_safe_view_reflects_expiration_status() {
448        let mut secret = create_test_secret();
449        secret.expires_at = Some(Utc::now() - Duration::days(1));
450
451        let view = secret.to_safe_view();
452        assert!(view.is_expired);
453    }
454
455    #[test]
456    fn test_secret_action_display() {
457        assert_eq!(SecretAction::Create.to_string(), "CREATE");
458        assert_eq!(SecretAction::Read.to_string(), "READ");
459        assert_eq!(SecretAction::Update.to_string(), "UPDATE");
460        assert_eq!(SecretAction::Delete.to_string(), "DELETE");
461        assert_eq!(SecretAction::List.to_string(), "LIST");
462        assert_eq!(SecretAction::Rotate.to_string(), "ROTATE");
463    }
464
465    #[test]
466    fn test_access_control_default() {
467        let ac = AccessControl::default();
468        assert_eq!(ac.allowed_workflows.len(), 0);
469        assert_eq!(ac.allowed_users.len(), 0);
470        assert_eq!(ac.ip_whitelist.len(), 0);
471        assert!(!ac.require_mfa);
472    }
473
474    #[test]
475    fn test_encryption_metadata_fields() {
476        let encryption = EncryptionMetadata {
477            algorithm: "AES-256-GCM".to_string(),
478            kdf: "PBKDF2".to_string(),
479            salt: "base64salt".to_string(),
480            iv: "base64iv".to_string(),
481            key_version: 1,
482        };
483
484        assert_eq!(encryption.algorithm, "AES-256-GCM");
485        assert_eq!(encryption.kdf, "PBKDF2");
486        assert_eq!(encryption.salt, "base64salt");
487        assert_eq!(encryption.iv, "base64iv");
488        assert_eq!(encryption.key_version, 1);
489    }
490
491    #[test]
492    fn test_secret_reference() {
493        let reference = SecretReference {
494            identifier: "my-api-key".to_string(),
495            is_id: false,
496            target_variable: "api_key".to_string(),
497        };
498
499        assert_eq!(reference.identifier, "my-api-key");
500        assert!(!reference.is_id);
501        assert_eq!(reference.target_variable, "api_key");
502    }
503
504    #[test]
505    fn test_create_secret_request() {
506        let request = CreateSecretRequest {
507            name: "test_secret".to_string(),
508            value: "secret_value".to_string(),
509            description: Some("Test description".to_string()),
510            tags: vec!["tag1".to_string(), "tag2".to_string()],
511            expires_at: None,
512        };
513
514        assert_eq!(request.name, "test_secret");
515        assert_eq!(request.value, "secret_value");
516        assert_eq!(request.description, Some("Test description".to_string()));
517        assert_eq!(request.tags.len(), 2);
518    }
519
520    #[test]
521    fn test_update_secret_request() {
522        let request = UpdateSecretRequest {
523            value: Some("new_value".to_string()),
524            description: Some("New description".to_string()),
525            tags: Some(vec!["new_tag".to_string()]),
526            expires_at: None,
527        };
528
529        assert_eq!(request.value, Some("new_value".to_string()));
530        assert_eq!(request.description, Some("New description".to_string()));
531        assert!(request.tags.is_some());
532    }
533}