tsafe-core 1.0.10

Cryptographic vault engine for tsafe — consume this crate to build tools on top.
Documentation
//! RBAC access profiles for runtime authority.
//!
//! This module keeps the first RBAC slice intentionally small:
//! - a serializable access profile (`read_only` vs `read_write`)
//! - explicit capability semantics
//! - role-scoped derived keys so future vault/team enforcement can bind to
//!   a stable cryptographic identity without changing the root-key schedule
//!
//! Higher layers can carry this profile through contracts, audit context, and
//! execution policy now; enforcement can build on the same model later.

use serde::{Deserialize, Serialize};

use crate::crypto::{self, VaultKey};
use crate::errors::{SafeError, SafeResult};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RbacProfile {
    ReadOnly,
    #[default]
    ReadWrite,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RbacCapability {
    Read,
    Write,
}

impl RbacProfile {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ReadOnly => "read_only",
            Self::ReadWrite => "read_write",
        }
    }

    pub fn capabilities(self) -> &'static [RbacCapability] {
        match self {
            Self::ReadOnly => &[RbacCapability::Read],
            Self::ReadWrite => &[RbacCapability::Read, RbacCapability::Write],
        }
    }

    pub fn allows_write(self) -> bool {
        matches!(self, Self::ReadWrite)
    }

    /// Derive a role-scoped 256-bit key from the root vault key.
    ///
    /// This does not change the on-disk vault format by itself. It gives later
    /// enforcement work a stable, domain-separated key identity for each access
    /// profile without reusing the root key directly.
    pub fn derive_role_key(self, root_key: &VaultKey) -> SafeResult<VaultKey> {
        crypto::derive_labeled_subkey(root_key, self.hkdf_label())
    }

    pub fn ensure_write_allowed(self) -> SafeResult<()> {
        if self.allows_write() {
            Ok(())
        } else {
            Err(SafeError::InvalidVault {
                reason: "rbac access profile 'read_only' does not allow write operations".into(),
            })
        }
    }

    fn hkdf_label(self) -> &'static str {
        match self {
            Self::ReadOnly => "tsafe/rbac/read-only/v1",
            Self::ReadWrite => "tsafe/rbac/read-write/v1",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::crypto::{
        derive_key, random_salt, VAULT_KDF_M_COST, VAULT_KDF_P_COST, VAULT_KDF_T_COST,
    };

    fn test_root_key() -> VaultKey {
        let salt = random_salt();
        derive_key(
            b"rbac-test-password",
            &salt,
            VAULT_KDF_M_COST,
            VAULT_KDF_T_COST,
            VAULT_KDF_P_COST,
        )
        .unwrap()
    }

    #[test]
    fn read_only_profile_is_default_denied_for_write() {
        assert!(!RbacProfile::ReadOnly.allows_write());
        assert!(RbacProfile::ReadOnly.ensure_write_allowed().is_err());
        assert!(RbacProfile::ReadWrite.ensure_write_allowed().is_ok());
    }

    #[test]
    fn role_keys_are_domain_separated_and_deterministic() {
        let root = test_root_key();

        let ro_1 = RbacProfile::ReadOnly.derive_role_key(&root).unwrap();
        let ro_2 = RbacProfile::ReadOnly.derive_role_key(&root).unwrap();
        let rw = RbacProfile::ReadWrite.derive_role_key(&root).unwrap();

        assert_eq!(ro_1.as_bytes(), ro_2.as_bytes());
        assert_ne!(ro_1.as_bytes(), rw.as_bytes());
    }

    #[test]
    fn profiles_expose_expected_capabilities() {
        assert_eq!(
            RbacProfile::ReadOnly.capabilities(),
            &[RbacCapability::Read]
        );
        assert_eq!(
            RbacProfile::ReadWrite.capabilities(),
            &[RbacCapability::Read, RbacCapability::Write]
        );
    }
}