Skip to main content

ironflow_store/entities/
secret.rs

1//! Secret entity for encrypted key-value storage.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// An encrypted secret stored in the database.
8///
9/// The `value` field contains the **plaintext** after decryption.
10/// Raw ciphertext and nonce are internal to the store implementations.
11///
12/// # Examples
13///
14/// ```
15/// use ironflow_store::entities::Secret;
16/// use chrono::Utc;
17/// use uuid::Uuid;
18///
19/// let secret = Secret {
20///     id: Uuid::now_v7(),
21///     key: "workflows/inbox/gmail_refresh_token".to_string(),
22///     value: "ya29.a0AfH6SM...".to_string(),
23///     created_at: Utc::now(),
24///     updated_at: Utc::now(),
25/// };
26/// assert!(secret.key.starts_with("workflows/"));
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Secret {
30    /// Unique secret ID (UUID v7).
31    pub id: Uuid,
32    /// Unique key, typically namespaced (e.g. `workflows/<name>/<secret_name>`).
33    pub key: String,
34    /// Decrypted plaintext value.
35    #[serde(skip_serializing)]
36    pub value: String,
37    /// When the secret was first created.
38    pub created_at: DateTime<Utc>,
39    /// When the secret was last updated.
40    pub updated_at: DateTime<Utc>,
41}
42
43/// Metadata about a secret, without the decrypted value.
44///
45/// Used for listing secrets in the API/dashboard where the value
46/// must never be exposed.
47///
48/// # Examples
49///
50/// ```
51/// use ironflow_store::entities::SecretMetadata;
52/// use chrono::Utc;
53/// use uuid::Uuid;
54///
55/// let meta = SecretMetadata {
56///     id: Uuid::now_v7(),
57///     key: "workflows/inbox/gmail_refresh_token".to_string(),
58///     created_at: Utc::now(),
59///     updated_at: Utc::now(),
60/// };
61/// ```
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SecretMetadata {
64    /// Unique secret ID (UUID v7).
65    pub id: Uuid,
66    /// Unique key.
67    pub key: String,
68    /// When the secret was first created.
69    pub created_at: DateTime<Utc>,
70    /// When the secret was last updated.
71    pub updated_at: DateTime<Utc>,
72}
73
74/// Default number of secrets re-encrypted per rotation batch.
75pub const DEFAULT_ROTATION_BATCH_SIZE: u32 = 100;
76
77/// Largest batch a single rotation call may process.
78pub const MAX_ROTATION_BATCH_SIZE: u32 = 1000;
79
80/// One batch of a key rotation.
81///
82/// Rotation is driven batch by batch by the caller rather than in one long
83/// call, so that a rotation can be watched, interrupted, and resumed.
84///
85/// # Examples
86///
87/// ```
88/// use ironflow_store::entities::RotationRequest;
89///
90/// // Start from the beginning of the stock.
91/// let first = RotationRequest::new(2);
92/// assert!(first.after_id.is_none());
93///
94/// // Continue after the last secret seen.
95/// let next = first.clone().after(uuid::Uuid::now_v7());
96/// assert!(next.after_id.is_some());
97/// ```
98#[derive(Debug, Clone)]
99pub struct RotationRequest {
100    /// Key version every secret in the batch is re-encrypted with.
101    pub to_version: i32,
102    /// How many secrets to process, clamped to
103    /// `[1, MAX_ROTATION_BATCH_SIZE]`.
104    pub batch_size: u32,
105    /// Resume after this secret ID. `None` starts from the beginning.
106    ///
107    /// The cursor is what keeps the rotation moving forward when a secret
108    /// cannot be decrypted: a failed row is never served again.
109    pub after_id: Option<Uuid>,
110}
111
112impl RotationRequest {
113    /// A request for the first batch, with the default batch size.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use ironflow_store::entities::{RotationRequest, DEFAULT_ROTATION_BATCH_SIZE};
119    ///
120    /// let req = RotationRequest::new(2);
121    /// assert_eq!(req.to_version, 2);
122    /// assert_eq!(req.batch_size, DEFAULT_ROTATION_BATCH_SIZE);
123    /// ```
124    pub fn new(to_version: i32) -> Self {
125        Self {
126            to_version,
127            batch_size: DEFAULT_ROTATION_BATCH_SIZE,
128            after_id: None,
129        }
130    }
131
132    /// Set the batch size.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use ironflow_store::entities::RotationRequest;
138    ///
139    /// let req = RotationRequest::new(2).with_batch_size(10);
140    /// assert_eq!(req.batch_size, 10);
141    /// ```
142    pub fn with_batch_size(mut self, batch_size: u32) -> Self {
143        self.batch_size = batch_size;
144        self
145    }
146
147    /// Resume after a given secret ID.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use ironflow_store::entities::RotationRequest;
153    /// use uuid::Uuid;
154    ///
155    /// let id = Uuid::now_v7();
156    /// let req = RotationRequest::new(2).after(id);
157    /// assert_eq!(req.after_id, Some(id));
158    /// ```
159    pub fn after(mut self, id: Uuid) -> Self {
160        self.after_id = Some(id);
161        self
162    }
163
164    /// The batch size clamped to the allowed range.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use ironflow_store::entities::{RotationRequest, MAX_ROTATION_BATCH_SIZE};
170    ///
171    /// assert_eq!(RotationRequest::new(2).with_batch_size(0).effective_batch_size(), 1);
172    /// assert_eq!(
173    ///     RotationRequest::new(2).with_batch_size(99_999).effective_batch_size(),
174    ///     MAX_ROTATION_BATCH_SIZE
175    /// );
176    /// ```
177    pub fn effective_batch_size(&self) -> u32 {
178        self.batch_size.clamp(1, MAX_ROTATION_BATCH_SIZE)
179    }
180}
181
182/// Outcome of one rotation batch.
183///
184/// # Examples
185///
186/// ```
187/// use ironflow_store::entities::RotationBatch;
188///
189/// let batch = RotationBatch {
190///     to_version: 2,
191///     rotated: 98,
192///     failed: 2,
193///     remaining: 348,
194///     last_id: Some(uuid::Uuid::now_v7()),
195/// };
196/// assert!(!batch.is_complete());
197/// ```
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct RotationBatch {
200    /// Key version the batch re-encrypted towards.
201    pub to_version: i32,
202    /// Secrets successfully re-encrypted in this batch.
203    pub rotated: u64,
204    /// Secrets skipped because they could not be decrypted.
205    pub failed: u64,
206    /// Secrets left on another key version after this batch.
207    pub remaining: u64,
208    /// Highest secret ID seen in this batch, failures included.
209    ///
210    /// Pass it back as [`RotationRequest::after_id`] to continue. `None`
211    /// means the batch was empty: there is nothing left to do.
212    pub last_id: Option<Uuid>,
213}
214
215impl RotationBatch {
216    /// Whether the rotation has nothing left to process.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use ironflow_store::entities::RotationBatch;
222    ///
223    /// let done = RotationBatch {
224    ///     to_version: 2,
225    ///     rotated: 0,
226    ///     failed: 0,
227    ///     remaining: 0,
228    ///     last_id: None,
229    /// };
230    /// assert!(done.is_complete());
231    /// ```
232    pub fn is_complete(&self) -> bool {
233        self.last_id.is_none() || self.remaining == 0
234    }
235}
236
237/// How the configured key ring lines up with what the stored secrets use.
238///
239/// This is what tells an operator whether an old key can be dropped from the
240/// configuration, without having to guess.
241///
242/// # Examples
243///
244/// ```
245/// use ironflow_store::entities::KeyVersionStatus;
246///
247/// let status = KeyVersionStatus {
248///     active: 2,
249///     configured: vec![1, 2],
250///     in_use: vec![2],
251///     missing: vec![],
252///     retirable: vec![1],
253/// };
254/// assert!(status.is_consistent());
255/// ```
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct KeyVersionStatus {
258    /// Version used to encrypt new secrets.
259    pub active: i32,
260    /// Versions present in the configured key ring, ascending.
261    pub configured: Vec<i32>,
262    /// Versions actually used by stored secrets, ascending.
263    pub in_use: Vec<i32>,
264    /// Versions used by stored secrets but absent from the key ring.
265    ///
266    /// Non-empty means some secrets are unreadable: the server refuses to
267    /// start in that state.
268    pub missing: Vec<i32>,
269    /// Versions that can be removed from the key ring safely: configured,
270    /// not active, and unused by any secret.
271    pub retirable: Vec<i32>,
272}
273
274impl KeyVersionStatus {
275    /// Whether every stored secret can be decrypted with the current ring.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use ironflow_store::entities::KeyVersionStatus;
281    ///
282    /// let broken = KeyVersionStatus {
283    ///     active: 1,
284    ///     configured: vec![1],
285    ///     in_use: vec![1, 2],
286    ///     missing: vec![2],
287    ///     retirable: vec![],
288    /// };
289    /// assert!(!broken.is_consistent());
290    /// ```
291    pub fn is_consistent(&self) -> bool {
292        self.missing.is_empty()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn secret_serde_excludes_value() {
302        let secret = Secret {
303            id: Uuid::now_v7(),
304            key: "test/my-secret".to_string(),
305            value: "super-secret-value-should-not-appear".to_string(),
306            created_at: Utc::now(),
307            updated_at: Utc::now(),
308        };
309
310        let json = serde_json::to_string(&secret).expect("serialize");
311        assert!(!json.contains("super-secret-value-should-not-appear"));
312        assert!(json.contains("test/my-secret"));
313    }
314
315    #[test]
316    fn secret_preserves_value_in_struct() {
317        let secret = Secret {
318            id: Uuid::now_v7(),
319            key: "k".to_string(),
320            value: "v".to_string(),
321            created_at: Utc::now(),
322            updated_at: Utc::now(),
323        };
324        assert_eq!(secret.value, "v");
325    }
326
327    #[test]
328    fn secret_metadata_serde_has_no_value() {
329        let meta = SecretMetadata {
330            id: Uuid::now_v7(),
331            key: "my/key".to_string(),
332            created_at: Utc::now(),
333            updated_at: Utc::now(),
334        };
335
336        let json = serde_json::to_string(&meta).expect("serialize");
337        assert!(json.contains("my/key"));
338        assert!(!json.contains("value"));
339    }
340}