use serde_json::json;
use tracing::{info, warn};
use vti_common::error::AppError;
use super::model::{RightsSet, Scope};
use super::ops::{Audit, audit};
use super::store::{self, RIGHTS_PREFIX, list_prefix};
use crate::acl::resource_grant::{self as rg, ResourceGrant};
use crate::server::AppState;
const UNMAPPED_PREFIX: &str = "rights-unmapped:";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RightsMigration {
pub migrated: u32,
pub applications: u32,
pub unmappable: Vec<String>,
}
pub async fn migrate_on_boot(state: &AppState) -> Result<RightsMigration, AppError> {
rg::rebuild_holder_index(&state.acl_ks).await?;
migrate_rights(state, "the boot migration").await
}
pub async fn migrate_rights(state: &AppState, how: &str) -> Result<RightsMigration, AppError> {
let _guard = store::write_lock().await;
let legacy = list_prefix::<RightsSet>(&state.git_ns.ks, RIGHTS_PREFIX).await?;
let mut out = RightsMigration::default();
if legacy.is_empty() {
return Ok(out);
}
for (key, set) in legacy {
let rest = &key[RIGHTS_PREFIX.len()..];
let scope = if let Some(id) = rest.strip_prefix("ns:") {
Scope::Namespace(id.to_string())
} else if let Some(id) = rest.strip_prefix("repo:") {
Scope::Repo(id.to_string())
} else {
warn!(key, "a rights row with an unknown scope: kept, unmapped");
move_unmapped(state, &key, rest, &set).await?;
out.unmappable.push(format!("{rest} (unknown scope)"));
continue;
};
let qualifier = store::scope_qualifier(&state.git_ns.ks, &scope).await?;
let mut unmapped = RightsSet::default();
for row in &set.rows {
let Some(q) = qualifier.clone() else {
out.unmappable
.push(format!("{}@{} → {}", row.right, scope.key(), row.subject));
unmapped.rows.push(row.clone());
continue;
};
let grant = store::grant_of(row, q);
let entry = crate::acl::get_acl_entry(&state.acl_ks, &row.subject).await?;
let mut entry = match entry {
Some(e) if !(e.is_application() && row.right.is_elevated()) => e,
Some(_) | None if row.right.is_elevated() => {
out.unmappable.push(format!(
"{} → {} (no member entry)",
grant.display(),
row.subject
));
unmapped.rows.push(row.clone());
continue;
}
_ => {
out.applications += 1;
let mut e = crate::acl::VtcAclEntry::new(
row.subject.clone(),
crate::acl::VtcRole::Application,
crate::acl::AdminAuthority::none(),
row.granted_by.clone(),
);
e.label = Some("holds git rights without membership".into());
e
}
};
if !holds(&entry.resource_grants, &grant) {
entry.resource_grants.push(grant);
out.migrated += 1;
}
crate::acl::store_acl_entry(&state.acl_ks, &entry).await?;
}
if !unmapped.rows.is_empty() {
move_unmapped(state, &key, rest, &unmapped).await?;
} else {
state.git_ns.ks.remove(key.clone()).await?;
}
}
out.unmappable.sort();
info!(
migrated = out.migrated,
applications = out.applications,
unmappable = out.unmappable.len(),
"moved git-namespace rights onto the ACL entries (phase C3)"
);
let vtc = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_else(|| "did:key:vtc-boot".into());
audit(
state,
&vtc,
None,
Audit {
action: "gitNs.rights.migrated",
namespace: None,
resource: None,
right: None,
policy_version: None,
detail: Some(
json!({
"migrated": out.migrated,
"applications": out.applications,
"unmappable": out.unmappable.len(),
"by": how,
})
.to_string(),
),
},
)
.await;
if !out.unmappable.is_empty() {
raise_unmappable(state, &out.unmappable).await?;
}
Ok(out)
}
fn holds(grants: &[ResourceGrant], g: &ResourceGrant) -> bool {
grants
.iter()
.any(|h| h.capability == g.capability && h.resource == g.resource && h.grade == g.grade)
}
async fn move_unmapped(
state: &AppState,
key: &str,
rest: &str,
set: &RightsSet,
) -> Result<(), AppError> {
state
.git_ns
.ks
.insert(format!("{UNMAPPED_PREFIX}{rest}"), set)
.await?;
state.git_ns.ks.remove(key.to_string()).await
}
async fn raise_unmappable(state: &AppState, unmappable: &[String]) -> Result<(), AppError> {
let now = crate::auth::session::now_epoch();
let acknowledgers: Vec<String> = crate::acl::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 dids: Vec<String> = unmappable
.iter()
.filter_map(|u| u.split(" → ").nth(1))
.map(|d| d.split(' ').next().unwrap_or(d).to_string())
.collect();
let write = crate::admin_actions::OperatorWrite {
marker: format!("git-rights-migration:{}", unmappable.join(",")),
command: format!(
"the migration of git-namespace rights onto ACL entries, which could not carry {} \
across — kept inert under rights-unmapped:*; grant them again with \
git-ns/right/grant if they should stand: {}",
unmappable.len(),
unmappable.join("; ")
),
action: "aclMigration".into(),
dids: if dids.is_empty() {
vec!["did:key:vtc-boot".into()]
} else {
dids
},
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(())
}