Skip to main content

ironflow_api/entities/
created_by.rs

1//! [`CreatedBy`] — public representation of a run's author.
2
3use ironflow_store::models::{Run, RunActor, TriggerKind};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Number of leading UUID characters kept in a fallback label.
8const ID_PREFIX_LEN: usize = 8;
9
10/// What kind of principal triggered a run.
11#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum CreatedByKind {
15    /// A user authenticated via JWT.
16    User,
17    /// An API key.
18    ApiKey,
19    /// No authenticated caller: cron, webhook, or a programmatic trigger.
20    System,
21}
22
23/// The author of a run, as exposed by the API.
24///
25/// Always present on a [`RunResponse`](crate::entities::RunResponse): a run with
26/// no recorded author (including every run created before authorship tracking)
27/// reports [`CreatedByKind::System`] with a label derived from its trigger, so
28/// clients never have to handle a missing value.
29///
30/// # Examples
31///
32/// ```
33/// use ironflow_api::entities::{CreatedBy, CreatedByKind};
34///
35/// // Built from a Run via `CreatedBy::from(&run)`.
36/// let system = CreatedBy {
37///     kind: CreatedByKind::System,
38///     id: None,
39///     label: "manual".to_string(),
40/// };
41/// assert_eq!(system.id, None);
42/// ```
43#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct CreatedBy {
46    /// What kind of principal triggered the run.
47    pub kind: CreatedByKind,
48    /// Identifier of the principal: the user ID, the API key ID, or `None` for
49    /// [`CreatedByKind::System`].
50    pub id: Option<Uuid>,
51    /// Human-readable label. Never empty.
52    ///
53    /// The username for a user, `"key-name (username)"` for an API key, and the
54    /// trigger description for a system run.
55    pub label: String,
56}
57
58/// Shorten a UUID to its leading characters for a fallback label.
59fn short_id(id: Uuid) -> String {
60    let text = id.to_string();
61    text[..ID_PREFIX_LEN.min(text.len())].to_string()
62}
63
64/// Describe an unauthenticated trigger.
65fn trigger_label(trigger: &TriggerKind) -> String {
66    match trigger {
67        TriggerKind::Manual => "manual".to_string(),
68        TriggerKind::Api => "api".to_string(),
69        TriggerKind::Workflow => "workflow".to_string(),
70        TriggerKind::Retry { .. } => "retry".to_string(),
71        TriggerKind::Replay { .. } => "replay".to_string(),
72        TriggerKind::Webhook { path } => path.clone(),
73        TriggerKind::Cron { schedule, .. } => schedule.clone(),
74        TriggerKind::Nats { subject } => format!("nats:{subject}"),
75        TriggerKind::RunEvent { event_kind, .. } => format!("event:{event_kind}"),
76        TriggerKind::Polling { probe } => format!("polling:{probe}"),
77    }
78}
79
80impl From<&Run> for CreatedBy {
81    fn from(run: &Run) -> Self {
82        match run.created_by {
83            None => CreatedBy {
84                kind: CreatedByKind::System,
85                id: None,
86                label: trigger_label(&run.trigger),
87            },
88            Some(RunActor::User { user_id }) => CreatedBy {
89                kind: CreatedByKind::User,
90                id: Some(user_id),
91                label: run
92                    .created_by_label
93                    .clone()
94                    .unwrap_or_else(|| format!("user {}", short_id(user_id))),
95            },
96            Some(RunActor::ApiKey { api_key_id, .. }) => CreatedBy {
97                kind: CreatedByKind::ApiKey,
98                id: Some(api_key_id),
99                label: run
100                    .created_by_label
101                    .clone()
102                    .unwrap_or_else(|| format!("key {}", short_id(api_key_id))),
103            },
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use std::collections::HashMap;
111
112    use ironflow_store::api_key_store::ApiKeyStore;
113    use ironflow_store::memory::InMemoryStore;
114    use ironflow_store::models::{ApiKeyScope, NewApiKey, NewRun, NewUser};
115    use ironflow_store::store::RunStore;
116    use ironflow_store::user_store::UserStore;
117    use serde_json::json;
118
119    use super::*;
120
121    /// Build a run through the real store, so `created_by_label` is resolved the
122    /// same way the API sees it at runtime.
123    async fn run_with(
124        store: &InMemoryStore,
125        trigger: TriggerKind,
126        created_by: Option<RunActor>,
127    ) -> Run {
128        store
129            .create_run(NewRun {
130                workflow_name: "deploy".to_string(),
131                trigger,
132                payload: json!({}),
133                max_retries: 0,
134                handler_version: None,
135                labels: HashMap::new(),
136                scheduled_at: None,
137                created_by,
138                idempotency_key: None,
139                concurrency_key: None,
140                priority: 0,
141                concurrency_limits: Vec::new(),
142                max_cost_usd: None,
143                worker_tags: Vec::new(),
144            })
145            .await
146            .expect("create run")
147            .into_run()
148    }
149
150    async fn seed_user(store: &InMemoryStore, username: &str) -> Uuid {
151        store
152            .create_user(NewUser {
153                email: format!("{username}@example.com"),
154                username: username.to_string(),
155                password_hash: "hash".to_string(),
156                is_admin: Some(false),
157            })
158            .await
159            .expect("create user")
160            .id
161    }
162
163    async fn seed_api_key(store: &InMemoryStore, user_id: Uuid, name: &str) -> Uuid {
164        store
165            .create_api_key(NewApiKey {
166                user_id,
167                name: name.to_string(),
168                key_hash: "hash".to_string(),
169                key_prefix: "irfl_0000".to_string(),
170                scopes: vec![ApiKeyScope::RunsWrite],
171                expires_at: None,
172                rate_limit_override: None,
173            })
174            .await
175            .expect("create api key")
176            .id
177    }
178
179    #[tokio::test]
180    async fn user_actor_uses_the_resolved_username() {
181        let store = InMemoryStore::new();
182        let user_id = seed_user(&store, "alice").await;
183        let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
184
185        let created_by = CreatedBy::from(&run);
186        assert_eq!(created_by.kind, CreatedByKind::User);
187        assert_eq!(created_by.id, Some(user_id));
188        assert_eq!(created_by.label, "alice");
189    }
190
191    #[tokio::test]
192    async fn user_actor_falls_back_to_a_short_id() {
193        let store = InMemoryStore::new();
194        let user_id = Uuid::now_v7();
195        let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
196
197        let created_by = CreatedBy::from(&run);
198        assert_eq!(created_by.kind, CreatedByKind::User);
199        assert_eq!(created_by.id, Some(user_id));
200        assert_eq!(created_by.label, format!("user {}", short_id(user_id)));
201    }
202
203    #[tokio::test]
204    async fn api_key_actor_exposes_the_key_id_and_a_combined_label() {
205        let store = InMemoryStore::new();
206        let user_id = seed_user(&store, "alice").await;
207        let api_key_id = seed_api_key(&store, user_id, "ci-deploy").await;
208        let run = run_with(
209            &store,
210            TriggerKind::Api,
211            Some(RunActor::ApiKey {
212                api_key_id,
213                user_id,
214            }),
215        )
216        .await;
217
218        let created_by = CreatedBy::from(&run);
219        assert_eq!(created_by.kind, CreatedByKind::ApiKey);
220        assert_eq!(created_by.id, Some(api_key_id));
221        assert_eq!(created_by.label, "ci-deploy (alice)");
222    }
223
224    #[tokio::test]
225    async fn api_key_actor_falls_back_to_a_short_id() {
226        let store = InMemoryStore::new();
227        let api_key_id = Uuid::now_v7();
228        let run = run_with(
229            &store,
230            TriggerKind::Api,
231            Some(RunActor::ApiKey {
232                api_key_id,
233                user_id: Uuid::now_v7(),
234            }),
235        )
236        .await;
237
238        let created_by = CreatedBy::from(&run);
239        assert_eq!(created_by.kind, CreatedByKind::ApiKey);
240        assert_eq!(created_by.label, format!("key {}", short_id(api_key_id)));
241    }
242
243    #[tokio::test]
244    async fn system_label_is_derived_from_every_trigger() {
245        let store = InMemoryStore::new();
246        let cases = [
247            (TriggerKind::Manual, "manual"),
248            (TriggerKind::Api, "api"),
249            (TriggerKind::Workflow, "workflow"),
250            (
251                TriggerKind::Retry {
252                    parent_run_id: Uuid::now_v7(),
253                },
254                "retry",
255            ),
256            (
257                TriggerKind::Replay {
258                    original_run_id: Uuid::now_v7(),
259                },
260                "replay",
261            ),
262            (
263                TriggerKind::Webhook {
264                    path: "/hooks/github".to_string(),
265                },
266                "/hooks/github",
267            ),
268            (
269                TriggerKind::Cron {
270                    schedule: "0 */5 * * * *".to_string(),
271                    schedule_id: None,
272                    scheduled_for: None,
273                },
274                "0 */5 * * * *",
275            ),
276            (
277                TriggerKind::Nats {
278                    subject: "orders.created".to_string(),
279                },
280                "nats:orders.created",
281            ),
282            (
283                TriggerKind::RunEvent {
284                    source_run_id: Uuid::now_v7(),
285                    event_kind: "completed".to_string(),
286                },
287                "event:completed",
288            ),
289            (
290                TriggerKind::Polling {
291                    probe: "http".to_string(),
292                },
293                "polling:http",
294            ),
295        ];
296
297        for (trigger, expected) in cases {
298            let run = run_with(&store, trigger, None).await;
299            let created_by = CreatedBy::from(&run);
300
301            assert_eq!(created_by.kind, CreatedByKind::System);
302            assert_eq!(created_by.id, None);
303            assert_eq!(created_by.label, expected);
304        }
305    }
306
307    #[tokio::test]
308    async fn a_pre_migration_run_reports_a_system_author() {
309        let store = InMemoryStore::new();
310        // A run predating authorship tracking has both columns NULL.
311        let run = run_with(&store, TriggerKind::Manual, None).await;
312
313        let created_by = CreatedBy::from(&run);
314        assert_eq!(created_by.kind, CreatedByKind::System);
315        assert_eq!(created_by.id, None);
316        assert_eq!(created_by.label, "manual");
317    }
318
319    #[tokio::test]
320    async fn label_is_never_empty() {
321        let store = InMemoryStore::new();
322        let run = run_with(
323            &store,
324            TriggerKind::Webhook {
325                path: "/h".to_string(),
326            },
327            None,
328        )
329        .await;
330
331        assert!(!CreatedBy::from(&run).label.is_empty());
332    }
333
334    #[test]
335    fn short_id_keeps_eight_characters() {
336        let id = Uuid::now_v7();
337        assert_eq!(short_id(id).len(), ID_PREFIX_LEN);
338        assert!(id.to_string().starts_with(&short_id(id)));
339    }
340
341    #[test]
342    fn serde_uses_snake_case_kinds() {
343        let created_by = CreatedBy {
344            kind: CreatedByKind::ApiKey,
345            id: Some(Uuid::now_v7()),
346            label: "ci (alice)".to_string(),
347        };
348
349        let json = serde_json::to_value(&created_by).expect("serialize");
350        assert_eq!(json["kind"], "api_key");
351
352        let back: CreatedBy = serde_json::from_value(json).expect("deserialize");
353        assert_eq!(back, created_by);
354    }
355}