trust-registry 0.20.0

Trust Registry
use affinidi_tdk::{
    messaging::protocols::mediator::acls::AccessListModeType, secrets_resolver::secrets::Secret,
};
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use tracing::warn;

use crate::didcomm::did_document::{TransportFlags, build_did_document, validate_public_url};

use super::{
    Configs,
    loaders::{environment::*, load},
};

/// Load the profile bundle from a configured secret-store backend, if any.
///
/// Returns `Ok(None)` when no backend is configured (deployment uses inline
/// `PROFILE_CONFIG`) or the backend holds no bundle yet, so the caller falls
/// back to `PROFILE_CONFIG`. Also `Ok(None)` when no `secrets-*` feature is
/// compiled in, which is the same "fall back to `PROFILE_CONFIG`" outcome.
async fn load_profile_from_secret_store() -> Result<Option<String>, String> {
    #[cfg(feature = "secrets")]
    {
        use super::secret_store;

        let cfg = secret_store::secrets_config_from_env();
        if !secret_store::backend_selected(&cfg) {
            return Ok(None);
        }
        secret_store::read_profile(&cfg, &secret_store::data_dir()).await
    }
    #[cfg(not(feature = "secrets"))]
    Ok(None)
}

/// Fetch the profile bundle from a configured VTA (feature `vta`); `Ok(None)`
/// when the VTA path is disabled or unconfigured.
async fn load_profile_from_vta() -> Result<Option<String>, String> {
    #[cfg(feature = "vta")]
    {
        super::vta::startup_profile_json().await
    }
    #[cfg(not(feature = "vta"))]
    {
        Ok(None)
    }
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditLogFormat {
    Text,
    /// The default: values are escaped by construction.
    #[default]
    Json,
}

impl fmt::Display for AuditLogFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Text => write!(f, "text"),
            Self::Json => write!(f, "json"),
        }
    }
}

