//! Role-based access control (RBAC) model and evaluator. //! //! Permission sets store `grants` as a JSON array of [`Grant`]. //! This module defines the canonical grant schema and matching logic. use crate::models::{ArtifactVisibility, Id, OwnerType}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::HashMap; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum Resource { Packs, Actions, Rules, Triggers, Executions, Events, Enforcements, Inquiries, Keys, Artifacts, Workflows, Webhooks, Analytics, History, Identities, Permissions, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum Action { Read, Create, Update, Delete, Execute, Cancel, Respond, Manage, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum OwnerConstraint { #[serde(rename = "self")] SelfOnly, Any, None, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ExecutionScopeConstraint { #[serde(rename = "self")] SelfOnly, Descendants, Any, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct GrantConstraints { #[serde(default, skip_serializing_if = "Option::is_none")] pub pack_refs: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_types: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub execution_scope: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub refs: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub ids: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub encrypted: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub attributes: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Grant { pub resource: Resource, pub actions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub constraints: Option, } #[derive(Debug, Clone)] pub struct AuthorizationContext { pub identity_id: Id, pub identity_attributes: HashMap, pub target_id: Option, pub target_ref: Option, pub pack_ref: Option, pub owner_identity_id: Option, pub owner_type: Option, pub visibility: Option, pub encrypted: Option, pub execution_owner_identity_id: Option, pub execution_ancestor_identity_ids: Vec, } impl AuthorizationContext { pub fn new(identity_id: Id) -> Self { Self { identity_id, identity_attributes: HashMap::new(), target_id: None, target_ref: None, pack_ref: None, owner_identity_id: None, owner_type: None, visibility: None, encrypted: None, execution_owner_identity_id: None, execution_ancestor_identity_ids: Vec::new(), } } } impl Grant { pub fn allows(&self, resource: Resource, action: Action, ctx: &AuthorizationContext) -> bool { self.resource == resource && self.actions.contains(&action) && self.constraints_match(ctx) } fn constraints_match(&self, ctx: &AuthorizationContext) -> bool { let Some(constraints) = &self.constraints else { return true; }; if let Some(pack_refs) = &constraints.pack_refs { let Some(pack_ref) = &ctx.pack_ref else { return false; }; if !pack_refs.contains(pack_ref) { return false; } } if let Some(owner) = constraints.owner { let owner_match = match owner { OwnerConstraint::SelfOnly => ctx.owner_identity_id == Some(ctx.identity_id), OwnerConstraint::Any => true, OwnerConstraint::None => ctx.owner_identity_id.is_none(), }; if !owner_match { return false; } } if let Some(owner_types) = &constraints.owner_types { let Some(owner_type) = ctx.owner_type else { return false; }; if !owner_types.contains(&owner_type) { return false; } } if let Some(visibility) = &constraints.visibility { let Some(target_visibility) = ctx.visibility else { return false; }; if !visibility.contains(&target_visibility) { return false; } } if let Some(execution_scope) = constraints.execution_scope { let execution_match = match execution_scope { ExecutionScopeConstraint::SelfOnly => { ctx.execution_owner_identity_id == Some(ctx.identity_id) } ExecutionScopeConstraint::Descendants => { ctx.execution_owner_identity_id == Some(ctx.identity_id) || ctx .execution_ancestor_identity_ids .contains(&ctx.identity_id) } ExecutionScopeConstraint::Any => true, }; if !execution_match { return false; } } if let Some(refs) = &constraints.refs { let Some(target_ref) = &ctx.target_ref else { return false; }; if !refs.contains(target_ref) { return false; } } if let Some(ids) = &constraints.ids { let Some(target_id) = ctx.target_id else { return false; }; if !ids.contains(&target_id) { return false; } } if let Some(encrypted) = constraints.encrypted { let Some(target_encrypted) = ctx.encrypted else { return false; }; if encrypted != target_encrypted { return false; } } if let Some(attributes) = &constraints.attributes { for (key, expected_value) in attributes { let Some(actual_value) = ctx.identity_attributes.get(key) else { return false; }; if actual_value != expected_value { return false; } } } true } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn grant_without_constraints_allows() { let grant = Grant { resource: Resource::Actions, actions: vec![Action::Read], constraints: None, }; let ctx = AuthorizationContext::new(42); assert!(grant.allows(Resource::Actions, Action::Read, &ctx)); assert!(!grant.allows(Resource::Actions, Action::Create, &ctx)); } #[test] fn key_constraint_owner_type_and_encrypted() { let grant = Grant { resource: Resource::Keys, actions: vec![Action::Read], constraints: Some(GrantConstraints { owner_types: Some(vec![OwnerType::System]), encrypted: Some(false), ..Default::default() }), }; let mut ctx = AuthorizationContext::new(1); ctx.owner_type = Some(OwnerType::System); ctx.encrypted = Some(false); assert!(grant.allows(Resource::Keys, Action::Read, &ctx)); ctx.encrypted = Some(true); assert!(!grant.allows(Resource::Keys, Action::Read, &ctx)); } #[test] fn attributes_constraint_requires_exact_value_match() { let grant = Grant { resource: Resource::Packs, actions: vec![Action::Read], constraints: Some(GrantConstraints { attributes: Some(HashMap::from([("team".to_string(), json!("platform"))])), ..Default::default() }), }; let mut ctx = AuthorizationContext::new(1); ctx.identity_attributes .insert("team".to_string(), json!("platform")); assert!(grant.allows(Resource::Packs, Action::Read, &ctx)); ctx.identity_attributes .insert("team".to_string(), json!("infra")); assert!(!grant.allows(Resource::Packs, Action::Read, &ctx)); } }