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}