impl std::str::FromStr for AuditLogFormat {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "text" => Ok(Self::Text),
            "json" => Ok(Self::Json),
            _ => Err(format!("Invalid audit log format: {s}")),
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct AuditConfig {
    pub log_format: AuditLogFormat,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileConfig {
    pub did: String,
    pub alias: String,
    pub secrets: Vec<Secret>,
}

#[derive(Debug, Clone, Default)]
pub struct AdminConfig {
    /// DIDs that may send record-mutating Trust Tasks.
    pub admin_dids: Vec<String>,
    /// Authorities, beyond its own DID, each admin DID may write records under.
    /// An admin not listed here may only write under its own DID.
    pub admin_authorities: HashMap<String, Vec<String>>,
    pub audit_config: AuditConfig,
}

/// Parse `ADMIN_AUTHORITIES`: a JSON object mapping an admin DID to the
/// authority DIDs it may write records under in addition to its own.
///
/// An empty or absent value means no admin writes beyond its own DID. A
/// malformed value is an error rather than being read as empty, so a typo
/// cannot silently change who may write what.
pub fn parse_admin_authorities(
    raw: Option<&str>,
    admin_dids: &[String],
) -> Result<HashMap<String, Vec<String>>, String> {
    let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else {
        return Ok(HashMap::new());
    };
    let admin_authorities: HashMap<String, Vec<String>> = serde_json::from_str(raw).map_err(|e| {
        format!(
            "ADMIN_AUTHORITIES must be a JSON object mapping an admin DID to a list of authority DIDs: {e}"
        )
    })?;
    if let Some(unknown) = admin_authorities
        .keys()
        .find(|admin| !admin_dids.contains(admin))
    {
        return Err(format!(
            "ADMIN_AUTHORITIES names {unknown}, which is not in ADMIN_DIDS"
        ));
    }
    Ok(admin_authorities)
}

#[derive(Debug, Clone)]
pub struct DidDocumentRetryConfig {
    pub max_attempts: u32,
    pub initial_delay_secs: u64,
    pub max_delay_secs: u64,
}

impl Default for DidDocumentRetryConfig {
    fn default() -> Self {
        Self {
            max_attempts: 10,
            initial_delay_secs: 2,
            max_delay_secs: 30,
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct DidcommConfig {
    pub is_enabled: bool,
    /// Which transports this process serves and advertises. Carried here even
    /// when DIDComm itself is disabled, so the server can gate the TSP receive
    /// loop and report the effective transport set at startup.
    pub transport_flags: TransportFlags,
    pub acl_mode: AccessListModeType,
    pub profile_config: ProfileConfig,
    pub mediator_did: String,
    pub did_document: String,
    pub admin_config: AdminConfig,
    pub retry_config: DidDocumentRetryConfig,
}

impl DidcommConfig {
    /// A REST-only configuration: no listener, no mediator, no DID document.
    ///
    /// Prefer this over `DidcommConfig::default()` when building a config
    /// programmatically. The derived default leaves `transport_flags.didcomm`
    /// at its own default of `true` while `is_enabled` is `false`, so the two
    /// halves disagree — `/health` and the startup transport summary would
    /// report DIDComm as served while nothing answers it. This constructor
    /// keeps them consistent.
    ///
    /// It matches what [`Configs::load`] produces when `ENABLE_DIDCOMM=false`.
    pub fn disabled() -> Self {
        Self {
            is_enabled: false,
            transport_flags: TransportFlags {
                rest: true,
                didcomm: false,
                tsp: false,
            },
            ..Default::default()
        }
    }
}

pub fn parse_profile_from_secrets_str(
    did_and_secrets_as_str: &str,
) -> Result<ProfileConfig, Box<dyn std::error::Error + Send + Sync>> {
    let profile_config: ProfileConfig = serde_json::from_str(did_and_secrets_as_str)?;
    Ok(profile_config)
}

/// Parse a boolean environment flag, defaulting when unset or empty.
///
/// Only `true`/`false` are accepted (case-insensitively): a typo like
/// `ENABLE_TSP=yes` must not silently read as "off" and leave the operator
/// believing TSP is running.
fn env_flag(name: &str, default: bool) -> Result<bool, String> {
    match std::env::var(name) {
        Err(_) => Ok(default),
        Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() {
            "" => Ok(default),
            "true" => Ok(true),
            "false" => Ok(false),
            other => Err(format!("{name} must be 'true' or 'false' (got '{other}')")),
        },
    }
}

/// Read [`TransportFlags`] from the environment and reject incoherent
/// combinations.
///
/// Defaults: `ENABLE_REST=true`, `ENABLE_DIDCOMM=true`, `ENABLE_TSP=false`.
///
/// Lives here rather than next to `TransportFlags` so that every environment
/// read in the crate sits under `configs`. The struct itself is plain public
/// data with a `Default`, so an embedding host constructs it directly and the
/// environment never reaches it. `TransportFlags::validate` still runs on both
/// paths, so a host cannot build an incoherent set either.
pub fn transport_flags_from_env() -> Result<TransportFlags, String> {
    let flags = TransportFlags {
        rest: env_flag("ENABLE_REST", true)?,
        didcomm: env_flag("ENABLE_DIDCOMM", true)?,
        tsp: env_flag("ENABLE_TSP", false)?,
    };
    flags.validate()?;
    Ok(flags)
}

#[async_trait::async_trait]
impl Configs for DidcommConfig {
    async fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        // Parsed before the DIDComm short-circuit so an invalid or empty
        // transport set is rejected even for a REST-only deployment.
        let transport_flags = transport_flags_from_env()?;
        if !transport_flags.didcomm {
            // No mediator profile is loaded on this path, so no DID document is
            // built either: a REST-only registry is reached by URL, not by DID.
            warn!(
                "DIDComm is disabled; no DID document will be published. \
                 Consumers must reach this registry by URL."
            );
            return Ok(DidcommConfig {
                is_enabled: false,
                transport_flags,
                ..Default::default()
            });
        }
        let acl_mode_raw = env_or("ACL_MODE", "ExplicitDeny");
        let acl_mode = if acl_mode_raw == "ExplicitAllow" {
            AccessListModeType::ExplicitAllow
        } else {
            AccessListModeType::ExplicitDeny
        };

        let admin_dids_str = optional_env("ADMIN_DIDS").unwrap_or_else(|| {
            warn!("Missing environment variable: ADMIN_DIDS. The admin list is empty");
            String::new()
        });
        let admin_dids: Vec<String> = admin_dids_str
            .split(',')
            .map(|e| e.trim().to_string())
            .collect();

        let log_format = env_or("AUDIT_LOG_FORMAT", "json")
            .parse::<AuditLogFormat>()
            .unwrap_or(AuditLogFormat::Json);

        let admin_authorities =
            parse_admin_authorities(optional_env("ADMIN_AUTHORITIES").as_deref(), &admin_dids)?;

        let admin_config = AdminConfig {
            admin_dids,
            admin_authorities,
            audit_config: AuditConfig { log_format },
        };

        let mediator_did = required_env("MEDIATOR_DID")?;

        // Identity source precedence: a configured VTA (remote key custody) wins,
        // then a configured secret-store backend (AWS/GCP/Azure/Vault/K8s/keyring),
        // then the inline PROFILE_CONFIG URI.
        let profile_configs_str = match load_profile_from_vta().await? {
            Some(bundle) => bundle,
            None => match load_profile_from_secret_store().await? {
                Some(bundle) => bundle,
                None => {
                    let profile_configs_uri = required_env("PROFILE_CONFIG")?;
                    load(&profile_configs_uri).await?
                }
            },
        };
        let profile_config = parse_profile_from_secrets_str(&profile_configs_str)?;

        // Externally reachable base URL for the REST/TRQP surface. Absent =>
        // no `TRQPRest` service entry, so the registry never advertises a
        // transport a peer cannot reach. LISTEN_ADDRESS is deliberately not a
        // fallback: it is a bind address, frequently `0.0.0.0`.
        let public_url = optional_env("TR_PUBLIC_URL")
            .map(|u| u.trim().to_string())
            .filter(|u| !u.is_empty());
        if let Some(url) = public_url.as_deref() {
            // Fail at startup rather than publish an endpoint consumers reject.
            validate_public_url(url)?;
        }
        if transport_flags.rest && public_url.is_none() {
            warn!(
                "ENABLE_REST=true but TR_PUBLIC_URL is unset: REST is served but not \
                 advertised in the DID document. Set TR_PUBLIC_URL to make it discoverable."
            );
        }

        let did_document = if let Some(doc) = optional_env("DID_DOCUMENT") {
            load(&doc).await?
        } else {
            build_did_document(
                &profile_config,
                &mediator_did,
                public_url.as_deref(),
                transport_flags,
            )
        };

        let retry_config = DidDocumentRetryConfig {
            max_attempts: env_or("DID_CHECK_MAX_ATTEMPTS", "10").parse().unwrap_or(10),
            initial_delay_secs: env_or("DID_CHECK_INITIAL_DELAY_SECS", "2")
                .parse()
                .unwrap_or(2),
            max_delay_secs: env_or("DID_CHECK_MAX_DELAY_SECS", "20")
                .parse()
                .unwrap_or(20),
        };

        Ok(DidcommConfig {
            is_enabled: true,
            transport_flags,
            acl_mode,
            mediator_did,
            profile_config,
            did_document,
            admin_config,
            retry_config,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::parse_admin_authorities;

    const ADMIN: &str = "did:example:admin";

    fn admins() -> Vec<String> {
        vec![ADMIN.to_string()]
    }

    #[test]
    fn absent_or_empty_admin_authorities_grant_nothing_extra() {
        assert!(parse_admin_authorities(None, &admins()).unwrap().is_empty());
        assert!(
            parse_admin_authorities(Some("  "), &admins())
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn admin_authorities_map_an_admin_to_its_extra_authorities() {
        let parsed = parse_admin_authorities(
            Some(r#"{"did:example:admin": ["did:example:a", "did:example:b"]}"#),
            &admins(),
        )
        .unwrap();
        assert_eq!(parsed[ADMIN], vec!["did:example:a", "did:example:b"]);
    }

    #[test]
    fn malformed_admin_authorities_are_an_error() {
        assert!(
            parse_admin_authorities(Some("did:example:admin=did:example:a"), &admins()).is_err()
        );
    }

    #[test]
    fn admin_authorities_for_a_non_admin_are_an_error() {
        assert!(
            parse_admin_authorities(
                Some(r#"{"did:example:stranger": ["did:example:a"]}"#),
                &admins()
            )
            .is_err()
        );
    }
}