Skip to main content

ironflow_api/entities/
secret.rs

1//! Secret request and response DTOs.
2
3use chrono::{DateTime, Utc};
4use ironflow_store::entities::{
5    DEFAULT_ROTATION_BATCH_SIZE, KeyVersionStatus, RotationBatch, Secret, SecretMetadata,
6};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9use validator::Validate;
10
11/// Response DTO for a secret (never exposes the value).
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[derive(Debug, Serialize)]
14pub struct SecretResponse {
15    /// Secret ID.
16    pub id: Uuid,
17    /// Secret key.
18    pub key: String,
19    /// Creation timestamp.
20    pub created_at: DateTime<Utc>,
21    /// Last update timestamp.
22    pub updated_at: DateTime<Utc>,
23}
24
25impl From<SecretMetadata> for SecretResponse {
26    fn from(meta: SecretMetadata) -> Self {
27        Self {
28            id: meta.id,
29            key: meta.key,
30            created_at: meta.created_at,
31            updated_at: meta.updated_at,
32        }
33    }
34}
35
36impl From<Secret> for SecretResponse {
37    fn from(secret: Secret) -> Self {
38        Self {
39            id: secret.id,
40            key: secret.key,
41            created_at: secret.created_at,
42            updated_at: secret.updated_at,
43        }
44    }
45}
46
47/// Request body for creating or updating a secret.
48#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
49#[derive(Debug, Deserialize, Validate)]
50pub struct SetSecretRequest {
51    /// Secret key (namespaced, e.g. `workflows/inbox/gmail_token`).
52    #[validate(length(min = 1, max = 512), custom(function = "validate_secret_key"))]
53    pub key: String,
54    /// Secret value (plaintext, will be encrypted at rest).
55    #[validate(length(min = 1, max = 65536))]
56    pub value: String,
57}
58
59/// Request body for rotating a batch of secrets to another key version.
60///
61/// Rotation is driven one batch per call so a long rotation never becomes a
62/// long HTTP request: the client loops, carrying `after_id` forward.
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[derive(Debug, Default, Deserialize, Validate)]
65pub struct RotateSecretsRequest {
66    /// Target key version. Defaults to the server's active version.
67    #[validate(range(min = 1))]
68    pub to_version: Option<i32>,
69    /// Secrets to process in this batch. Defaults to 100, clamped to 1000.
70    #[validate(range(min = 1))]
71    pub batch_size: Option<u32>,
72    /// Resume after this secret ID. Omit to start from the beginning.
73    pub after_id: Option<Uuid>,
74}
75
76impl RotateSecretsRequest {
77    /// The batch size to apply, falling back to the default.
78    pub fn effective_batch_size(&self) -> u32 {
79        self.batch_size.unwrap_or(DEFAULT_ROTATION_BATCH_SIZE)
80    }
81}
82
83/// Outcome of one rotation batch.
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[derive(Debug, Serialize)]
86pub struct RotateSecretsResponse {
87    /// Key version the batch re-encrypted towards.
88    pub to_version: i32,
89    /// Secrets successfully re-encrypted in this batch.
90    pub rotated: u64,
91    /// Secrets skipped because they could not be decrypted.
92    pub failed: u64,
93    /// Secrets left on another key version after this batch.
94    pub remaining: u64,
95    /// Highest secret ID seen in this batch. Pass it back as `after_id` to
96    /// continue; `null` means there is nothing left to do.
97    pub last_id: Option<Uuid>,
98}
99
100impl From<RotationBatch> for RotateSecretsResponse {
101    fn from(batch: RotationBatch) -> Self {
102        Self {
103            to_version: batch.to_version,
104            rotated: batch.rotated,
105            failed: batch.failed,
106            remaining: batch.remaining,
107            last_id: batch.last_id,
108        }
109    }
110}
111
112/// How the configured key ring lines up with the stored secrets.
113#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
114#[derive(Debug, Serialize)]
115pub struct KeyVersionsResponse {
116    /// Version used to encrypt new secrets.
117    pub active: i32,
118    /// Versions present in the configured key ring.
119    pub configured: Vec<i32>,
120    /// Versions actually used by stored secrets.
121    pub in_use: Vec<i32>,
122    /// Versions used by stored secrets but absent from the key ring.
123    /// Non-empty means some secrets are unreadable.
124    pub missing: Vec<i32>,
125    /// Versions that can be removed from the key ring safely.
126    pub retirable: Vec<i32>,
127}
128
129impl From<KeyVersionStatus> for KeyVersionsResponse {
130    fn from(status: KeyVersionStatus) -> Self {
131        Self {
132            active: status.active,
133            configured: status.configured,
134            in_use: status.in_use,
135            missing: status.missing,
136            retirable: status.retirable,
137        }
138    }
139}
140
141/// Validate that a secret key contains only safe characters.
142///
143/// Allowed: alphanumeric, `/`, `-`, `_`, `.`
144fn validate_secret_key(key: &str) -> Result<(), validator::ValidationError> {
145    if !key
146        .chars()
147        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
148    {
149        let mut err = validator::ValidationError::new("invalid_characters");
150        err.message =
151            Some("key must only contain alphanumeric characters, '/', '-', '_', '.'".into());
152        return Err(err);
153    }
154    if key.starts_with('/') || key.ends_with('/') || key.contains("//") {
155        let mut err = validator::ValidationError::new("invalid_format");
156        err.message = Some("key must not start/end with '/' or contain '//'".into());
157        return Err(err);
158    }
159    Ok(())
160}