Skip to main content

ironflow_store/entities/
api_key_scope.rs

1//! Scopes for API key permissions.
2
3use serde::{Deserialize, Serialize};
4use strum::{Display, EnumString};
5
6/// Permission scope for an API key.
7///
8/// Each scope grants access to a specific set of actions.
9/// A key with no scopes has no permissions.
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
12#[serde(rename_all = "snake_case")]
13#[strum(serialize_all = "snake_case")]
14pub enum ApiKeyScope {
15    /// Read workflow definitions.
16    WorkflowsRead,
17    /// Read runs and their steps.
18    RunsRead,
19    /// Create new runs (trigger workflows).
20    RunsWrite,
21    /// Cancel, approve, reject, retry runs.
22    RunsManage,
23    /// Read aggregated statistics.
24    StatsRead,
25    /// Full access to all operations.
26    Admin,
27}
28
29impl ApiKeyScope {
30    /// Check whether this scope grants the required permission.
31    pub fn permits(&self, required: &ApiKeyScope) -> bool {
32        match self {
33            ApiKeyScope::Admin => true,
34            other => other == required,
35        }
36    }
37
38    /// Check whether a set of scopes grants the required permission.
39    pub fn has_permission(scopes: &[ApiKeyScope], required: &ApiKeyScope) -> bool {
40        scopes.iter().any(|s| s.permits(required))
41    }
42
43    /// All available scopes (excluding admin).
44    pub fn all_non_admin() -> Vec<ApiKeyScope> {
45        vec![
46            ApiKeyScope::WorkflowsRead,
47            ApiKeyScope::RunsRead,
48            ApiKeyScope::RunsWrite,
49            ApiKeyScope::RunsManage,
50            ApiKeyScope::StatsRead,
51        ]
52    }
53
54    /// Scopes a non-admin member is allowed to use.
55    ///
56    /// Whitelist approach: only these scopes are permitted for members.
57    /// Any scope not listed here is forbidden for non-admin users.
58    pub fn member_allowed() -> &'static [ApiKeyScope] {
59        &[
60            ApiKeyScope::WorkflowsRead,
61            ApiKeyScope::RunsRead,
62            ApiKeyScope::StatsRead,
63        ]
64    }
65
66    /// Check whether all scopes in the set are allowed for a non-admin member.
67    pub fn all_allowed_for_member(scopes: &[ApiKeyScope]) -> bool {
68        let allowed = Self::member_allowed();
69        scopes.iter().all(|s| allowed.contains(s))
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn admin_permits_everything() {
79        let admin = ApiKeyScope::Admin;
80        assert!(admin.permits(&ApiKeyScope::RunsRead));
81        assert!(admin.permits(&ApiKeyScope::RunsWrite));
82        assert!(admin.permits(&ApiKeyScope::RunsManage));
83        assert!(admin.permits(&ApiKeyScope::WorkflowsRead));
84        assert!(admin.permits(&ApiKeyScope::StatsRead));
85        assert!(admin.permits(&ApiKeyScope::Admin));
86    }
87
88    #[test]
89    fn regular_scope_only_permits_itself() {
90        let scope = ApiKeyScope::RunsRead;
91        assert!(scope.permits(&ApiKeyScope::RunsRead));
92        assert!(!scope.permits(&ApiKeyScope::RunsWrite));
93        assert!(!scope.permits(&ApiKeyScope::Admin));
94    }
95
96    #[test]
97    fn has_permission_with_multiple_scopes() {
98        let scopes = vec![ApiKeyScope::RunsRead, ApiKeyScope::WorkflowsRead];
99        assert!(ApiKeyScope::has_permission(&scopes, &ApiKeyScope::RunsRead));
100        assert!(ApiKeyScope::has_permission(
101            &scopes,
102            &ApiKeyScope::WorkflowsRead
103        ));
104        assert!(!ApiKeyScope::has_permission(
105            &scopes,
106            &ApiKeyScope::RunsWrite
107        ));
108    }
109
110    #[test]
111    fn roundtrip_display_parse() {
112        let scopes = vec![
113            ApiKeyScope::WorkflowsRead,
114            ApiKeyScope::RunsRead,
115            ApiKeyScope::RunsWrite,
116            ApiKeyScope::RunsManage,
117            ApiKeyScope::StatsRead,
118            ApiKeyScope::Admin,
119        ];
120        for scope in scopes {
121            let s = scope.to_string();
122            let parsed: ApiKeyScope = s.parse().expect("should parse");
123            assert_eq!(parsed, scope);
124        }
125    }
126
127    #[test]
128    fn parse_invalid_scope() {
129        let result = "invalid".parse::<ApiKeyScope>();
130        assert!(result.is_err());
131    }
132
133    #[test]
134    fn serde_roundtrip() {
135        let scope = ApiKeyScope::RunsWrite;
136        let json = serde_json::to_string(&scope).expect("serialize");
137        assert_eq!(json, "\"runs_write\"");
138        let parsed: ApiKeyScope = serde_json::from_str(&json).expect("deserialize");
139        assert_eq!(parsed, scope);
140    }
141
142    #[test]
143    fn all_non_admin_excludes_admin() {
144        let scopes = ApiKeyScope::all_non_admin();
145        assert!(!scopes.contains(&ApiKeyScope::Admin));
146        assert_eq!(scopes.len(), 5);
147    }
148
149    #[test]
150    fn member_allowed_is_read_only() {
151        let allowed = ApiKeyScope::member_allowed();
152        assert!(allowed.contains(&ApiKeyScope::WorkflowsRead));
153        assert!(allowed.contains(&ApiKeyScope::RunsRead));
154        assert!(allowed.contains(&ApiKeyScope::StatsRead));
155        assert!(!allowed.contains(&ApiKeyScope::RunsWrite));
156        assert!(!allowed.contains(&ApiKeyScope::RunsManage));
157        assert!(!allowed.contains(&ApiKeyScope::Admin));
158    }
159
160    #[test]
161    fn all_allowed_for_member_accepts_read_scopes() {
162        let scopes = vec![ApiKeyScope::WorkflowsRead, ApiKeyScope::RunsRead];
163        assert!(ApiKeyScope::all_allowed_for_member(&scopes));
164    }
165
166    #[test]
167    fn all_allowed_for_member_rejects_write_scopes() {
168        let scopes = vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite];
169        assert!(!ApiKeyScope::all_allowed_for_member(&scopes));
170    }
171
172    #[test]
173    fn all_allowed_for_member_rejects_admin() {
174        let scopes = vec![ApiKeyScope::Admin];
175        assert!(!ApiKeyScope::all_allowed_for_member(&scopes));
176    }
177}