use crate::store::keyspaces;
use vta_sdk::display_name::{NameBook, NameSource, shorten_did};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::acl::{
AdminAuthority, AdminRole, CapRef, CapabilityGrant, CapabilityScope, VtcAclEntry, VtcRole,
delete_acl_entry, get_acl_entry, list_acl_entries, store_acl_entry,
};
use crate::config::AppConfig;
type CliResult = Result<(), Box<dyn std::error::Error>>;
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub async fn run_acl_list(config_path: Option<PathBuf>) -> CliResult {
let config = AppConfig::load(config_path)?;
let store = crate::store::offline::open_offline(&config.store)?;
let acl_ks = store.keyspace(keyspaces::ACL)?;
let mut entries = list_acl_entries(&acl_ks).await?;
if entries.is_empty() {
println!("No ACL entries.");
return Ok(());
}
entries.sort_by(|a, b| a.did.cmp(&b.did));
let mut book = NameBook::new();
for e in &entries {
book.insert_opt(&e.did, e.label.as_deref(), NameSource::AclLabel);
}
let show_names = book.names_any(entries.iter().map(|e| e.did.as_str()));
let now = now_epoch();
if show_names {
println!(
" {:<24} {:<44} {:<14} {:<12} ADMINISTRATION",
"NAME", "DID", "ROLE", "EXPIRES"
);
} else {
println!(
" {:<44} {:<14} {:<12} ADMINISTRATION",
"DID", "ROLE", "EXPIRES"
);
}
for e in &entries {
let expires = match e.expires_at {
None => "never".to_string(),
Some(t) if t <= now => "EXPIRED".to_string(),
Some(t) => format!("{}s", t - now),
};
let contexts = describe_authority(&e.admin);
let did = shorten_did(&e.did);
if show_names {
let name = book.name_of(&e.did).unwrap_or_else(|| "\u{2014}".into());
println!(
" {:<24} {:<44} {:<14} {:<12} {}",
name,
did,
e.role.to_string(),
expires,
contexts
);
} else {
println!(
" {:<44} {:<14} {:<12} {}",
did,
e.role.to_string(),
expires,
contexts
);
}
}
println!(
"\n{} entr{}.",
entries.len(),
if entries.len() == 1 { "y" } else { "ies" }
);
Ok(())
}
pub struct AclAddArgs {
pub config_path: Option<PathBuf>,
pub did: String,
pub role: String,
pub label: Option<String>,
pub contexts: Vec<String>,
pub admin_role: Option<String>,
pub capabilities: Vec<String>,
pub expires: Option<u64>,
}
pub async fn run_acl_add(args: AclAddArgs) -> CliResult {
let role = VtcRole::from_str(&args.role)?;
role.refuse_unassignable()?;
if !args.contexts.is_empty() {
return Err(format!(
"a community holds no contexts (VTI-VTC-010), so --contexts {} cannot be held — \
narrow administrative authority with --admin-role and --capability instead",
args.contexts.join(",")
)
.into());
}
let admin = authority_from_args(&role, args.admin_role.as_deref(), &args.capabilities)?;
let config = AppConfig::load(args.config_path)?;
let store = crate::store::offline::open_offline(&config.store)?;
let acl_ks = store.keyspace(keyspaces::ACL)?;
let now = now_epoch();
let existing = get_acl_entry(&acl_ks, &args.did).await?;
let entry = VtcAclEntry {
did: args.did.clone(),
role,
label: args.label,
admin,
delegated_by: None,
created_at: existing.as_ref().map(|e| e.created_at).unwrap_or(now),
created_by: "cli:acl-add".into(),
updated_at: None,
updated_by: None,
expires_at: args.expires.map(|ttl| now.saturating_add(ttl)),
resource_grants: existing
.as_ref()
.map(|e| e.resource_grants.clone())
.unwrap_or_default(),
label_set_by_subject: false,
suspension: None,
};
store_acl_entry(&acl_ks, &entry).await?;
crate::install::record_offline_acl_write(
&store,
"vtc acl add",
"grant",
&entry.did,
Some(&entry.role),
&entry
.admin
.effective()
.iter()
.map(CapRef::display)
.collect::<Vec<_>>(),
)
.await?;
store.persist().await?;
println!(
"{} ACL entry for {} (role {}; {}).",
if existing.is_some() {
"Updated"
} else {
"Added"
},
args.did,
entry.role,
describe_authority(&entry.admin)
);
Ok(())
}
pub fn authority_from_args(
role: &VtcRole,
admin_role: Option<&str>,
capabilities: &[String],
) -> Result<AdminAuthority, Box<dyn std::error::Error>> {
let admin_role = match admin_role {
Some("none" | "member") => None,
Some(r) => Some(r.parse::<AdminRole>()?),
None => role.implied_admin_role(),
};
let mut admin = match admin_role {
Some(r) => AdminAuthority::for_role(r),
None => AdminAuthority::none(),
};
if !capabilities.is_empty() {
let grants = capabilities
.iter()
.map(|c| c.parse::<CapRef>().map(CapabilityGrant::from))
.collect::<Result<Vec<_>, _>>()?;
admin.capabilities = CapabilityScope::listed(grants)?;
}
admin
.validate_against_ceiling()
.map_err(|e| e.to_string())?;
Ok(admin)
}
pub fn describe_authority(admin: &AdminAuthority) -> String {
let Some(role) = admin.admin_role.as_ref() else {
return "nothing (no administrative role)".into();
};
if !admin.act.is_all() {
return format!("{role}: acts nowhere (approves only)");
}
if *role == AdminRole::CommunityAdmin && admin.capabilities == CapabilityScope::Ceiling {
return format!("{role}: everything");
}
let caps: Vec<String> = admin.effective().iter().map(CapRef::display).collect();
if caps.is_empty() {
format!("{role}: nothing")
} else {
format!("{role}: {}", caps.join(", "))
}
}
pub async fn run_acl_remove(config_path: Option<PathBuf>, did: String) -> CliResult {
let config = AppConfig::load(config_path)?;
let store = crate::store::offline::open_offline(&config.store)?;
let acl_ks = store.keyspace(keyspaces::ACL)?;
let present = match get_acl_entry(&acl_ks, &did).await {
Ok(entry) => entry.is_some(),
Err(_) => true,
};
if !present {
println!("No ACL entry for {did} — nothing to remove.");
return Ok(());
}
delete_acl_entry(&acl_ks, &did).await?;
let owed = crate::rooms::community::CommunityRooms::new(
store.keyspace(keyspaces::COMMUNITY_ROOMS)?,
None,
)
.record_departure(
&did,
crate::rooms::community::DepartureCause::AclRemoved,
chrono::Utc::now(),
)
.await?;
crate::install::record_offline_acl_write(&store, "vtc acl remove", "remove", &did, None, &[])
.await?;
store.persist().await?;
println!("Removed ACL entry for {did}.");
if owed > 0 {
println!(
"{did} is owed removal from {owed} room(s) the community owns; the daemon removes \
them when it next runs."
);
}
Ok(())
}