use super::ControlPanelState;
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DelegationRule {
pub caller_id: String,
pub target_id: String,
pub created_at: String,
}
pub type DelegationStore = Arc<std::sync::RwLock<Vec<DelegationRule>>>;
pub fn new_delegation_store() -> DelegationStore {
Arc::new(std::sync::RwLock::new(Vec::new()))
}
pub fn would_create_cycle(permissions: &[DelegationRule], caller: &str, target: &str) -> bool {
if caller == target {
return true;
}
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(target.to_string());
while let Some(node) = queue.pop_front() {
if node == caller {
return true;
}
if !visited.insert(node.clone()) {
continue;
}
for rule in permissions {
if rule.caller_id == node {
queue.push_back(rule.target_id.clone());
}
}
}
false
}
pub(crate) async fn api_delegation_list(
axum::extract::State(state): axum::extract::State<Arc<ControlPanelState>>,
) -> axum::Json<serde_json::Value> {
let permissions = state
.delegation_store
.read()
.map(|store| store.clone())
.unwrap_or_default();
axum::Json(serde_json::json!({
"ok": true,
"data": permissions
}))
}
#[derive(serde::Deserialize)]
pub(crate) struct AddDelegationPayload {
pub caller_id: String,
pub target_id: String,
}
pub(crate) async fn api_delegation_add(
axum::extract::State(state): axum::extract::State<Arc<ControlPanelState>>,
axum::Json(payload): axum::Json<AddDelegationPayload>,
) -> axum::Json<serde_json::Value> {
let caller_id = payload.caller_id.trim().to_string();
let target_id = payload.target_id.trim().to_string();
if caller_id.is_empty() || target_id.is_empty() {
return axum::Json(serde_json::json!({
"ok": false,
"message": "Both caller_id and target_id are required"
}));
}
let mut store = state.delegation_store.write().expect("lock poisoned");
if store
.iter()
.any(|r| r.caller_id == caller_id && r.target_id == target_id)
{
return axum::Json(serde_json::json!({
"ok": false,
"message": "Delegation permission already exists"
}));
}
if would_create_cycle(&store, &caller_id, &target_id) {
return axum::Json(serde_json::json!({
"ok": false,
"message": format!(
"Cannot add delegation {caller_id} → {target_id}: would create a circular chain"
)
}));
}
let rule = DelegationRule {
caller_id: caller_id.clone(),
target_id: target_id.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
};
store.push(rule);
tracing::info!(caller = %caller_id, target = %target_id, "delegation permission added via UI");
axum::Json(serde_json::json!({
"ok": true,
"message": format!("Delegation permission added: {caller_id} → {target_id}")
}))
}
#[derive(serde::Deserialize)]
pub(crate) struct RemoveDelegationPayload {
pub caller_id: String,
pub target_id: String,
}
pub(crate) async fn api_delegation_remove(
axum::extract::State(state): axum::extract::State<Arc<ControlPanelState>>,
axum::Json(payload): axum::Json<RemoveDelegationPayload>,
) -> axum::Json<serde_json::Value> {
let caller_id = payload.caller_id.trim().to_string();
let target_id = payload.target_id.trim().to_string();
let mut store = state.delegation_store.write().expect("lock poisoned");
let before = store.len();
store.retain(|r| !(r.caller_id == caller_id && r.target_id == target_id));
let removed = store.len() < before;
if removed {
tracing::info!(caller = %caller_id, target = %target_id, "delegation permission removed via UI");
axum::Json(serde_json::json!({
"ok": true,
"message": format!("Delegation permission removed: {caller_id} → {target_id}")
}))
} else {
axum::Json(serde_json::json!({
"ok": false,
"message": "Delegation permission not found"
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_cycle_simple() {
let perms = vec![DelegationRule {
caller_id: "a".into(),
target_id: "b".into(),
created_at: String::new(),
}];
assert!(!would_create_cycle(&perms, "b", "c"));
}
#[test]
fn test_direct_cycle() {
let perms = vec![DelegationRule {
caller_id: "a".into(),
target_id: "b".into(),
created_at: String::new(),
}];
assert!(would_create_cycle(&perms, "b", "a"));
}
#[test]
fn test_transitive_cycle() {
let perms = vec![
DelegationRule {
caller_id: "a".into(),
target_id: "b".into(),
created_at: String::new(),
},
DelegationRule {
caller_id: "b".into(),
target_id: "c".into(),
created_at: String::new(),
},
];
assert!(would_create_cycle(&perms, "c", "a"));
}
#[test]
fn test_self_delegation_is_cycle() {
let perms = vec![];
assert!(would_create_cycle(&perms, "a", "a"));
}
#[test]
fn test_no_cycle_disconnected() {
let perms = vec![
DelegationRule {
caller_id: "a".into(),
target_id: "b".into(),
created_at: String::new(),
},
DelegationRule {
caller_id: "c".into(),
target_id: "d".into(),
created_at: String::new(),
},
];
assert!(!would_create_cycle(&perms, "d", "a"));
}
#[test]
fn test_longer_transitive_cycle() {
let perms = vec![
DelegationRule {
caller_id: "a".into(),
target_id: "b".into(),
created_at: String::new(),
},
DelegationRule {
caller_id: "b".into(),
target_id: "c".into(),
created_at: String::new(),
},
DelegationRule {
caller_id: "c".into(),
target_id: "d".into(),
created_at: String::new(),
},
];
assert!(would_create_cycle(&perms, "d", "a"));
assert!(would_create_cycle(&perms, "d", "b"));
assert!(!would_create_cycle(&perms, "e", "a"));
}
}