Skip to main content

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}