use std::collections::HashMap;
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{info, warn};
use vti_common::error::AppError;
use crate::community::{CommunityProfile, CommunityProfileUpdate, load_profile, store_profile};
use crate::config_store::{
ConfigStore, EffectiveConfig, compute_effective_config, lookup, validate_value,
};
use crate::server::AppState;
#[allow(unused_imports)]
use crate::supervisor::SupervisorKind;
use vti_common::audit::{
AuditEvent, AuditWriter, CommunityProfileUpdatedData, ConfigChange, ConfigChangedData,
ConfigReloadedData, ConfigSource, RestartRequestedData,
};
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct PatchRequest {
pub overrides: HashMap<String, Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PatchResponse {
pub applied: Vec<String>,
pub pending_restart: Vec<String>,
pub rejected: Vec<RejectedKey>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[schema(as = ConfigRejectedKey)]
pub struct RejectedKey {
pub key: String,
pub reason: String,
}
pub(crate) async fn get_config(
state: &AppState,
keys: Option<&[String]>,
) -> Result<EffectiveConfig, AppError> {
let cfg = state.config.read().await;
let store = ConfigStore::new(state.config_ks.clone());
let mut eff = compute_effective_config(&cfg, &store).await?;
if let Some(keys) = keys {
eff.fields.retain(|f| keys.contains(&f.key));
}
Ok(eff)
}
pub(crate) async fn patch_config(
state: &AppState,
actor: &str,
req: PatchRequest,
) -> Result<PatchResponse, AppError> {
let store = ConfigStore::new(state.config_ks.clone());
let current = store.snapshot().await?;
let mut applied = Vec::new();
let mut pending_restart = Vec::new();
let mut rejected = Vec::new();
let mut audit_changes: Vec<ConfigChange> = Vec::new();
let mut requires_restart = false;
for (key, value) in req.overrides {
let Some(def) = lookup(&key) else {
let reason = crate::config_store::host_only_refusal(&key)
.unwrap_or_else(|| "unknown config key (not in registry)".into());
rejected.push(RejectedKey { key, reason });
continue;
};
if let Err(e) = validate_value(def, &value) {
rejected.push(RejectedKey {
key,
reason: format!("validation failed: {e}"),
});
continue;
}
if let Err(e) = check_against_community(state, &key, &value).await {
rejected.push(RejectedKey {
key,
reason: format!("validation failed: {e}"),
});
continue;
}
let old_value = current.get(&key).cloned();
if let Err(e) = store.put(&key, &value).await {
rejected.push(RejectedKey {
key,
reason: format!("persistence failed: {e}"),
});
continue;
}
crate::admin_actions::record_effect(state).await;
info!(
key = %key,
requires_restart = def.requires_restart,
sensitive = def.sensitive,
"admin config PATCH applied"
);
if def.trust_semantics {
warn!(
key = %key,
actor = %actor,
trust_semantics = true,
"a configuration key that alters trust semantics was changed (VTI-CMP-090)"
);
}
let mut change = ConfigChange {
key: key.clone(),
old_value: old_value.clone(),
new_value: value,
source_before: if old_value.is_some() {
ConfigSource::Db
} else {
ConfigSource::Default
},
};
change.redact_if(|k| matches!(lookup(k), Some(d) if d.sensitive));
audit_changes.push(change);
if def.requires_restart {
requires_restart = true;
pending_restart.push(key);
} else {
applied.push(key);
}
}
if !audit_changes.is_empty() {
let audit_writer = require_audit_writer(state)?;
audit_writer
.write(
actor,
None,
AuditEvent::ConfigChanged(ConfigChangedData {
changes: audit_changes,
requires_restart,
}),
)
.await?;
}
Ok(PatchResponse {
applied,
pending_restart,
rejected,
})
}
const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ReloadResponse {
pub keys_reloaded: Vec<String>,
}
pub(crate) async fn reload_config(
state: &AppState,
actor: &str,
) -> Result<ReloadResponse, AppError> {
let audit_writer = require_audit_writer(state)?;
let store = ConfigStore::new(state.config_ks.clone());
let new_effective = {
let cfg = state.config.read().await;
compute_effective_config(&cfg, &store).await?
};
let mut keys_reloaded = Vec::new();
{
let mut cfg = state.config.write().await;
for def in crate::config_store::REGISTRY {
if def.requires_restart {
continue;
}
let new_value = new_effective
.fields
.iter()
.find(|f| f.key == def.key)
.map(|f| f.value.clone())
.unwrap_or(Value::Null);
let live_value = lookup_live(&cfg, def.key);
if new_value != live_value && apply_to_live(&mut cfg, def.key, &new_value) {
keys_reloaded.push(def.key.to_string());
}
}
}
audit_writer
.write(
actor,
None,
AuditEvent::ConfigReloaded(ConfigReloadedData {
keys_reloaded: keys_reloaded.clone(),
}),
)
.await?;
info!(?keys_reloaded, "config reloaded");
Ok(ReloadResponse { keys_reloaded })
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RestartResponse {
pub supervisor: SupervisorKind,
pub drain_timeout_seconds: u64,
}
pub(crate) async fn restart_config(
state: &AppState,
actor: &str,
) -> Result<RestartResponse, AppError> {
let audit_writer = require_audit_writer(state)?;
let supervisor = state.supervisor.ok_or_else(|| AppError::ServiceError {
status: StatusCode::PRECONDITION_FAILED,
message: "SupervisorRequired: refusing to restart without a process supervisor \
(set VTC_SUPERVISED=1 or run under systemd / kubernetes)"
.into(),
})?;
audit_writer
.write(
actor,
None,
AuditEvent::RestartRequested(RestartRequestedData {
drain_timeout_seconds: DEFAULT_DRAIN_TIMEOUT_SECS,
}),
)
.await?;
info!(?supervisor, "restart requested");
let _ = state.shutdown_tx.send(true);
Ok(RestartResponse {
supervisor,
drain_timeout_seconds: DEFAULT_DRAIN_TIMEOUT_SECS,
})
}
fn require_audit_writer(state: &AppState) -> Result<&AuditWriter, AppError> {
state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "audit writer not configured".into(),
})
}
async fn check_against_community(
state: &AppState,
key: &str,
value: &Value,
) -> Result<(), AppError> {
if key == crate::config_store::UNRESTRICTED_ADMIN_CONSENT_THRESHOLD
&& let Some(n) = value.as_u64()
{
crate::acl::admin_consent::check_threshold_meetable(state, n).await?;
}
if key == crate::credentials::trusted_witnesses::WITNESSES_TRUSTED {
let own = state.config.read().await.vtc_did.clone();
crate::credentials::trusted_witnesses::parse_list(value, own.as_deref())?;
}
Ok(())
}
pub(crate) async fn lowered_threshold<'a>(
state: &AppState,
overrides: impl IntoIterator<Item = (&'a String, &'a Value)>,
) -> Result<Option<(u64, u64)>, AppError> {
let key = crate::config_store::UNRESTRICTED_ADMIN_CONSENT_THRESHOLD;
let Some((_, value)) = overrides.into_iter().find(|(k, _)| k.as_str() == key) else {
return Ok(None);
};
let Some(def) = lookup(key) else {
return Ok(None);
};
if validate_value(def, value).is_err() {
return Ok(None);
}
let Some(next) = value.as_u64() else {
return Ok(None);
};
let current = crate::acl::admin_consent::threshold(state).await?;
Ok((next < current).then_some((current, next)))
}
fn lookup_live(cfg: &crate::config::AppConfig, key: &str) -> Value {
match key {
"server.host" => Value::String(cfg.server.host.clone()),
"server.port" => Value::Number(cfg.server.port.into()),
"log.level" => Value::String(cfg.log.level.clone()),
"auth.admin_idle_timeout" => Value::Number(cfg.auth.admin_idle_timeout.into()),
crate::config_store::UNRESTRICTED_ADMIN_CONSENT_THRESHOLD => {
Value::Number(cfg.acl.unrestricted_admin_consent_threshold.into())
}
crate::config_store::CONSENT_REQUEST_PUSH => Value::Bool(cfg.acl.consent_request_push),
crate::credentials::trusted_witnesses::WITNESSES_TRUSTED => {
serde_json::to_value(&cfg.witnesses.trusted).unwrap_or(Value::Null)
}
other => crate::config_store::action_key_live(cfg, other)
.map(|n| Value::Number(n.into()))
.unwrap_or(Value::Null),
}
}
fn apply_to_live(cfg: &mut crate::config::AppConfig, key: &str, value: &Value) -> bool {
if key == "log.level"
&& let Some(s) = value.as_str()
{
cfg.log.level = s.to_string();
return true;
}
if key == "auth.admin_idle_timeout"
&& let Some(n) = value.as_u64()
{
cfg.auth.admin_idle_timeout = n;
return true;
}
if key == crate::config_store::UNRESTRICTED_ADMIN_CONSENT_THRESHOLD
&& let Some(n) = value.as_u64().filter(|n| *n >= 1)
{
cfg.acl.unrestricted_admin_consent_threshold = n;
return true;
}
if key == crate::config_store::CONSENT_REQUEST_PUSH
&& let Some(b) = value.as_bool()
{
cfg.acl.consent_request_push = b;
return true;
}
if key == crate::credentials::trusted_witnesses::WITNESSES_TRUSTED
&& let Ok(list) =
crate::credentials::trusted_witnesses::parse_list(value, cfg.vtc_did.as_deref())
{
cfg.witnesses.trusted = list;
return true;
}
if let Some(n) = value.as_u64()
&& crate::config_store::set_action_key(cfg, key, n)
{
return true;
}
false
}
pub const EXPORT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigExportDocument {
pub schema_version: u32,
pub exported_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub community_profile: Option<CommunityProfile>,
pub config_overrides: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportResponse {
pub document: ConfigExportDocument,
}
pub(crate) async fn export_inner(state: &AppState) -> Result<ExportResponse, AppError> {
let community_profile = load_profile(&state.community_ks).await?;
let store = ConfigStore::new(state.config_ks.clone());
let config_overrides = store.snapshot().await?;
Ok(ExportResponse {
document: ConfigExportDocument {
schema_version: EXPORT_SCHEMA_VERSION,
exported_at: Utc::now(),
community_profile,
config_overrides,
ext: None,
},
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ImportRequest {
pub document: ConfigExportDocument,
#[serde(default)]
pub confirm: bool,
#[serde(default)]
#[allow(dead_code)]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldDiff {
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_value: Option<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportResponse {
pub status: ImportStatus,
pub profile_changes: Vec<FieldDiff>,
pub override_changes: Vec<FieldDiff>,
pub pending_restart: Vec<String>,
pub rejected: Vec<RejectedKey>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ImportStatus {
Preview,
Imported,
}
pub const IMPORT_ERR_UNSUPPORTED_SCHEMA_VERSION: &str =
trust_tasks_rs::specs::vtc::config::import::v0_1::error_codes::UNSUPPORTED_SCHEMA_VERSION.code;
pub const IMPORT_ERR_COMMUNITY_DID_MISMATCH: &str =
trust_tasks_rs::specs::vtc::config::import::v0_1::error_codes::COMMUNITY_DID_MISMATCH.code;
pub(crate) async fn import_inner(
state: &AppState,
actor_did: &str,
body: ImportRequest,
) -> Result<ImportResponse, crate::error::TaskError> {
use crate::error::TaskError;
let ImportRequest {
document, confirm, ..
} = body;
let req = document;
if req.schema_version != EXPORT_SCHEMA_VERSION {
return Err(TaskError::declared(
IMPORT_ERR_UNSUPPORTED_SCHEMA_VERSION,
AppError::Validation(format!(
"unsupported export schemaVersion: got {}, expected {EXPORT_SCHEMA_VERSION}",
req.schema_version
)),
));
}
let current_profile = load_profile(&state.community_ks).await?;
if let (Some(current), Some(incoming)) = (¤t_profile, &req.community_profile)
&& current.community_did != incoming.community_did
{
return Err(TaskError::declared(
IMPORT_ERR_COMMUNITY_DID_MISMATCH,
AppError::Conflict(format!(
"communityDid mismatch: current is {}, import carries {}",
current.community_did, incoming.community_did
)),
));
}
let store = ConfigStore::new(state.config_ks.clone());
let current_overrides = store.snapshot().await?;
let mut profile_diff: Vec<FieldDiff> = Vec::new();
let mut overrides_diff: Vec<FieldDiff> = Vec::new();
let mut rejected: Vec<RejectedKey> = Vec::new();
if let Some(incoming) = &req.community_profile {
for (key, old, new) in
crate::community::profile::profile_field_pairs(current_profile.as_ref(), incoming)
{
if old != new {
profile_diff.push(FieldDiff {
key: key.into(),
old_value: old,
new_value: new,
});
}
}
}
for (key, new_value) in &req.config_overrides {
let Some(def) = lookup(key) else {
let reason = crate::config_store::host_only_refusal(key)
.unwrap_or_else(|| "unknown config key (not in registry)".into());
rejected.push(RejectedKey {
key: key.clone(),
reason,
});
continue;
};
if let Err(e) = validate_value(def, new_value) {
rejected.push(RejectedKey {
key: key.clone(),
reason: format!("validation failed: {e}"),
});
continue;
}
if let Err(e) = check_against_community(state, key, new_value).await {
rejected.push(RejectedKey {
key: key.clone(),
reason: format!("validation failed: {e}"),
});
continue;
}
let old = current_overrides.get(key).cloned();
if old.as_ref() != Some(new_value) {
overrides_diff.push(FieldDiff {
key: key.clone(),
old_value: old,
new_value: Some(new_value.clone()),
});
}
}
if !confirm {
let pending_restart = overrides_diff
.iter()
.filter(|d| matches!(lookup(&d.key), Some(def) if def.requires_restart))
.map(|d| d.key.clone())
.collect();
return Ok(ImportResponse {
status: ImportStatus::Preview,
profile_changes: profile_diff,
override_changes: overrides_diff,
pending_restart,
rejected,
});
}
let audit_writer = require_audit_writer(state)?;
let community_profile_applied = if let Some(incoming) = req.community_profile.clone() {
apply_profile_import(state, incoming, current_profile.as_ref()).await?
} else {
Vec::new()
};
let mut config_overrides_applied: Vec<String> = Vec::new();
let mut pending_restart: Vec<String> = Vec::new();
let mut audit_changes: Vec<ConfigChange> = Vec::new();
let mut requires_restart = false;
for FieldDiff {
key,
old_value,
new_value,
} in &overrides_diff
{
let Some(def) = lookup(key) else { continue };
let new_value = match new_value {
Some(v) => v.clone(),
None => continue,
};
if let Err(e) = store.put(key, &new_value).await {
rejected.push(RejectedKey {
key: key.clone(),
reason: format!("persistence failed: {e}"),
});
continue;
}
config_overrides_applied.push(key.clone());
crate::admin_actions::record_effect(state).await;
let mut change = ConfigChange {
key: key.clone(),
old_value: old_value.clone(),
new_value,
source_before: if old_value.is_some() {
ConfigSource::Db
} else {
ConfigSource::Default
},
};
change.redact_if(|k| matches!(lookup(k), Some(d) if d.sensitive));
audit_changes.push(change);
if def.trust_semantics {
warn!(
key = %key,
trust_semantics = true,
"a configuration key that alters trust semantics was imported (VTI-CMP-090)"
);
}
if def.requires_restart {
requires_restart = true;
pending_restart.push(key.clone());
}
}
if !audit_changes.is_empty() {
audit_writer
.write(
actor_did,
None,
AuditEvent::ConfigChanged(ConfigChangedData {
changes: audit_changes,
requires_restart,
}),
)
.await?;
}
if !community_profile_applied.is_empty() {
audit_writer
.write(
actor_did,
None,
AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
fields_changed: community_profile_applied.clone(),
changes: profile_diff
.iter()
.map(|d| vti_common::audit::FieldChange {
field: d.key.clone(),
old: d.old_value.clone(),
new: d.new_value.clone(),
})
.collect(),
}),
)
.await?;
}
info!(
profile_changed = community_profile_applied.len(),
overrides_applied = config_overrides_applied.len(),
rejected = rejected.len(),
"config imported"
);
let profile_changes: Vec<FieldDiff> = profile_diff
.iter()
.filter(|d| community_profile_applied.contains(&d.key))
.cloned()
.collect();
let override_changes: Vec<FieldDiff> = overrides_diff
.into_iter()
.filter(|d| config_overrides_applied.contains(&d.key))
.collect();
Ok(ImportResponse {
status: ImportStatus::Imported,
profile_changes,
override_changes,
pending_restart,
rejected,
})
}
async fn apply_profile_import(
state: &AppState,
incoming: CommunityProfile,
current: Option<&CommunityProfile>,
) -> Result<Vec<String>, AppError> {
let (mut updated, fresh) = match current {
Some(current) => (current.clone(), false),
None => {
let mut base = CommunityProfile::new(incoming.community_did.clone(), "");
base.created_at = incoming.created_at;
(base, true)
}
};
let patch = CommunityProfileUpdate {
name: Some(incoming.name),
description: Some(incoming.description),
logo_url: Some(incoming.logo_url),
public_url: Some(incoming.public_url),
contact_email: Some(incoming.contact_email),
language: Some(incoming.language),
relationship_identifier_default: Some(incoming.relationship_identifier_default),
personhood: Some(incoming.personhood),
extensions: Some(incoming.extensions),
};
let changed = patch.apply(&mut updated)?;
if fresh || !changed.is_empty() {
store_profile(&state.community_ks, &updated).await?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
}