Skip to main content

openstack_sdk_auth_core/
authtoken_scope.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14
15//! OpenStack AuthToken Scope handling
16//!
17//! When authenticating with AuthToken user is able to explicitly request scope (authorization)
18//!
19//! - `project` - intention to work with a certain project
20//! - `domain` - intention to work with a certain domain
21//! - `unscoped` - authenticate without any explicit roles
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::{Domain, Project, System};
27
28//use crate::auth::auth_token_endpoint as token_v3;
29
30/// AuthToken (X-Auth-Token) Scope based auth errors
31#[derive(Debug, Error)]
32#[non_exhaustive]
33pub enum AuthTokenScopeError {
34    /// Auth data is missing in the config
35    #[error("Auth data is missing")]
36    MissingAuthData,
37
38    /// Scope cannot be built
39    #[error("Cannot determine authorization scope from config")]
40    MissingScope,
41
42    /// Scope cannot be built
43    #[error(transparent)]
44    //"Cannot determine authorization scope from config")]
45    Builder {
46        #[from]
47        source: crate::BuilderError,
48    },
49}
50
51/// Represents AuthToken authorization scope
52#[derive(Clone, Deserialize, Eq, Hash, PartialEq, Serialize, Debug, Default)]
53#[serde(rename_all = "lowercase")]
54pub enum AuthTokenScope {
55    /// Project
56    Project(Project),
57    /// Domain
58    Domain(Domain),
59    /// System
60    System(System),
61    /// Unscoped
62    #[default]
63    Unscoped,
64}
65
66impl AuthTokenScope {
67    /// Checks if this scope (the requested scope) matches another scope (the cached scope).
68    /// This implements "wildcard" matching: if a field in the requested scope is `None`,
69    /// it matches any value in the cached scope.
70    pub fn matches(&self, cached: &Self) -> bool {
71        match (self, cached) {
72            (AuthTokenScope::Project(req), AuthTokenScope::Project(cached)) => {
73                let id_match = req
74                    .id
75                    .as_ref()
76                    .is_none_or(|id| cached.id.as_ref() == Some(id));
77                let name_match = req
78                    .name
79                    .as_ref()
80                    .is_none_or(|name| cached.name.as_ref() == Some(name));
81                let domain_match = if let Some(req_domain) = &req.domain {
82                    if let Some(cached_domain) = &cached.domain {
83                        let d_id_match = req_domain
84                            .id
85                            .as_ref()
86                            .is_none_or(|id| cached_domain.id.as_ref() == Some(id));
87                        let d_name_match = req_domain
88                            .name
89                            .as_ref()
90                            .is_none_or(|name| cached_domain.name.as_ref() == Some(name));
91                        d_id_match && d_name_match
92                    } else {
93                        true
94                    }
95                } else {
96                    true
97                };
98                id_match && name_match && domain_match
99            }
100            (AuthTokenScope::Domain(req), AuthTokenScope::Domain(cached)) => {
101                let id_match = req
102                    .id
103                    .as_ref()
104                    .is_none_or(|id| cached.id.as_ref() == Some(id));
105                let name_match = req
106                    .name
107                    .as_ref()
108                    .is_none_or(|name| cached.name.as_ref() == Some(name));
109                id_match && name_match
110            }
111            (AuthTokenScope::Unscoped, AuthTokenScope::Unscoped) => true,
112            (AuthTokenScope::System(_), AuthTokenScope::System(_)) => true,
113            _ => false,
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn make_project_scope(id: Option<&str>, name: Option<&str>) -> AuthTokenScope {
123        AuthTokenScope::Project(Project {
124            id: id.map(|s| s.to_string()),
125            name: name.map(|s| s.to_string()),
126            domain: None,
127        })
128    }
129
130    fn make_domain_scope(id: Option<&str>, name: Option<&str>) -> AuthTokenScope {
131        AuthTokenScope::Domain(Domain {
132            id: id.map(|s| s.to_string()),
133            name: name.map(|s| s.to_string()),
134        })
135    }
136
137    // Project scope tests
138    #[test]
139    fn test_matches_project_exact_id() {
140        let req = make_project_scope(Some("proj-1"), None);
141        let cached = make_project_scope(Some("proj-1"), None);
142        assert!(req.matches(&cached));
143    }
144
145    #[test]
146    fn test_matches_project_id_mismatch() {
147        let req = make_project_scope(Some("proj-1"), None);
148        let cached = make_project_scope(Some("proj-2"), None);
149        assert!(!req.matches(&cached));
150    }
151
152    #[test]
153    fn test_matches_project_req_id_none_matches_cached_id_some() {
154        let req = make_project_scope(None, Some("myproj"));
155        let cached = make_project_scope(Some("proj-1"), Some("myproj"));
156        assert!(req.matches(&cached));
157    }
158
159    #[test]
160    fn test_matches_project_req_name_none_matches_cached_name() {
161        let req = make_project_scope(None, None);
162        let cached = make_project_scope(Some("proj-1"), Some("myproj"));
163        assert!(req.matches(&cached));
164    }
165
166    #[test]
167    fn test_matches_project_name_mismatch_when_req_name_some() {
168        let req = make_project_scope(None, Some("req-name"));
169        let cached = make_project_scope(Some("proj-1"), Some("cached-name"));
170        assert!(!req.matches(&cached));
171    }
172
173    #[test]
174    fn test_matches_project_both_none() {
175        let req = make_project_scope(None, None);
176        let cached = make_project_scope(None, None);
177        assert!(req.matches(&cached));
178    }
179
180    // Domain scope tests
181    #[test]
182    fn test_matches_domain_exact_id() {
183        let req = make_domain_scope(Some("d1"), None);
184        let cached = make_domain_scope(Some("d1"), None);
185        assert!(req.matches(&cached));
186    }
187
188    #[test]
189    fn test_matches_domain_id_mismatch() {
190        let req = make_domain_scope(Some("d1"), None);
191        let cached = make_domain_scope(Some("d2"), None);
192        assert!(!req.matches(&cached));
193    }
194
195    #[test]
196    fn test_matches_domain_req_id_none_matches_cached() {
197        let req = make_domain_scope(None, Some("D"));
198        let cached = make_domain_scope(Some("d1"), Some("D"));
199        assert!(req.matches(&cached));
200    }
201
202    #[test]
203    fn test_matches_domain_name_mismatch_when_req_name_some() {
204        let req = make_domain_scope(None, Some("req-D"));
205        let cached = make_domain_scope(Some("d1"), Some("cached-D"));
206        assert!(!req.matches(&cached));
207    }
208
209    // Cross-type tests
210    #[test]
211    fn test_matches_project_does_not_match_domain() {
212        let req = make_project_scope(Some("p1"), None);
213        let cached = make_domain_scope(Some("p1"), None);
214        assert!(!req.matches(&cached));
215    }
216
217    #[test]
218    fn test_matches_domain_does_not_match_project() {
219        let req = make_domain_scope(Some("d1"), None);
220        let cached = make_project_scope(Some("d1"), None);
221        assert!(!req.matches(&cached));
222    }
223
224    #[test]
225    fn test_matches_unscoped_matches_unscoped() {
226        let req = AuthTokenScope::Unscoped;
227        let cached = AuthTokenScope::Unscoped;
228        assert!(req.matches(&cached));
229    }
230
231    #[test]
232    fn test_matches_unscoped_does_not_match_project() {
233        let req = AuthTokenScope::Unscoped;
234        let cached = make_project_scope(Some("p1"), None);
235        assert!(!req.matches(&cached));
236    }
237
238    // Domain-within-project tests
239    #[test]
240    fn test_matches_project_with_domain_exact() {
241        let req = AuthTokenScope::Project(Project {
242            id: Some("project-id".to_string()),
243            name: None,
244            domain: Some(Domain {
245                id: Some("domain-id".to_string()),
246                name: None,
247            }),
248        });
249        let cached = AuthTokenScope::Project(Project {
250            id: Some("project-id".to_string()),
251            name: None,
252            domain: Some(Domain {
253                id: Some("domain-id".to_string()),
254                name: Some("D".to_string()),
255            }),
256        });
257        assert!(req.matches(&cached));
258    }
259
260    #[test]
261    fn test_matches_project_with_domain_mismatch() {
262        let req = AuthTokenScope::Project(Project {
263            id: Some("project-id".to_string()),
264            name: None,
265            domain: Some(Domain {
266                id: Some("domain-1".to_string()),
267                name: None,
268            }),
269        });
270        let cached = AuthTokenScope::Project(Project {
271            id: Some("project-id".to_string()),
272            name: None,
273            domain: Some(Domain {
274                id: Some("domain-2".to_string()),
275                name: Some("D".to_string()),
276            }),
277        });
278        assert!(!req.matches(&cached));
279    }
280
281    #[test]
282    fn test_matches_project_cached_has_domain_req_has_no_domain() {
283        let req = AuthTokenScope::Project(Project {
284            id: Some("project-id".to_string()),
285            name: None,
286            domain: None,
287        });
288        let cached = AuthTokenScope::Project(Project {
289            id: Some("project-id".to_string()),
290            name: None,
291            domain: Some(Domain {
292                id: Some("domain-id".to_string()),
293                name: None,
294            }),
295        });
296        assert!(req.matches(&cached));
297    }
298
299    #[test]
300    fn test_matches_project_req_domain_by_name() {
301        let req = AuthTokenScope::Project(Project {
302            id: None,
303            name: Some("myproj".to_string()),
304            domain: Some(Domain {
305                id: None,
306                name: Some("Default".to_string()),
307            }),
308        });
309        let cached = AuthTokenScope::Project(Project {
310            id: Some("proj-123".to_string()),
311            name: Some("myproj".to_string()),
312            domain: Some(Domain {
313                id: Some("d1".to_string()),
314                name: Some("Default".to_string()),
315            }),
316        });
317        assert!(req.matches(&cached));
318    }
319}