webgates-core 1.0.0

Core domain types, permission system, and authorization building blocks for webgates.
Documentation
use crate::permissions::as_permission_name::AsPermissionName;

use const_crypto::sha2::Sha256;
use serde::{Deserialize, Serialize};

/// A deterministic 64-bit permission identifier derived from a normalized permission name.
///
/// `PermissionId` is the compact internal representation used by permission sets
/// and access policies. In normal application code, you often do not need to
/// construct it manually because APIs also accept permission strings directly.
///
/// Reach for `PermissionId` when you need a stable, comparable identifier for a
/// permission, or when you are integrating with lower-level storage and mapping
/// code.
///
/// # Normalization rules
///
/// - Trim leading and trailing whitespace
/// - Convert to lowercase
///
/// # Collision bound
///
/// The identifier is derived from the first 8 bytes (64 bits) of a SHA-256
/// digest (see [`const_sha256_u64`]). By the birthday paradox, the probability
/// of any collision reaches ~50 % at roughly **2³² ≈ 4 billion** distinct
/// permission names. For practical permission registries (typically fewer than
/// 100 000 distinct names) the collision risk is negligible.
///
/// However, because permission names are typically developer-defined constants
/// rather than user-supplied strings, collisions will surface in tests during
/// development. If your application uses dynamically generated permission names,
/// consider checking for collisions at registration time by maintaining an
/// external `HashMap<PermissionId, &str>` of known names and asserting
/// uniqueness before inserting.
///
/// # Examples
///
/// ```rust
/// use webgates_core::permissions::permission_id::PermissionId;
///
/// let read_id = PermissionId::from("read:file");
/// let write_id = PermissionId::from("write:file");
///
/// assert_ne!(read_id, write_id);
/// assert_eq!(read_id, PermissionId::from("READ:FILE"));
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PermissionId(u64);

impl std::fmt::Display for PermissionId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl PermissionId {
    /// Returns the raw 64-bit value used by bitmap-based permission storage.
    ///
    /// This is mostly useful for serialization, diagnostics, and low-level
    /// integrations.
    pub fn as_u64(self) -> u64 {
        self.0
    }

    /// Creates a permission identifier from its raw 64-bit representation.
    ///
    /// This is mainly useful when reconstructing permission IDs from serialized
    /// storage or other trusted internal representations.
    pub fn from_u64(value: u64) -> Self {
        Self(value)
    }
}

impl From<u64> for PermissionId {
    fn from(value: u64) -> Self {
        Self::from_u64(value)
    }
}

impl From<PermissionId> for u64 {
    fn from(id: PermissionId) -> u64 {
        id.as_u64()
    }
}

impl From<&str> for PermissionId {
    fn from(name: &str) -> Self {
        let norm = normalize_permission(name);
        Self(const_sha256_u64(&norm))
    }
}

impl From<String> for PermissionId {
    fn from(name: String) -> Self {
        Self::from(name.as_str())
    }
}

impl<T: AsPermissionName> From<&T> for PermissionId {
    fn from(permission: &T) -> Self {
        let norm = normalize_permission(&permission.as_permission_name());
        Self(const_sha256_u64(&norm))
    }
}

/// Normalizes a permission name using the crate's canonical rules.
fn normalize_permission(input: &str) -> String {
    input.trim().to_lowercase()
}

