Skip to main content

use_authz/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3#![allow(clippy::module_name_repetitions)]
4
5use core::{fmt, str::FromStr};
6use std::error::Error;
7
8/// Error returned when authorization names are invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum AuthzNameError {
11    Empty,
12    NonAscii,
13    InvalidCharacter,
14}
15
16impl fmt::Display for AuthzNameError {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Empty => formatter.write_str("authorization name cannot be empty"),
20            Self::NonAscii => formatter.write_str("authorization name must be ASCII"),
21            Self::InvalidCharacter => {
22                formatter.write_str("authorization name contains an invalid character")
23            }
24        }
25    }
26}
27
28impl Error for AuthzNameError {}
29
30/// Error returned when an authorization label cannot be parsed.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum AuthzParseError {
33    Empty,
34    Unknown,
35}
36
37impl fmt::Display for AuthzParseError {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Empty => formatter.write_str("authorization label cannot be empty"),
41            Self::Unknown => formatter.write_str("unknown authorization label"),
42        }
43    }
44}
45
46impl Error for AuthzParseError {}
47
48macro_rules! ascii_name {
49    ($name:ident) => {
50        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51        pub struct $name(String);
52
53        impl $name {
54            /// Creates a non-empty ASCII-safe authorization name.
55            pub fn new(input: impl AsRef<str>) -> Result<Self, AuthzNameError> {
56                let trimmed = input.as_ref().trim();
57                validate_name(trimmed)?;
58                Ok(Self(trimmed.to_owned()))
59            }
60
61            /// Returns the stored name.
62            #[must_use]
63            pub fn as_str(&self) -> &str {
64                &self.0
65            }
66        }
67
68        impl fmt::Display for $name {
69            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70                formatter.write_str(self.as_str())
71            }
72        }
73
74        impl FromStr for $name {
75            type Err = AuthzNameError;
76
77            fn from_str(input: &str) -> Result<Self, Self::Err> {
78                Self::new(input)
79            }
80        }
81
82        impl TryFrom<&str> for $name {
83            type Error = AuthzNameError;
84
85            fn try_from(value: &str) -> Result<Self, Self::Error> {
86                Self::new(value)
87            }
88        }
89    };
90}
91
92macro_rules! label_enum {
93    ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
94        impl $name {
95            /// Returns the stable label.
96            #[must_use]
97            pub const fn as_str(self) -> &'static str {
98                match self {
99                    $(Self::$variant => $label,)+
100                }
101            }
102        }
103
104        impl fmt::Display for $name {
105            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106                formatter.write_str(self.as_str())
107            }
108        }
109
110        impl FromStr for $name {
111            type Err = AuthzParseError;
112
113            fn from_str(input: &str) -> Result<Self, Self::Err> {
114                let trimmed = input.trim();
115                if trimmed.is_empty() {
116                    return Err(AuthzParseError::Empty);
117                }
118                let normalized = trimmed.to_ascii_lowercase();
119                match normalized.as_str() {
120                    $($label => Ok(Self::$variant),)+
121                    _ => Err(AuthzParseError::Unknown),
122                }
123            }
124        }
125    };
126}
127
128ascii_name!(PermissionName);
129ascii_name!(RoleName);
130ascii_name!(ScopeName);
131ascii_name!(ClaimName);
132ascii_name!(AccessSubject);
133ascii_name!(AccessResource);
134ascii_name!(AccessAction);
135
136/// Authorization model labels.
137#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub enum AuthorizationModel {
139    Rbac,
140    Abac,
141    Rebac,
142    Acl,
143    Capability,
144    PolicyBased,
145    Custom,
146}
147
148label_enum!(AuthorizationModel {
149    Rbac => "rbac",
150    Abac => "abac",
151    Rebac => "rebac",
152    Acl => "acl",
153    Capability => "capability",
154    PolicyBased => "policy-based",
155    Custom => "custom",
156});
157
158/// Access decision labels.
159#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
160pub enum AccessDecision {
161    Allow,
162    Deny,
163    Abstain,
164    NotApplicable,
165}
166
167label_enum!(AccessDecision {
168    Allow => "allow",
169    Deny => "deny",
170    Abstain => "abstain",
171    NotApplicable => "not-applicable",
172});
173
174/// Policy effect labels.
175#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub enum PolicyEffect {
177    Allow,
178    Deny,
179}
180
181label_enum!(PolicyEffect {
182    Allow => "allow",
183    Deny => "deny",
184});
185
186fn validate_name(value: &str) -> Result<(), AuthzNameError> {
187    if value.is_empty() {
188        return Err(AuthzNameError::Empty);
189    }
190    if !value.is_ascii() {
191        return Err(AuthzNameError::NonAscii);
192    }
193    if value.bytes().all(is_ascii_safe_name_byte) {
194        Ok(())
195    } else {
196        Err(AuthzNameError::InvalidCharacter)
197    }
198}
199
200const fn is_ascii_safe_name_byte(byte: u8) -> bool {
201    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'*')
202}
203
204#[cfg(test)]
205mod tests {
206    use super::{
207        AccessAction, AccessDecision, AuthorizationModel, AuthzNameError, PermissionName,
208        PolicyEffect, RoleName,
209    };
210
211    #[test]
212    fn validates_ascii_safe_names() {
213        let permission = PermissionName::new("document:read").expect("permission");
214
215        assert_eq!(permission.as_str(), "document:read");
216        assert_eq!(RoleName::new(" "), Err(AuthzNameError::Empty));
217        assert_eq!(
218            RoleName::new("read write"),
219            Err(AuthzNameError::InvalidCharacter)
220        );
221        assert_eq!(
222            AccessAction::new("lire-ecrire-\u{00e9}"),
223            Err(AuthzNameError::NonAscii)
224        );
225    }
226
227    #[test]
228    fn parses_and_displays_labels() {
229        assert_eq!(
230            "rbac".parse::<AuthorizationModel>().expect("model"),
231            AuthorizationModel::Rbac
232        );
233        assert_eq!(AccessDecision::NotApplicable.to_string(), "not-applicable");
234        assert_eq!(PolicyEffect::Deny.to_string(), "deny");
235    }
236}