use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
use crate::runtime::security::{PolicyEffect, SecurityContext};
mod casbin_engine;
pub(crate) use casbin_engine::validate_casbin_model;
pub mod bundle;
pub mod native_access;
pub mod policy_engine;
pub use policy_engine::PolicyEngine;
pub fn rpc_action(rpc_name: &str) -> &'static str {
match rpc_name {
"Select" | "BatchSelect" => "data.select",
"Upsert" | "BatchUpsert" => "data.upsert",
"Delete" => "data.delete",
"VectorSearch" | "VectorHybridSearch" => "vector.search",
"VectorUpsert" | "VectorBatchUpsert" => "vector.upsert",
"PutObject" | "InitiateMultipartUpload" => "object.write",
"GetObject" => "object.read",
"GeneratePresignedUrl" => "object.presign",
"GenericDispatch" => "backend.dispatch",
"PublishCDC" | "EnqueueOutboxEvent" => "cdc.publish",
_ => "admin.manage",
}
}
pub fn authz_catalog_ddl(_schema: &str) -> Vec<String> {
crate::runtime::native_catalog::native_service_catalog_ddl()
.into_iter()
.filter(|sql| sql.contains("udb_authz"))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effect {
#[default]
Allow,
Deny,
}
impl Effect {
pub fn as_str(&self) -> &'static str {
match self {
Effect::Allow => "allow",
Effect::Deny => "deny",
}
}
}
impl From<PolicyEffect> for Effect {
fn from(value: PolicyEffect) -> Self {
match value {
PolicyEffect::Allow => Effect::Allow,
PolicyEffect::Deny => Effect::Deny,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Principal {
pub principal_id: String,
pub subject: String,
pub user_id: String,
pub service_identity: String,
pub tenant_id: String,
pub project_id: String,
pub scopes: Vec<String>,
pub roles: Vec<String>,
pub provider_id: String,
pub auth_method: String,
}
impl Principal {
pub fn has_scope(&self, scope: &str) -> bool {
if scope.trim().is_empty() {
return true;
}
self.scopes
.iter()
.any(|s| s == scope || s == "*" || s == "udb:*")
}
pub fn from_security_context(ctx: &SecurityContext, roles: Vec<String>) -> Self {
let subject = if !ctx.user_id.trim().is_empty() {
ctx.user_id.clone()
} else {
ctx.service_identity.clone()
};
Self {
principal_id: subject.clone(),
subject,
user_id: ctx.user_id.clone(),
service_identity: ctx.service_identity.clone(),
tenant_id: ctx.tenant_id.clone(),
project_id: ctx.project_id.clone(),
scopes: ctx.scopes.clone(),
roles,
provider_id: String::new(),
auth_method: String::new(),
}
}
fn identities(&self) -> [&str; 4] {
[
self.subject.as_str(),
self.service_identity.as_str(),
self.user_id.as_str(),
self.principal_id.as_str(),
]
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceRef {
pub resource_type: String,
pub resource_name: String,
pub message_type: String,
pub schema: String,
pub table: String,
pub backend: String,
pub instance: String,
}
impl ResourceRef {
pub fn message(message_type: impl Into<String>) -> Self {
let message_type = message_type.into();
Self {
resource_type: "message".to_string(),
resource_name: message_type.clone(),
message_type,
..Self::default()
}
}
fn selectors(&self) -> [&str; 4] {
[
self.resource_name.as_str(),
self.message_type.as_str(),
self.table.as_str(),
self.resource_type.as_str(),
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AuthzPolicy {
pub id: String,
pub priority: i32,
pub enabled: bool,
pub effect: Effect,
pub tenant: String,
pub project: String,
pub subject: String,
pub role: String,
pub action: String,
pub resource: String,
pub purpose: String,
pub relationship: String,
pub conditions: BTreeMap<String, String>,
pub required_scopes: Vec<String>,
}
impl Default for AuthzPolicy {
fn default() -> Self {
Self {
id: String::new(),
priority: 0,
enabled: true,
effect: Effect::Allow,
tenant: String::new(),
project: String::new(),
subject: String::new(),
role: String::new(),
action: String::new(),
resource: String::new(),
purpose: String::new(),
relationship: String::new(),
conditions: BTreeMap::new(),
required_scopes: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RoleBinding {
pub subject: String,
pub role: String,
pub tenant: String,
pub project: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RelationshipTuple {
pub subject: String,
pub relation: String,
pub object: String,
pub tenant: String,
pub project: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Decision {
pub decision_id: String,
pub allowed: bool,
pub effect: Effect,
pub deny_reason: String,
pub matched_policy_ids: Vec<String>,
pub required_scopes: Vec<String>,
pub policy_version: String,
pub relationship_version: String,
pub cache_ttl_seconds: u64,
pub audit_required: bool,
pub via_role: bool,
}
#[derive(Debug, Clone)]
pub struct AuthzQuery<'a> {
pub principal: &'a Principal,
pub resource: &'a ResourceRef,
pub action: &'a str,
pub purpose: &'a str,
pub attributes: &'a BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct AuthzSnapshot {
pub version: String,
pub relationship_version: String,
pub policies: Vec<AuthzPolicy>,
pub role_bindings: Vec<RoleBinding>,
pub tuples: Vec<RelationshipTuple>,
pub default_allow: bool,
}
impl AuthzSnapshot {
fn effective_roles(&self, principal: &Principal) -> Vec<String> {
let mut roles = principal.roles.clone();
for binding in &self.role_bindings {
if !domain_match(&binding.tenant, &principal.tenant_id)
|| !domain_match(&binding.project, &principal.project_id)
{
continue;
}
if principal
.identities()
.iter()
.any(|id| *id == binding.subject)
&& !roles.contains(&binding.role)
{
roles.push(binding.role.clone());
}
}
roles
}
fn has_tuple(&self, principal: &Principal, relation: &str, object: &str) -> bool {
self.tuples.iter().any(|t| {
t.relation == relation
&& t.object == object
&& principal.identities().iter().any(|id| *id == t.subject)
&& domain_match(&t.tenant, &principal.tenant_id)
&& domain_match(&t.project, &principal.project_id)
})
}
fn policy_matches(&self, policy: &AuthzPolicy, roles: &[String], req: &AuthzQuery<'_>) -> bool {
let p = req.principal;
let base = domain_match(&policy.tenant, &p.tenant_id)
&& domain_match(&policy.project, &p.project_id)
&& subject_match(&policy.subject, &p.identities())
&& role_match(&policy.role, roles)
&& pattern_match(&policy.action, req.action)
&& resource_match(&policy.resource, &req.resource.selectors())
&& wildcard(&policy.purpose, req.purpose)
&& conditions_match(&policy.conditions, req.attributes)
&& (policy.relationship.is_empty()
|| self.has_tuple(p, &policy.relationship, &req.resource.resource_name));
if !base {
return false;
}
match policy.effect {
Effect::Allow => policy
.required_scopes
.iter()
.all(|scope| p.has_scope(scope)),
Effect::Deny => true,
}
}
}
impl AuthzSnapshot {
fn decision_id(&self, req: &AuthzQuery<'_>) -> String {
let mut hasher = Sha256::new();
for part in [
self.version.as_str(),
req.principal.principal_id.as_str(),
req.principal.subject.as_str(),
req.principal.tenant_id.as_str(),
req.principal.project_id.as_str(),
req.resource.resource_name.as_str(),
req.resource.message_type.as_str(),
req.action,
req.purpose,
] {
hasher.update(part.as_bytes());
hasher.update([0u8]); }
let digest = hasher.finalize();
let id: String = format!("{digest:x}").chars().take(16).collect();
format!("authz_{id}")
}
}
fn wildcard(pattern: &str, value: &str) -> bool {
let pattern = pattern.trim();
pattern.is_empty() || pattern == "*" || pattern == value
}
fn domain_match(pattern: &str, value: &str) -> bool {
wildcard(pattern, value)
}
fn pattern_match(pattern: &str, value: &str) -> bool {
let pattern = pattern.trim();
if pattern.is_empty() || pattern == "*" || pattern == value {
return true;
}
if let Some(prefix) = pattern.strip_suffix(".*") {
return value == prefix || value.starts_with(&format!("{prefix}."));
}
false
}
fn subject_match(pattern: &str, identities: &[&str]) -> bool {
let pattern = pattern.trim();
pattern.is_empty() || pattern == "*" || identities.iter().any(|id| *id == pattern)
}
fn role_match(pattern: &str, roles: &[String]) -> bool {
let pattern = pattern.trim();
pattern.is_empty() || pattern == "*" || roles.iter().any(|r| r == pattern)
}
fn resource_match(pattern: &str, selectors: &[&str]) -> bool {
let pattern = pattern.trim();
if pattern.is_empty() || pattern == "*" {
return true;
}
selectors.iter().any(|sel| pattern_match(pattern, sel))
}
fn conditions_match(
conditions: &BTreeMap<String, String>,
attrs: &BTreeMap<String, String>,
) -> bool {
conditions
.iter()
.all(|(key, want)| attrs.get(key).map(String::as_str) == Some(want.as_str()))
}
#[cfg(test)]
mod tests;