Skip to main content

ironflow_api/
authorization.rs

1//! Per-route authorization of an authenticated caller.
2//!
3//! Every user-facing handler starts with [`AuthorizeExt::require`]: an API key
4//! must hold the route's [`ApiKeyScope`] (or `admin`), and admin-only routes
5//! also need an admin user behind the session or the key. JWT sessions are
6//! not scope-checked, only the admin flag applies to them.
7
8use ironflow_auth::extractor::{AuthMethod, Authenticated};
9use ironflow_store::entities::ApiKeyScope;
10
11use crate::error::ApiError;
12
13/// Scope and admin checks on an [`Authenticated`] caller.
14///
15/// Lives in `ironflow-api` rather than `ironflow-auth` because it returns
16/// [`ApiError`], which `ironflow-auth` cannot depend on.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_api::authorization::AuthorizeExt;
22/// use ironflow_api::error::ApiError;
23/// use ironflow_auth::extractor::{AuthMethod, Authenticated};
24/// use ironflow_store::entities::ApiKeyScope;
25/// use uuid::Uuid;
26///
27/// # fn example() -> Result<(), ApiError> {
28/// let auth = Authenticated {
29///     user_id: Uuid::now_v7(),
30///     method: AuthMethod::ApiKey {
31///         key_id: Uuid::now_v7(),
32///         key_name: "ci".to_string(),
33///         scopes: vec![ApiKeyScope::RunsRead],
34///         owner_is_admin: false,
35///     },
36/// };
37/// auth.require(ApiKeyScope::RunsRead, false)?;
38/// # Ok(())
39/// # }
40/// ```
41pub trait AuthorizeExt {
42    /// Check that the caller may use a route needing `scope`.
43    ///
44    /// An API key must hold `scope` (the `admin` scope implies every scope).
45    /// When `admin_only` is set, the caller must also be an admin: a JWT
46    /// session must belong to an admin, and an API key must be owned by an
47    /// admin (read at request time, so a demotion takes effect immediately).
48    /// The scope is checked before the admin flag.
49    ///
50    /// # Errors
51    ///
52    /// - [`ApiError::InsufficientScope`] if an API key lacks `scope`
53    /// - [`ApiError::Forbidden`] if `admin_only` is set and the caller (or the
54    ///   key owner) is not an admin
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use ironflow_api::authorization::AuthorizeExt;
60    /// use ironflow_api::error::ApiError;
61    /// use ironflow_auth::extractor::{AuthMethod, Authenticated};
62    /// use ironflow_store::entities::ApiKeyScope;
63    /// use uuid::Uuid;
64    ///
65    /// # fn example() -> Result<(), ApiError> {
66    /// let auth = Authenticated {
67    ///     user_id: Uuid::now_v7(),
68    ///     method: AuthMethod::Jwt {
69    ///         username: "alice".to_string(),
70    ///         is_admin: true,
71    ///     },
72    /// };
73    /// auth.require(ApiKeyScope::Admin, true)?;
74    /// # Ok(())
75    /// # }
76    /// ```
77    fn require(&self, scope: ApiKeyScope, admin_only: bool) -> Result<(), ApiError>;
78}
79
80impl AuthorizeExt for Authenticated {
81    fn require(&self, scope: ApiKeyScope, admin_only: bool) -> Result<(), ApiError> {
82        match &self.method {
83            AuthMethod::Jwt { is_admin, .. } => {
84                if admin_only && !is_admin {
85                    return Err(ApiError::Forbidden);
86                }
87            }
88            AuthMethod::ApiKey {
89                scopes,
90                owner_is_admin,
91                ..
92            } => {
93                if !ApiKeyScope::has_permission(scopes, &scope) {
94                    return Err(ApiError::InsufficientScope);
95                }
96                if admin_only && !owner_is_admin {
97                    return Err(ApiError::Forbidden);
98                }
99            }
100        }
101        Ok(())
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use uuid::Uuid;
108
109    use super::*;
110
111    fn jwt(is_admin: bool) -> Authenticated {
112        Authenticated {
113            user_id: Uuid::now_v7(),
114            method: AuthMethod::Jwt {
115                username: "user".to_string(),
116                is_admin,
117            },
118        }
119    }
120
121    fn key(scopes: Vec<ApiKeyScope>, owner_is_admin: bool) -> Authenticated {
122        Authenticated {
123            user_id: Uuid::now_v7(),
124            method: AuthMethod::ApiKey {
125                key_id: Uuid::now_v7(),
126                key_name: "key".to_string(),
127                scopes,
128                owner_is_admin,
129            },
130        }
131    }
132
133    #[test]
134    fn scope_jwt_member_on_admin_only_route_is_forbidden() {
135        let result = jwt(false).require(ApiKeyScope::RunsManage, true);
136        assert!(matches!(result, Err(ApiError::Forbidden)));
137    }
138
139    #[test]
140    fn scope_jwt_member_on_regular_route_passes() {
141        assert!(jwt(false).require(ApiKeyScope::RunsRead, false).is_ok());
142    }
143
144    #[test]
145    fn scope_jwt_admin_passes_admin_only_route() {
146        assert!(jwt(true).require(ApiKeyScope::Admin, true).is_ok());
147    }
148
149    #[test]
150    fn scope_key_with_another_scope_is_insufficient() {
151        let auth = key(vec![ApiKeyScope::StatsRead], true);
152        let result = auth.require(ApiKeyScope::RunsRead, false);
153        assert!(matches!(result, Err(ApiError::InsufficientScope)));
154    }
155
156    #[test]
157    fn scope_key_with_the_exact_scope_passes() {
158        let auth = key(vec![ApiKeyScope::RunsRead], false);
159        assert!(auth.require(ApiKeyScope::RunsRead, false).is_ok());
160    }
161
162    #[test]
163    fn scope_admin_key_satisfies_any_scope() {
164        let auth = key(vec![ApiKeyScope::Admin], true);
165        for scope in ApiKeyScope::all_non_admin() {
166            assert!(auth.require(scope, false).is_ok());
167        }
168        assert!(auth.require(ApiKeyScope::Admin, true).is_ok());
169    }
170
171    #[test]
172    fn scope_key_of_member_on_admin_only_route_is_forbidden() {
173        let auth = key(vec![ApiKeyScope::RunsManage], false);
174        let result = auth.require(ApiKeyScope::RunsManage, true);
175        assert!(matches!(result, Err(ApiError::Forbidden)));
176    }
177
178    #[test]
179    fn scope_is_checked_before_the_owner_admin_flag() {
180        let auth = key(vec![ApiKeyScope::StatsRead], false);
181        let result = auth.require(ApiKeyScope::RunsManage, true);
182        assert!(matches!(result, Err(ApiError::InsufficientScope)));
183    }
184
185    #[test]
186    fn scope_admin_key_of_member_is_forbidden_on_admin_only_route() {
187        let auth = key(vec![ApiKeyScope::Admin], false);
188        let result = auth.require(ApiKeyScope::Admin, true);
189        assert!(matches!(result, Err(ApiError::Forbidden)));
190    }
191}