Skip to main content

wyrd_runtime/
permission_check.rs

1//! Runtime permission check contract.
2
3use crate::permission::Permission;
4use crate::principal::{Principal, PrincipalId};
5
6/// Authorization decision contract.
7///
8/// This is synchronous on purpose: RBAC is a pure function over an in-memory
9/// `PermissionSet`. DB-backed or network-backed policy checks belong behind a
10/// separate contract.
11pub trait PermissionCheck: Send + Sync + std::fmt::Debug {
12    /// Check whether `principal` has `permission`.
13    fn check(&self, principal: &Principal, permission: &Permission) -> PermissionVerdict;
14}
15
16/// Authorization verdict.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PermissionVerdict {
19    /// The principal is allowed to perform the action.
20    Allow,
21    /// The principal is denied.
22    Deny {
23        /// Denial reason.
24        reason: PermissionDenyReason,
25    },
26}
27
28/// Why the check denied.
29///
30/// This enum intentionally has no ABAC/CEL variant. Attribute and policy
31/// decisions belong to the Policy plane, not to the runtime RBAC check.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum PermissionDenyReason {
34    /// The principal's effective RBAC permissions did not cover the required
35    /// permission.
36    Rbac {
37        /// Required permission.
38        ///
39        /// Boxed because the permission now carries a typed object scope, and
40        /// this reason travels inside a `Result` error on every check.
41        required: Box<Permission>,
42        /// Principal that failed the check.
43        principal: PrincipalId,
44    },
45}
46
47impl PermissionVerdict {
48    /// Convert a verdict to a result at a handler boundary.
49    pub fn into_result(self) -> Result<(), PermissionDenyReason> {
50        match self {
51            Self::Allow => Ok(()),
52            Self::Deny { reason } => Err(reason),
53        }
54    }
55}
56
57/// Pure RBAC permission checker.
58///
59/// Returns `Allow` when the principal's effective permission set covers the
60/// required permission.
61#[derive(Debug, Default, Clone, Copy)]
62pub struct RbacCheck;
63
64impl PermissionCheck for RbacCheck {
65    fn check(&self, principal: &Principal, permission: &Permission) -> PermissionVerdict {
66        if principal.effective_permissions.contains(permission) {
67            PermissionVerdict::Allow
68        } else {
69            PermissionVerdict::Deny {
70                reason: PermissionDenyReason::Rbac {
71                    required: Box::new(permission.clone()),
72                    principal: principal.id,
73                },
74            }
75        }
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::{PermissionCheck, PermissionDenyReason, PermissionVerdict, RbacCheck};
82    use crate::permission::{Permission, PermissionSet};
83    use crate::principal::{Principal, PrincipalId, PrincipalKind, RoleRef};
84
85    fn principal_with_permissions(permissions: impl IntoIterator<Item = Permission>) -> Principal {
86        Principal::new(
87            PrincipalId::new(uuid::Uuid::now_v7()),
88            PrincipalKind::User,
89            wyrd_spec::DataTenantId::new_v7(),
90            vec![RoleRef::new("editor").expect("static role is valid")],
91            PermissionSet::from_iter(permissions),
92        )
93    }
94
95    #[test]
96    fn rbac_allows_match() {
97        let principal = principal_with_permissions([Permission::card_write()]);
98
99        let verdict = RbacCheck.check(&principal, &Permission::card_write());
100
101        assert_eq!(verdict, PermissionVerdict::Allow);
102    }
103
104    #[test]
105    fn rbac_denies_missing() {
106        let principal = principal_with_permissions([]);
107
108        let verdict = RbacCheck.check(&principal, &Permission::card_write());
109
110        assert_eq!(
111            verdict,
112            PermissionVerdict::Deny {
113                reason: PermissionDenyReason::Rbac {
114                    required: Box::new(Permission::card_write()),
115                    principal: principal.id,
116                },
117            }
118        );
119    }
120
121    /// A principal holding only the wildcard permission is allowed any
122    /// concrete permission, proving RBAC resolves through wildcard grants.
123    ///
124    /// # Panics
125    /// Panics when `card_write` or `bifrost_record_write` is not allowed.
126    #[test]
127    fn rbac_resolves_through_wildcard() {
128        let principal = principal_with_permissions([Permission::wildcard()]);
129
130        assert_eq!(
131            RbacCheck.check(&principal, &Permission::card_write()),
132            PermissionVerdict::Allow
133        );
134        assert_eq!(
135            RbacCheck.check(&principal, &Permission::bifrost_record_write()),
136            PermissionVerdict::Allow
137        );
138    }
139
140    #[test]
141    fn verdict_into_result_round_trips() {
142        assert_eq!(PermissionVerdict::Allow.into_result(), Ok(()));
143
144        let reason = PermissionDenyReason::Rbac {
145            required: Box::new(Permission::card_write()),
146            principal: PrincipalId::new(uuid::Uuid::now_v7()),
147        };
148        let verdict = PermissionVerdict::Deny {
149            reason: reason.clone(),
150        };
151
152        assert_eq!(verdict.into_result(), Err(reason));
153    }
154
155    #[test]
156    fn deny_reason_carries_required_and_principal() {
157        let reason = PermissionDenyReason::Rbac {
158            required: Box::new(Permission::card_write()),
159            principal: PrincipalId::new(uuid::Uuid::now_v7()),
160        };
161
162        let debug = format!("{reason:?}");
163
164        assert!(debug.contains("required:"));
165        assert!(debug.contains("principal:"));
166    }
167}