webgates_core/permissions/mapping.rs
1use crate::permissions::permission_id::PermissionId;
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Mapping between a permission name and its deterministic permission ID.
7///
8/// This type stores the relationship between:
9/// - the normalized permission string (trimmed and lowercased)
10/// - the computed 64-bit permission ID used in bitmap storage
11///
12/// # Purpose
13///
14/// This mapping enables reverse lookup from permission IDs back to their
15/// normalized string representations, which is useful for:
16/// - Debugging and logging
17/// - Administrative interfaces
18/// - Audit trails
19/// - Permission reporting
20///
21/// # Design Principles
22///
23/// - Immutable once created; construct via `From<&str>`/`From<String>` or `PermissionMapping::new(original, id)`
24/// - Contains only the normalized string and computed ID (the original input form is not retained)
25/// - Validates consistency between string and ID during construction with `new`; `validate()` can be used to re-check invariants
26///
27/// # Examples
28///
29/// ```rust
30/// use webgates_core::permissions::permission_id::PermissionId;
31/// use webgates_core::permissions::mapping::PermissionMapping;
32///
33/// // Create from a permission string
34/// let mapping = PermissionMapping::from("Read:API");
35/// assert_eq!(mapping.normalized_string(), "read:api");
36/// assert_eq!(mapping.permission_id(), PermissionId::from("Read:API"));
37///
38/// // Create from components (useful for deserialization)
39/// let id = PermissionId::from("write:file");
40/// let mapping = PermissionMapping::new("Write:File", id).unwrap();
41/// ```
42///
43/// # Validation
44///
45/// The mapping validates that the provided permission ID actually corresponds
46/// to the normalized string to prevent inconsistent state.
47///
48/// # Construction
49///
50/// Prefer `PermissionMapping::from(<&str|String>)` when you have the permission
51/// in string form. Use `PermissionMapping::new(original, id)` when deserializing
52/// or when both pieces are provided and must be validated.
53///
54/// # Serialization
55///
56/// This type derives `Serialize`/`Deserialize`. The serialized shape contains
57/// `normalized_string` and `permission_id`.
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct PermissionMapping {
60 /// The normalized permission string (trimmed and lowercased)
61 normalized_string: String,
62 /// The computed 64-bit permission ID
63 permission_id: PermissionId,
64}
65
66impl PermissionMapping {
67 /// Creates a new mapping from a permission string and an existing ID.
68 ///
69 /// This constructor validates that the provided ID actually corresponds to
70 /// the normalized string so inconsistent state cannot be created by mistake.
71 ///
72 /// # Arguments
73 ///
74 /// * `original` - The original permission string as provided
75 /// * `id` - The permission ID that must correspond to the normalized form of `original`
76 ///
77 /// Normalization is handled internally from `original` (trim + lowercase)
78 /// # Returns
79 ///
80 /// Returns `Ok(PermissionMapping)` if the ID matches the normalized string,
81 /// or `Err(PermissionMappingError)` if there's a mismatch.
82 ///
83 /// # Examples
84 ///
85 /// ```rust
86 /// use webgates_core::permissions::permission_id::PermissionId;
87 /// use webgates_core::permissions::mapping::PermissionMapping;
88 ///
89 /// let id = PermissionId::from("read:api");
90 /// let mapping = PermissionMapping::new("Read:API", id).unwrap();
91 /// ```
92 pub fn new(
93 original: impl Into<String>,
94 id: PermissionId,
95 ) -> Result<Self, PermissionMappingError> {
96 let original_string: String = original.into();
97 let normalized_string = Self::normalize_permission(&original_string);
98
99 // Validate that the ID corresponds to the normalized string
100 let expected_id = PermissionId::from(normalized_string.as_str());
101 if id != expected_id {
102 return Err(PermissionMappingError::IdMismatch {
103 normalized_string: normalized_string.clone(),
104 provided_id: id.as_u64(),
105 expected_id: expected_id.as_u64(),
106 });
107 }
108
109 Ok(Self {
110 normalized_string,
111 permission_id: id,
112 })
113 }
114
115 /// Returns the normalized permission string.
116 ///
117 /// The normalized form is trimmed and lowercased. This is the exact value
118 /// used to compute the permission ID.
119 pub fn normalized_string(&self) -> &str {
120 &self.normalized_string
121 }
122
123 /// Returns the computed permission ID.
124 ///
125 /// This is the 64-bit identifier that would be stored in the permissions bitmap.
126 pub fn permission_id(&self) -> PermissionId {
127 self.permission_id
128 }
129
130 /// Returns the permission ID as a raw `u64` value.
131 ///
132 /// This is a convenience method for storage, diagnostics, or comparisons.
133 pub fn id_as_u64(&self) -> u64 {
134 self.permission_id.as_u64()
135 }
136
137 /// Checks whether this mapping corresponds to the given permission string.
138 ///
139 /// The comparison uses the normalized form of the input, so it ignores case
140 /// and surrounding whitespace differences.
141 ///
142 /// # Examples
143 ///
144 /// ```rust
145 /// use webgates_core::permissions::mapping::PermissionMapping;
146 ///
147 /// let mapping = PermissionMapping::from("read:api");
148 /// assert!(mapping.matches_string("READ:API"));
149 /// assert!(mapping.matches_string(" read:api "));
150 /// assert!(!mapping.matches_string("write:api"));
151 /// ```
152 pub fn matches_string(&self, permission: &str) -> bool {
153 let normalized = Self::normalize_permission(permission);
154 self.normalized_string == normalized
155 }
156
157 /// Checks whether this mapping corresponds to the given permission ID.
158 ///
159 /// # Examples
160 ///
161 /// ```rust
162 /// use webgates_core::permissions::permission_id::PermissionId;
163 /// use webgates_core::permissions::mapping::PermissionMapping;
164 ///
165 /// let mapping = PermissionMapping::from("read:api");
166 /// let id = PermissionId::from("read:api");
167 /// assert!(mapping.matches_id(id));
168 /// ```
169 pub fn matches_id(&self, id: PermissionId) -> bool {
170 self.permission_id == id
171 }
172
173 /// Validates that this mapping is internally consistent.
174 ///
175 /// This checks that the permission ID actually corresponds to the
176 /// normalized string, which should always be true for properly
177 /// constructed mappings.
178 ///
179 /// Note: Calling this is typically only necessary when a mapping is created
180 /// via serde deserialization. Constructors from strings (`From<&str>`/`From<String>`)
181 /// and `PermissionMapping::new(original, id)` enforce the invariant at creation time.
182 ///
183 /// Returns `Ok(())` if consistent, or `Err(PermissionMappingError)` if not.
184 pub fn validate(&self) -> Result<(), PermissionMappingError> {
185 let expected_id = PermissionId::from(self.normalized_string.as_str());
186 if self.permission_id != expected_id {
187 return Err(PermissionMappingError::IdMismatch {
188 normalized_string: self.normalized_string.clone(),
189 provided_id: self.permission_id.as_u64(),
190 expected_id: expected_id.as_u64(),
191 });
192 }
193 Ok(())
194 }
195
196 /// Normalize a permission name (trim + lowercase).
197 ///
198 /// This function implements the same normalization logic used in
199 /// the PermissionId implementation to ensure consistency.
200 fn normalize_permission(input: &str) -> String {
201 input.trim().to_lowercase()
202 }
203}
204
205impl fmt::Display for PermissionMapping {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 write!(
208 f,
209 "PermissionMapping(normalized: '{}', id: {})",
210 self.normalized_string,
211 self.permission_id.as_u64()
212 )
213 }
214}
215
216impl From<&str> for PermissionMapping {
217 fn from(permission: &str) -> Self {
218 Self::from(permission.to_string())
219 }
220}
221
222impl From<String> for PermissionMapping {
223 fn from(permission: String) -> Self {
224 let normalized_string = Self::normalize_permission(&permission);
225 let permission_id = PermissionId::from(normalized_string.as_str());
226
227 Self {
228 normalized_string,
229 permission_id,
230 }
231 }
232}
233
234/// Errors that can occur when constructing or validating permission mappings.
235#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
236pub enum PermissionMappingError {
237 /// The provided permission ID doesn't match the normalized string.
238 #[error(
239 "Permission ID mismatch: normalized string '{normalized_string}' should produce ID {expected_id}, but got {provided_id}"
240 )]
241 IdMismatch {
242 /// The normalized permission string that was used for ID computation
243 normalized_string: String,
244 /// The permission ID that was provided
245 provided_id: u64,
246 /// The permission ID that should have been computed from the normalized string
247 expected_id: u64,
248 },
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn from_string_creates_valid_mapping() {
257 let mapping = PermissionMapping::from("Read:API");
258 assert_eq!(mapping.normalized_string(), "read:api");
259 assert_eq!(mapping.permission_id(), PermissionId::from("read:api"));
260 }
261
262 #[test]
263 fn from_string_handles_whitespace() {
264 let mapping = PermissionMapping::from(" Write:File ");
265 assert_eq!(mapping.normalized_string(), "write:file");
266 }
267
268 #[test]
269 fn new_validates_consistency() {
270 let id = PermissionId::from("read:api");
271 let mapping = PermissionMapping::new("Read:API", id);
272 assert!(mapping.is_ok());
273 }
274
275 #[test]
276 fn new_rejects_inconsistent_id() {
277 let id = PermissionId::from("write:api");
278 let result = PermissionMapping::new("Read:API", id);
279 assert!(result.is_err());
280
281 if let Err(PermissionMappingError::IdMismatch {
282 normalized_string,
283 provided_id,
284 expected_id,
285 }) = result
286 {
287 assert_eq!(normalized_string, "read:api");
288 assert_eq!(provided_id, PermissionId::from("write:api").as_u64());
289 assert_eq!(expected_id, PermissionId::from("read:api").as_u64());
290 }
291 }
292
293 #[test]
294 fn matches_string_works_with_normalization() {
295 let mapping = PermissionMapping::from("read:api");
296 assert!(mapping.matches_string("READ:API"));
297 assert!(mapping.matches_string(" read:api "));
298 assert!(mapping.matches_string("Read:Api"));
299 assert!(!mapping.matches_string("write:api"));
300 }
301
302 #[test]
303 fn matches_id_works_correctly() {
304 let mapping = PermissionMapping::from("read:api");
305 let matching_id = PermissionId::from("read:api");
306 let different_id = PermissionId::from("write:api");
307
308 assert!(mapping.matches_id(matching_id));
309 assert!(!mapping.matches_id(different_id));
310 }
311
312 #[test]
313 fn validate_passes_for_consistent_mapping() {
314 let mapping = PermissionMapping::from("read:api");
315 assert!(mapping.validate().is_ok());
316 }
317
318 #[test]
319 fn display_shows_all_components() {
320 let mapping = PermissionMapping::from("Read:API");
321 let display = format!("{}", mapping);
322
323 assert!(display.contains("read:api"));
324 assert!(display.contains(&mapping.id_as_u64().to_string()));
325 }
326
327 #[test]
328 fn mapping_equality_works() {
329 let mapping1 = PermissionMapping::from("read:api");
330 let mapping2 = PermissionMapping::from("READ:API");
331
332 // These should be equal because they have the same normalized form
333 assert_eq!(mapping1.normalized_string(), mapping2.normalized_string());
334 assert_eq!(mapping1.permission_id(), mapping2.permission_id());
335
336 // And they are equal as mappings since only normalized form and ID are stored
337 assert_eq!(mapping1, mapping2);
338 }
339
340 #[test]
341 fn id_as_u64_convenience_method() {
342 let mapping = PermissionMapping::from("read:api");
343 assert_eq!(mapping.id_as_u64(), mapping.permission_id().as_u64());
344 }
345
346 #[test]
347 fn from_traits_work() {
348 let from_str: PermissionMapping = "read:api".into();
349 let from: PermissionMapping = "read:api".to_string().into();
350
351 assert_eq!(from_str.normalized_string(), "read:api");
352 assert_eq!(from.normalized_string(), "read:api");
353 }
354}