[wip] single runtime handling
This commit is contained in:
292
crates/common/src/rbac.rs
Normal file
292
crates/common/src/rbac.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! 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<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner: Option<OwnerConstraint>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_types: Option<Vec<OwnerType>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility: Option<Vec<ArtifactVisibility>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_scope: Option<ExecutionScopeConstraint>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refs: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ids: Option<Vec<Id>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encrypted: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attributes: Option<HashMap<String, JsonValue>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Grant {
|
||||
pub resource: Resource,
|
||||
pub actions: Vec<Action>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub constraints: Option<GrantConstraints>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationContext {
|
||||
pub identity_id: Id,
|
||||
pub identity_attributes: HashMap<String, JsonValue>,
|
||||
pub target_id: Option<Id>,
|
||||
pub target_ref: Option<String>,
|
||||
pub pack_ref: Option<String>,
|
||||
pub owner_identity_id: Option<Id>,
|
||||
pub owner_type: Option<OwnerType>,
|
||||
pub visibility: Option<ArtifactVisibility>,
|
||||
pub encrypted: Option<bool>,
|
||||
pub execution_owner_identity_id: Option<Id>,
|
||||
pub execution_ancestor_identity_ids: Vec<Id>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user