use std::collections::BTreeMap;
use crate::runtime::authz::{Decision, Principal, ResourceRef};
use crate::runtime::backend_context::AppliedContext;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativeAccessConfig {
pub base_dsn: String,
pub role_prefix: String,
pub database: String,
pub schema: String,
pub backend: String,
pub ttl_seconds: u64,
}
impl Default for NativeAccessConfig {
fn default() -> Self {
Self {
base_dsn: String::new(),
role_prefix: "udb".to_string(),
database: String::new(),
schema: "public".to_string(),
backend: "postgres".to_string(),
ttl_seconds: 900,
}
}
}
impl NativeAccessConfig {
pub fn from_env() -> Self {
let default = Self::default();
Self {
base_dsn: std::env::var("UDB_NATIVE_BASE_DSN").unwrap_or(default.base_dsn),
role_prefix: non_empty_env("UDB_NATIVE_ROLE_PREFIX").unwrap_or(default.role_prefix),
database: std::env::var("UDB_NATIVE_DATABASE").unwrap_or(default.database),
schema: non_empty_env("UDB_NATIVE_SCHEMA").unwrap_or(default.schema),
backend: non_empty_env("UDB_NATIVE_BACKEND").unwrap_or(default.backend),
ttl_seconds: std::env::var("UDB_NATIVE_ACCESS_TTL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(default.ttl_seconds),
}
}
pub fn enabled(&self) -> bool {
!self.base_dsn.trim().is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessMode {
Read,
Write,
}
impl AccessMode {
pub fn from_action(action: &str) -> Self {
match action {
"data.select" | "vector.search" | "object.read" | "object.presign" => AccessMode::Read,
_ => AccessMode::Write,
}
}
pub fn as_str(&self) -> &'static str {
match self {
AccessMode::Read => "ro",
AccessMode::Write => "rw",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativeGrant {
pub dsn: String,
pub role: String,
pub backend: String,
pub database: String,
pub schema: String,
pub session_variables: BTreeMap<String, String>,
pub expires_at_unix: i64,
pub ttl_seconds: u64,
}
impl NativeAccessConfig {
pub fn role_name(&self, tenant_id: &str, mode: AccessMode) -> String {
let tenant = sanitize_identifier(tenant_id);
let tenant = if tenant.is_empty() {
"default".to_string()
} else {
tenant
};
format!("{}_{}_{}", self.role_prefix, tenant, mode.as_str())
}
pub fn render_dsn(&self, role: &str) -> String {
let mut dsn = self
.base_dsn
.replace("{role}", role)
.replace("{database}", &self.database);
if !self.base_dsn.contains("{role}") {
dsn = inject_userinfo(&dsn, role);
}
dsn
}
pub fn mint(
&self,
principal: &Principal,
resource: &ResourceRef,
action: &str,
purpose: &str,
decision: &Decision,
now_unix: i64,
) -> Option<NativeGrant> {
if !self.enabled() || !decision.allowed {
return None;
}
let mode = AccessMode::from_action(action);
let role = self.role_name(&principal.tenant_id, mode);
let dsn = self.render_dsn(&role);
let applied = AppliedContext {
tenant_id: principal.tenant_id.clone(),
project_id: principal.project_id.clone(),
purpose: purpose.to_string(),
scopes: principal.scopes.join(","),
correlation_id: String::new(),
user_id: principal.user_id.clone(),
service_identity: principal.service_identity.clone(),
decision_id: decision.decision_id.clone(),
attributes: BTreeMap::new(),
};
let session_variables = applied
.session_context_pairs()
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
let schema = if resource.schema.trim().is_empty() {
self.schema.clone()
} else {
resource.schema.clone()
};
Some(NativeGrant {
dsn,
role,
backend: self.backend.clone(),
database: self.database.clone(),
schema,
session_variables,
expires_at_unix: now_unix.saturating_add(self.ttl_seconds as i64),
ttl_seconds: self.ttl_seconds,
})
}
}
fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
fn sanitize_identifier(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else {
out.push('_');
}
}
out.trim_matches('_').to_string()
}
fn inject_userinfo(dsn: &str, role: &str) -> String {
if let Some((scheme, rest)) = dsn.split_once("://") {
if rest.contains('@') {
return dsn.to_string();
}
return format!("{scheme}://{role}@{rest}");
}
dsn.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::authz::Decision;
fn allowed(id: &str) -> Decision {
Decision {
decision_id: id.to_string(),
allowed: true,
..Decision::default()
}
}
#[test]
fn disabled_without_base_dsn() {
let cfg = NativeAccessConfig::default();
assert!(!cfg.enabled());
let p = Principal {
tenant_id: "acme".into(),
..Principal::default()
};
assert!(
cfg.mint(
&p,
&ResourceRef::message("X"),
"data.select",
"billing",
&allowed("d1"),
0
)
.is_none()
);
}
#[test]
fn role_name_sanitizes_tenant_and_modes() {
let cfg = NativeAccessConfig {
role_prefix: "udb".into(),
..NativeAccessConfig::default()
};
assert_eq!(
cfg.role_name("Acme Corp!", AccessMode::Read),
"udb_acme_corp_ro"
);
assert_eq!(cfg.role_name("", AccessMode::Write), "udb_default_rw");
}
#[test]
fn action_to_mode() {
assert_eq!(AccessMode::from_action("data.select"), AccessMode::Read);
assert_eq!(AccessMode::from_action("data.upsert"), AccessMode::Write);
assert_eq!(AccessMode::from_action("admin.manage"), AccessMode::Write);
}
#[test]
fn render_dsn_substitutes_or_injects_role() {
let templ = NativeAccessConfig {
base_dsn: "postgresql://host:5432/{database}".into(),
database: "app".into(),
..NativeAccessConfig::default()
};
assert_eq!(
templ.render_dsn("udb_acme_ro"),
"postgresql://udb_acme_ro@host:5432/app"
);
let placeholder = NativeAccessConfig {
base_dsn: "postgresql://{role}@host/{database}".into(),
database: "app".into(),
..NativeAccessConfig::default()
};
assert_eq!(
placeholder.render_dsn("udb_acme_rw"),
"postgresql://udb_acme_rw@host/app"
);
}
#[test]
fn mint_carries_decision_context_into_session_vars() {
let cfg = NativeAccessConfig {
base_dsn: "postgresql://host/{database}".into(),
database: "app".into(),
ttl_seconds: 600,
..NativeAccessConfig::default()
};
let p = Principal {
tenant_id: "acme".into(),
user_id: "u1".into(),
service_identity: "svc".into(),
scopes: vec!["data.read".into()],
..Principal::default()
};
let grant = cfg
.mint(
&p,
&ResourceRef::message("Invoice"),
"data.select",
"billing",
&allowed("dec-123"),
1000,
)
.expect("grant");
assert_eq!(grant.role, "udb_acme_ro");
assert_eq!(grant.expires_at_unix, 1600);
assert_eq!(
grant.session_variables.get("app.current_purpose"),
Some(&"billing".to_string())
);
assert_eq!(
grant.session_variables.get("app.current_decision_id"),
Some(&"dec-123".to_string())
);
assert_eq!(
grant.session_variables.get("app.current_service_identity"),
Some(&"svc".to_string())
);
assert_eq!(
grant.session_variables.get("app.current_tenant_id"),
Some(&"acme".to_string())
);
}
}