/// Computes a deterministic 64-bit identifier from a normalized permission name.
///
/// The identifier is derived from the **first 8 bytes (64 bits)** of the SHA-256
/// digest. Truncating the full 256-bit hash to 64 bits is safe for permission
/// registries with up to ~100 000 distinct names; beyond that the birthday-bound
/// collision probability grows non-trivially (50 % at ~4 billion entries).
///
/// This function is `const` so it can be used in compile-time validation contexts.
///
/// # Security note
///
/// This truncation is intentional for compact bitmap storage. It is **not**
/// intended to be used as a general-purpose cryptographic hash. Do not use the
/// output as a MAC, HMAC, or any other security-critical digest.
pub const fn const_sha256_u64(input: &str) -> u64 {
    let hash = Sha256::new().update(input.as_bytes()).finalize();
    u64::from_be_bytes([
        hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
    ])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn permission_id_different_names() {
        let read_id = PermissionId::from("read:file");
        let write_id = PermissionId::from("write:file");
        assert_ne!(read_id, write_id);
    }

    #[test]
    fn permission_id_case_normalization() {
        let id_lower = PermissionId::from("read:file");
        let id_upper = PermissionId::from("READ:FILE");
        assert_eq!(id_lower, id_upper);
    }

    #[test]
    fn permission_id_conversions() {
        let id = PermissionId::from("test:permission");
        let raw = id.as_u64();
        let from_u64 = PermissionId::from_u64(raw);
        assert_eq!(id, from_u64);
    }

    #[test]
    fn permission_id_from_string_types() {
        let name = "test:permission";
        let string_name = name.to_string();

        let from_str = PermissionId::from(name);
        let from_str_ref: PermissionId = name.into();
        let from_string: PermissionId = string_name.into();

        assert_eq!(from_str, from_str_ref);
        assert_eq!(from_str, from_string);
    }

    #[test]
    fn permission_id_from_permission_trait() {
        #[derive(Debug)]
        enum TestPermission {
            Read,
            Write,
        }

        impl AsPermissionName for TestPermission {
            fn as_permission_name(&self) -> String {
                format!("Test:{:?}", self)
            }
        }

        let read_perm = TestPermission::Read;
        let write_perm = TestPermission::Write;

        let read_id = PermissionId::from(&read_perm);
        let write_id = PermissionId::from(&write_perm);
        let read_id_from_trait = PermissionId::from(&read_perm);

        assert_ne!(read_id, write_id);
        assert_eq!(read_id, read_id_from_trait);

        // Case normalization (Test:Read vs test:read)
        assert_eq!(read_id, PermissionId::from("test:read"));
        assert_eq!(write_id, PermissionId::from("test:write"));
    }

    #[test]
    fn permission_id_deterministic() {
        let id1 = PermissionId::from("read:file");
        let id2 = PermissionId::from("read:file");
        assert_eq!(id1, id2);
    }

    #[test]
    fn permission_id_nested_enum_example() {
        #[derive(Debug)]
        enum AppPermission {
            Repository(RepositoryPermission),
            Api(ApiPermission),
        }

        #[derive(Debug)]
        enum RepositoryPermission {
            Read,
        }

        #[derive(Debug)]
        enum ApiPermission {
            Read,
        }

        impl AsPermissionName for AppPermission {
            fn as_permission_name(&self) -> String {
                match self {
                    AppPermission::Repository(perm) => {
                        format!("repository:{:?}", perm)
                    }
                    AppPermission::Api(perm) => format!("api:{:?}", perm),
                }
            }
        }

        let repo_read = AppPermission::Repository(RepositoryPermission::Read);
        let api_read = AppPermission::Api(ApiPermission::Read);

        let repo_read_id = PermissionId::from(&repo_read);
        let api_read_id = PermissionId::from(&api_read);

        assert_ne!(repo_read_id, api_read_id);
        assert_eq!(repo_read_id, PermissionId::from("repository:read"));
        assert_eq!(api_read_id, PermissionId::from("api:read"));
    }

    #[test]
    fn permission_id_integrates_with_permissions_struct() {
        use crate::permissions::Permissions;

        #[derive(Debug)]
        enum TestPermission {
            Read,
            Write,
        }

        impl AsPermissionName for TestPermission {
            fn as_permission_name(&self) -> String {
                format!("test:{:?}", self)
            }
        }

        let read_perm = TestPermission::Read;
        let write_perm = TestPermission::Write;

        let mut permissions = Permissions::new();

        permissions.grant(&read_perm);
        permissions.grant(&write_perm);

        assert!(permissions.has(&read_perm));
        assert!(permissions.has(&write_perm));

        assert!(permissions.has("test:read"));
        assert!(permissions.has("test:write"));

        assert!(permissions.has_all([&read_perm, &write_perm]));
        assert!(permissions.has_any([&read_perm]));
    }
}