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#[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#[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 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 #[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 #[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#[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#[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#[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}