use std::cmp::Reverse;
use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
use crate::runtime::security::{AbacPolicy, PolicyEffect, SecurityContext};
mod casbin_engine;
pub mod bundle;
pub mod native_access;
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 decision_for_abac(
policies: &[AbacPolicy],
principal: &Principal,
message_type: &str,
operation: &str,
purpose: &str,
default_allow: bool,
) -> Decision {
let mut snapshot = AuthzSnapshot::from_abac_policies("live-abac", policies);
snapshot.default_allow = default_allow;
let resource = ResourceRef::message(message_type);
let attributes = BTreeMap::new();
snapshot.authorize(&AuthzQuery {
principal,
resource: &resource,
action: operation,
purpose,
attributes: &attributes,
})
}
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)]
pub enum Effect {
#[default]
Allow,
Deny,
}
impl Effect {
pub fn as_str(&self) -> &'static str {
match self {
Effect::Allow => "allow",
Effect::Deny => "deny",
}
}
fn rank(&self) -> u8 {
match self {
Effect::Deny => 0,
Effect::Allow => 1,
}
}
}
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)]
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(),
}
}
}
impl AuthzPolicy {
pub fn from_abac(id: impl Into<String>, p: &AbacPolicy) -> Self {
let required_scopes = if p.required_scope.trim().is_empty() {
Vec::new()
} else {
vec![p.required_scope.clone()]
};
Self {
id: id.into(),
priority: 0,
enabled: true,
effect: p.effect.clone().into(),
tenant: p.tenant_id.clone(),
project: String::new(),
subject: p.service_identity.clone(),
role: String::new(),
action: p.operation.clone(),
resource: p.message_type.clone(),
purpose: p.purpose.clone(),
relationship: String::new(),
conditions: BTreeMap::new(),
required_scopes,
}
}
}
#[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,
}
pub trait Authorizer: Send + Sync {
fn authorize(&self, req: &AuthzQuery<'_>) -> Decision;
}
#[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 {
pub fn from_abac_policies(version: impl Into<String>, policies: &[AbacPolicy]) -> Self {
Self {
version: version.into(),
relationship_version: String::new(),
policies: policies
.iter()
.enumerate()
.map(|(i, p)| AuthzPolicy::from_abac(format!("abac-{i}"), p))
.collect(),
role_bindings: Vec::new(),
tuples: Vec::new(),
default_allow: false,
}
}
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 Authorizer for AuthzSnapshot {
fn authorize(&self, req: &AuthzQuery<'_>) -> Decision {
let roles = self.effective_roles(req.principal);
let mut matched: Vec<&AuthzPolicy> = self
.policies
.iter()
.filter(|p| p.enabled && self.policy_matches(p, &roles, req))
.collect();
matched.sort_by_key(|p| (Reverse(p.priority), p.effect.rank(), p.id.clone()));
let decision_id = self.decision_id(req);
let matched_ids: Vec<String> = matched.iter().map(|p| p.id.clone()).collect();
let chosen = matched
.iter()
.find(|p| p.effect == Effect::Deny)
.copied()
.or_else(|| matched.first().copied());
match chosen {
Some(policy) => {
let allowed = policy.effect == Effect::Allow;
Decision {
decision_id,
allowed,
effect: policy.effect,
deny_reason: if allowed {
String::new()
} else {
format!("denied by policy {}", policy.id)
},
matched_policy_ids: matched_ids,
required_scopes: policy.required_scopes.clone(),
policy_version: self.version.clone(),
relationship_version: self.relationship_version.clone(),
cache_ttl_seconds: 0,
audit_required: !allowed,
via_role: allowed && !policy.role.trim().is_empty(),
}
}
None => {
let allowed = self.policies.is_empty() && self.default_allow;
Decision {
decision_id,
allowed,
effect: if allowed { Effect::Allow } else { Effect::Deny },
deny_reason: if allowed {
String::new()
} else {
"no authz policy matched request (default deny)".to_string()
},
matched_policy_ids: Vec::new(),
required_scopes: Vec::new(),
policy_version: self.version.clone(),
relationship_version: self.relationship_version.clone(),
cache_ttl_seconds: 0,
audit_required: !allowed,
via_role: false,
}
}
}
}
}
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;