use serde::Deserialize;
use serde_json::Value;
use vti_common::error::AppError;
use super::{AdminAuthority, VtcAclEntry, VtcRole, store_acl_entry};
use crate::server::AppState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Migrated {
Current,
Mapped,
NeedsRegrant { contexts: Vec<String> },
}
#[derive(Debug, Deserialize)]
struct LegacyEntry {
did: String,
role: VtcRole,
#[serde(default)]
label: Option<String>,
#[serde(default)]
allowed_contexts: Vec<String>,
created_at: u64,
created_by: String,
#[serde(default)]
updated_at: Option<u64>,
#[serde(default)]
updated_by: Option<String>,
#[serde(default)]
expires_at: Option<u64>,
}
pub fn migrate_row(bytes: &[u8]) -> Result<(VtcAclEntry, Migrated), AppError> {
let value: Value = serde_json::from_slice(bytes)
.map_err(|e| AppError::Validation(format!("backup: ACL row is not JSON: {e}")))?;
if value.get("act").is_some() {
let entry: VtcAclEntry = serde_json::from_value(value)
.map_err(|e| AppError::Validation(format!("backup: ACL row does not decode: {e}")))?;
return Ok((entry, Migrated::Current));
}
let legacy: LegacyEntry = serde_json::from_value(value).map_err(|e| {
AppError::Validation(format!("backup: legacy ACL row does not decode: {e}"))
})?;
let (admin, outcome) = match (&legacy.role, legacy.allowed_contexts.is_empty()) {
(VtcRole::Admin, false) => (
AdminAuthority::none(),
Migrated::NeedsRegrant {
contexts: legacy.allowed_contexts.clone(),
},
),
(role, _) => (role.implied_authority(), Migrated::Mapped),
};
Ok((
VtcAclEntry {
did: legacy.did,
role: legacy.role,
label: legacy.label,
admin,
delegated_by: None,
created_at: legacy.created_at,
created_by: legacy.created_by,
updated_at: legacy.updated_at,
updated_by: legacy.updated_by,
expires_at: legacy.expires_at,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
outcome,
))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BootMigration {
pub migrated: u32,
pub administrators: u32,
pub lost_admin_authority: Vec<String>,
}
const MIGRATION_MARKER: &str = "acl-migration";
pub async fn migrate_on_boot(state: &AppState) -> Result<BootMigration, AppError> {
let mut planned = Vec::new();
for (key, value) in state.acl_ks.prefix_iter_raw(b"acl:".to_vec()).await? {
let did = String::from_utf8_lossy(&key[b"acl:".len()..]).into_owned();
let refuse = |why: String| {
AppError::Config(format!(
"the ACL row for {did} predates role-based administration and cannot be \
migrated ({why}). Nothing was changed. With the daemon stopped, rewrite the \
entry — `vtc acl remove --did {did}`, then `vtc acl add --did {did} --role \
<role> [--admin-role <role>]` — and start it again; or reinstall and restore \
the community from a backup taken before the upgrade (`cnm backup import`), \
which maps the same rows"
))
};
let (entry, how) = migrate_row(&value).map_err(|e| refuse(e.to_string()))?;
if how == Migrated::Current {
continue;
}
if entry.did != did {
return Err(refuse(format!(
"the row names {} but is stored under {did}",
entry.did
)));
}
planned.push((entry, how));
}
let mut out = BootMigration::default();
for (entry, how) in planned {
store_acl_entry(&state.acl_ks, &entry).await?;
out.migrated += 1;
if entry.is_administrator() {
out.administrators += 1;
}
if let Migrated::NeedsRegrant { contexts } = how {
tracing::warn!(
did = %entry.did,
contexts = ?contexts,
"a context-scoped administrator came across the ACL migration with no \
administrative role; re-grant it with acl/update if it should keep any"
);
out.lost_admin_authority.push(entry.did);
}
}
if out.migrated == 0 {
return Ok(out);
}
out.lost_admin_authority.sort();
tracing::info!(
migrated = out.migrated,
administrators = out.administrators,
lost = out.lost_admin_authority.len(),
"migrated pre-role ACL rows to role-based administration"
);
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
"did:key:vtc-boot",
None,
vti_common::audit::AuditEvent::AclMigrated(vti_common::audit::AclMigratedData {
migrated: out.migrated,
administrators: out.administrators,
lost_admin_authority: out.lost_admin_authority.clone(),
}),
)
.await?;
}
if !out.lost_admin_authority.is_empty() {
let now = crate::auth::session::now_epoch();
let acknowledgers: Vec<String> = super::list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(|e| !e.is_expired(now) && e.is_community_admin())
.map(|e| e.did)
.collect();
let write = crate::admin_actions::OperatorWrite {
marker: format!("{MIGRATION_MARKER}:{}", out.lost_admin_authority.join(",")),
command: format!(
"the boot migration to role-based administration, which left {} with no \
administrative role",
out.lost_admin_authority.join(", ")
),
action: "aclMigration".into(),
dids: out.lost_admin_authority.clone(),
operator_host: gethostname::gethostname().to_string_lossy().into_owned(),
invoked_at: chrono::Utc::now(),
acknowledgers: (!acknowledgers.is_empty()).then_some(acknowledgers),
};
crate::admin_actions::raise_operator_item(state, &write).await?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AdminRole, Capability};
use serde_json::json;
fn legacy(role: &str, contexts: &[&str]) -> Vec<u8> {
serde_json::to_vec(&json!({
"did": "did:key:zLegacy",
"role": role,
"label": "x",
"allowed_contexts": contexts,
"created_at": 7,
"created_by": "did:key:zInstall",
}))
.unwrap()
}
#[test]
fn a_backup_row_maps_onto_the_role_its_community_role_implied() {
let (e, m) = migrate_row(&legacy("admin", &[])).unwrap();
assert_eq!(m, Migrated::Mapped);
assert_eq!(e.admin, AdminAuthority::community_admin());
assert!(e.can(Capability::RolesAssign, None));
assert_eq!(e.role, VtcRole::Admin);
assert_eq!(e.created_at, 7);
let (e, m) = migrate_row(&legacy("admin", &["ctx-a"])).unwrap();
assert_eq!(
m,
Migrated::NeedsRegrant {
contexts: vec!["ctx-a".into()]
}
);
assert_eq!(
e.admin,
AdminAuthority::none(),
"a label never meant anything"
);
assert_eq!(e.role, VtcRole::Admin, "the community role is kept");
let (e, _) = migrate_row(&legacy("moderator", &[])).unwrap();
assert_eq!(e.admin.admin_role, Some(AdminRole::Moderator));
assert_eq!(e.role, VtcRole::Moderator);
let (e, _) = migrate_row(&legacy("issuer", &[])).unwrap();
assert_eq!(e.admin.admin_role, Some(AdminRole::CredentialOfficer));
for role in ["member", "custom:editor"] {
let (e, m) = migrate_row(&legacy(role, &[])).unwrap();
assert_eq!(m, Migrated::Mapped);
assert_eq!(e.admin, AdminAuthority::none(), "{role}");
}
}
#[test]
fn a_current_row_is_taken_unchanged() {
let entry = VtcAclEntry::new(
"did:key:zNew",
VtcRole::Member,
AdminAuthority::for_role(AdminRole::Auditor),
"did:key:zAdmin",
);
let (e, m) = migrate_row(&serde_json::to_vec(&entry).unwrap()).unwrap();
assert_eq!(m, Migrated::Current);
assert_eq!(e, entry);
}
}