vtc-service 0.11.22

Service for Verifiable Trust Communities
//! Offline ACL management for the `vtc` CLI.
//!
//! `vtc acl {list,add,remove}` — direct fjall access to the `acl`
//! keyspace, no running daemon and no auth ceremony (the operator's
//! filesystem access *is* the authority, same trust model as
//! `vtc create-did-key --admin` and `vtc admin invite`). Run on a
//! **stopped** daemon — fjall takes an exclusive lock, so the commands
//! fail while the server holds the store open. Not for TEE deployments
//! (the store lives behind the vsock proxy there).
//!
//! For online ACL management against a running VTC, use the admin UI
//! (ACL plugin) or the REST `/v1/acl` surface.

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::{
    ActScope, VtcAclEntry, VtcRole, delete_acl_entry, get_acl_entry, list_acl_entries,
    store_acl_entry,
};
use crate::config::AppConfig;
use crate::store::Store;

type CliResult = Result<(), Box<dyn std::error::Error>>;

fn now_epoch() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

/// `vtc acl list` — print every ACL entry.
pub async fn run_acl_list(config_path: Option<PathBuf>) -> CliResult {
    let config = AppConfig::load(config_path)?;
    let store = Store::open(&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));

    // Names come from the entries' own labels. Promote them to their own
    // leading column — the label used to be tacked onto the end behind the
    // contexts, which is the last place an operator scans.
    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} CONTEXTS",
            "NAME", "DID", "ROLE", "EXPIRES"
        );
    } else {
        println!("   {:<44} {:<14} {:<12} CONTEXTS", "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),
        };
        // Decode through `ActScope` rather than testing the list: an empty
        // scope set means community-wide for an admin and *nothing at all* for
        // every other role, and rendering both as blank left the two looking
        // identical on an operator display. Scoped entries keep the bracketed
        // form.
        let contexts = match e.act_scope() {
            ActScope::Contexts(cs) => format!("[{}]", cs.join(",")),
            other => other.to_string(),
        };
        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(())
}

/// `vtc acl add` — create or overwrite the ACL entry for a DID.
pub struct AclAddArgs {
    pub config_path: Option<PathBuf>,
    pub did: String,
    pub role: String,
    pub label: Option<String>,
    pub contexts: Vec<String>,
    /// Expiry, in seconds from now. `None` → no expiry.
    pub expires: Option<u64>,
}

pub async fn run_acl_add(args: AclAddArgs) -> CliResult {
    // Parse the role first so a typo fails before we touch the store.
    let role = VtcRole::from_str(&args.role)?;

    let config = AppConfig::load(args.config_path)?;
    let store = Store::open(&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,
        allowed_contexts: args.contexts,
        // Preserve the original creation time on update.
        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)),
    };
    store_acl_entry(&acl_ks, &entry).await?;
    store.persist().await?;

    println!(
        "{} ACL entry for {} (role {}).",
        if existing.is_some() {
            "Updated"
        } else {
            "Added"
        },
        args.did,
        entry.role
    );
    Ok(())
}

/// `vtc acl remove` — delete the ACL entry for a DID.
pub async fn run_acl_remove(config_path: Option<PathBuf>, did: String) -> CliResult {
    let config = AppConfig::load(config_path)?;
    let store = Store::open(&config.store)?;
    let acl_ks = store.keyspace(keyspaces::ACL)?;

    if get_acl_entry(&acl_ks, &did).await?.is_none() {
        println!("No ACL entry for {did} — nothing to remove.");
        return Ok(());
    }
    delete_acl_entry(&acl_ks, &did).await?;
    store.persist().await?;
    println!("Removed ACL entry for {did}.");
    Ok(())
}