use std::collections::BTreeMap;
use serde::de::DeserializeOwned;
use tokio::sync::{Mutex, MutexGuard};
use tracing::warn;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::GitNsHandles;
use super::model::{LinkAttempt, Namespace, Repo, RightRow, RightsSet, Scope};
static WRITE_LOCK: Mutex<()> = Mutex::const_new(());
pub async fn write_lock() -> MutexGuard<'static, ()> {
WRITE_LOCK.lock().await
}
const NS_PREFIX: &str = "ns:";
const REPO_PREFIX: &str = "repo:";
pub(crate) const RIGHTS_PREFIX: &str = "rights:";
const LINK_PREFIX: &str = "link:";
pub(crate) async fn list_prefix<T: DeserializeOwned>(
ks: &KeyspaceHandle,
prefix: &str,
) -> Result<Vec<(String, T)>, AppError> {
let rows = ks.prefix_iter_raw(prefix.as_bytes().to_vec()).await?;
let mut out = Vec::with_capacity(rows.len());
for (k, v) in rows {
let key = String::from_utf8_lossy(&k).to_string();
match serde_json::from_slice::<T>(&v) {
Ok(t) => out.push((key, t)),
Err(e) => warn!(key, error = %e, "skipping unreadable git-ns row"),
}
}
Ok(out)
}
pub async fn get_namespace(ks: &KeyspaceHandle, id: &str) -> Result<Option<Namespace>, AppError> {
ks.get(format!("{NS_PREFIX}{id}")).await
}
pub async fn put_namespace(ks: &KeyspaceHandle, ns: &Namespace) -> Result<(), AppError> {
ks.insert(format!("{NS_PREFIX}{}", ns.id), ns).await
}
pub async fn delete_namespace(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
ks.remove(format!("{NS_PREFIX}{id}")).await
}
pub async fn list_namespaces(ks: &KeyspaceHandle) -> Result<Vec<Namespace>, AppError> {
Ok(list_prefix(ks, NS_PREFIX)
.await?
.into_iter()
.map(|(_, v)| v)
.collect())
}
pub async fn get_repo(ks: &KeyspaceHandle, id: &str) -> Result<Option<Repo>, AppError> {
ks.get(format!("{REPO_PREFIX}{id}")).await
}
pub async fn put_repo(ks: &KeyspaceHandle, repo: &Repo) -> Result<(), AppError> {
ks.insert(format!("{REPO_PREFIX}{}", repo.id), repo).await
}
pub async fn delete_repo(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
ks.remove(format!("{REPO_PREFIX}{id}")).await
}
pub async fn list_repos(ks: &KeyspaceHandle) -> Result<Vec<Repo>, AppError> {
Ok(list_prefix(ks, REPO_PREFIX)
.await?
.into_iter()
.map(|(_, v)| v)
.collect())
}
pub async fn get_rights(h: &GitNsHandles, scope: &Scope) -> Result<RightsSet, AppError> {
let mut set = RightsSet::default();
for entry in holders(&h.acl_ks).await? {
for g in &entry.resource_grants {
if grant_on_scope(&h.ks, g, scope).await?
&& let Some(row) = row_of(&entry.did, g)
{
set.rows.push(row);
}
}
}
sort_rows(&mut set.rows);
Ok(set)
}
pub async fn put_rights(h: &GitNsHandles, scope: &Scope, set: &RightsSet) -> Result<(), AppError> {
use crate::acl::resource_grant as rg;
let qualifier = scope_qualifier(&h.ks, scope).await?;
if qualifier.is_none() && !set.rows.is_empty() {
return Err(AppError::Internal(format!(
"git-ns: no namespace or repository record for {}; its rights cannot be written",
scope.key()
)));
}
let mut desired: BTreeMap<String, Vec<rg::ResourceGrant>> = BTreeMap::new();
if let Some(q) = qualifier.as_ref() {
for row in &set.rows {
desired
.entry(row.subject.clone())
.or_default()
.push(grant_of(row, q.clone()));
}
}
let (mut gaining, mut losing, mut removing) = (Vec::new(), Vec::new(), Vec::new());
for mut entry in holders(&h.acl_ks).await? {
let mut kept = Vec::with_capacity(entry.resource_grants.len());
let mut before = Vec::new();
for g in std::mem::take(&mut entry.resource_grants) {
if grant_on_scope(&h.ks, &g, scope).await? {
before.push(g);
} else {
kept.push(g);
}
}
let after = desired.remove(&entry.did).unwrap_or_default();
if before == after {
continue;
}
let gains = after.iter().any(|g| !before.contains(g));
kept.extend(after);
entry.resource_grants = kept;
if entry.resource_grants.is_empty() && entry.is_application() {
removing.push(entry.did.clone());
} else if gains {
gaining.push(entry);
} else {
losing.push(entry);
}
}
for (did, grants) in desired {
let entry = match crate::acl::get_acl_entry(&h.acl_ks, &did).await? {
Some(mut e) => {
e.resource_grants.extend(grants);
e
}
None => {
if let Some(g) = grants
.iter()
.find(|g| g.git_right().is_some_and(|r| r.is_elevated()))
{
return Err(AppError::Internal(format!(
"git-ns: refusing to write {} to {did}, which holds no membership — an \
elevated right goes to a current member only",
g.display()
)));
}
let by = grants
.first()
.map(|g| g.delegated_by.clone())
.unwrap_or_default();
let mut e = crate::acl::VtcAclEntry::new(
did.clone(),
crate::acl::VtcRole::Application,
crate::acl::AdminAuthority::none(),
by,
);
e.label = Some("holds git rights without membership".into());
e.resource_grants = grants;
e
}
};
gaining.push(entry);
}
for e in gaining.iter().chain(losing.iter()) {
crate::acl::store_acl_entry(&h.acl_ks, e).await?;
if e.resource_grants.is_empty() {
rg::index_holder(&h.acl_ks, &e.did, false).await?;
}
}
for did in removing {
h.acl_ks.remove(format!("acl:{did}")).await?;
rg::index_holder(&h.acl_ks, &did, false).await?;
}
Ok(())
}
async fn holders(acl_ks: &KeyspaceHandle) -> Result<Vec<crate::acl::VtcAclEntry>, AppError> {
let mut out = Vec::new();
for did in crate::acl::resource_grant::indexed_holders(acl_ks).await? {
if let Some(e) = crate::acl::get_acl_entry(acl_ks, &did).await?
&& !e.resource_grants.is_empty()
{
out.push(e);
}
}
Ok(out)
}
pub async fn scope_qualifier(
ks: &KeyspaceHandle,
scope: &Scope,
) -> Result<Option<crate::acl::ResourceQualifier>, AppError> {
use crate::acl::resource_grant as rg;
Ok(match scope {
Scope::Namespace(id) => get_namespace(ks, id)
.await?
.map(|n| rg::namespace_qualifier(&n.forge, &n.owner)),
Scope::Repo(id) => match get_repo(ks, id).await? {
Some(r) => get_namespace(ks, &r.namespace_id)
.await?
.map(|n| rg::repo_qualifier(&n.forge, &n.owner, id)),
None => None,
},
})
}
async fn grant_on_scope(
ks: &KeyspaceHandle,
g: &crate::acl::resource_grant::ResourceGrant,
scope: &Scope,
) -> Result<bool, AppError> {
use crate::acl::ResourceQualifier as Q;
if g.git_right().is_none() {
return Ok(false);
}
Ok(match (scope, &g.resource) {
(Scope::Repo(id), Q::GitRepo(_)) => {
crate::acl::resource_grant::repo_id_of(&g.resource) == Some(id.as_str())
}
(Scope::Namespace(_), Q::GitNs(_)) => {
scope_qualifier(ks, scope).await?.as_ref() == Some(&g.resource)
}
_ => false,
})
}
pub fn row_of(subject: &str, g: &crate::acl::resource_grant::ResourceGrant) -> Option<RightRow> {
Some(RightRow {
subject: subject.to_string(),
right: g.git_right()?,
granted_by: g.delegated_by.clone(),
granted_at: g.granted_at,
expires_at: g.expires_at,
reason: g.reason.clone(),
subject_was_member: g.subject_was_member,
granter_was_member: g.granter_was_member,
break_glass: g.break_glass.clone(),
review: g.review.clone(),
single_admin: g.single_admin.clone(),
})
}
pub fn grant_of(
row: &RightRow,
resource: crate::acl::ResourceQualifier,
) -> crate::acl::resource_grant::ResourceGrant {
let (capability, grade) = crate::acl::resource_grant::capability_for(row.right);
crate::acl::resource_grant::ResourceGrant {
capability,
resource,
grade,
delegated_by: row.granted_by.clone(),
granted_at: row.granted_at,
expires_at: row.expires_at,
reason: row.reason.clone(),
subject_was_member: row.subject_was_member,
granter_was_member: row.granter_was_member,
break_glass: row.break_glass.clone(),
review: row.review.clone(),
single_admin: row.single_admin.clone(),
}
}
fn sort_rows(rows: &mut [RightRow]) {
rows.sort_by(|a, b| {
(a.granted_at, &a.subject, a.right).cmp(&(b.granted_at, &b.subject, b.right))
});
}
pub async fn get_link(ks: &KeyspaceHandle, id: &str) -> Result<Option<LinkAttempt>, AppError> {
ks.get(format!("{LINK_PREFIX}{id}")).await
}
pub async fn put_link(ks: &KeyspaceHandle, link: &LinkAttempt) -> Result<(), AppError> {
ks.insert(format!("{LINK_PREFIX}{}", link.id), link).await
}
pub async fn delete_link(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
ks.remove(format!("{LINK_PREFIX}{id}")).await
}
pub async fn list_links(ks: &KeyspaceHandle) -> Result<Vec<LinkAttempt>, AppError> {
Ok(list_prefix(ks, LINK_PREFIX)
.await?
.into_iter()
.map(|(_, v)| v)
.collect())
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub namespaces: Vec<Namespace>,
pub repos: Vec<Repo>,
pub rights: BTreeMap<Scope, RightsSet>,
}
impl Snapshot {
pub async fn load(h: &GitNsHandles) -> Result<Snapshot, AppError> {
use crate::acl::ResourceQualifier as Q;
let mut snap = Snapshot::load_records(&h.ks).await?;
let epoch = crate::auth::session::now_epoch();
let mut rights: BTreeMap<Scope, RightsSet> = BTreeMap::new();
for entry in holders(&h.acl_ks).await? {
if entry.is_expired(epoch) {
continue;
}
for g in &entry.resource_grants {
let scope = match &g.resource {
Q::GitRepo(_) => crate::acl::resource_grant::repo_id_of(&g.resource)
.filter(|id| snap.repo(id).is_some())
.map(|id| Scope::Repo(id.to_string())),
Q::GitNs(path) => snap
.namespace_at(path)
.map(|n| Scope::Namespace(n.id.clone())),
_ => None,
};
if let (Some(scope), Some(row)) = (scope, row_of(&entry.did, g)) {
rights.entry(scope).or_default().rows.push(row);
}
}
}
for set in rights.values_mut() {
sort_rows(&mut set.rows);
}
snap.rights = rights;
Ok(snap)
}
pub async fn load_records(ks: &KeyspaceHandle) -> Result<Snapshot, AppError> {
Ok(Snapshot {
namespaces: list_namespaces(ks).await?,
repos: list_repos(ks).await?,
rights: BTreeMap::new(),
})
}
pub fn namespace_at(&self, path: &str) -> Option<&Namespace> {
let at = |n: &&Namespace| format!("{}/{}", n.forge, n.owner) == path;
self.namespaces
.iter()
.filter(at)
.find(|n| n.state == super::model::NamespaceState::Bound)
.or_else(|| self.namespaces.iter().find(at))
}
pub fn without_unratified_break_glass(&self) -> Snapshot {
let mut out = self.clone();
for set in out.rights.values_mut() {
set.rows.retain(|r| !r.is_unratified_break_glass());
}
out
}
pub fn namespace(&self, id: &str) -> Option<&Namespace> {
self.namespaces.iter().find(|n| n.id == id)
}
pub fn repo(&self, id: &str) -> Option<&Repo> {
self.repos.iter().find(|r| r.id == id)
}
pub fn namespace_containing(&self, resource: &super::model::Resource) -> Option<&Namespace> {
self.namespaces
.iter()
.find(|n| n.resource().contains(resource))
}
pub fn lookup_repo(&self, resource: &str) -> Result<Option<&Repo>, String> {
let detached = |r: &&Repo| r.state == super::model::RepoState::Detached;
let at: Vec<&Repo> = self
.repos
.iter()
.filter(|r| r.resource == resource)
.collect();
let live: Vec<&Repo> = at.iter().copied().filter(|r| !detached(r)).collect();
match live.len() {
0 => Ok(at
.into_iter()
.filter(detached)
.max_by(|a, b| a.created_at.cmp(&b.created_at).then(b.id.cmp(&a.id)))),
1 => Ok(Some(live[0])),
n => Err(format!(
"{n} governed repositories are recorded at {resource}; an administrator must \
resolve which one it is before anything is done there"
)),
}
}
pub fn repo_at(&self, resource: &str) -> Option<&Repo> {
match self.lookup_repo(resource) {
Ok(r) => r,
Err(e) => {
tracing::error!("{e}");
None
}
}
}
pub fn forge_id_held_elsewhere(&self, forge_id: &str, except: &str) -> Option<&Repo> {
self.repos.iter().find(|r| {
r.id != except
&& r.state != super::model::RepoState::Detached
&& r.forge_id.as_deref() == Some(forge_id)
})
}
pub fn repo_by_forge_id(&self, namespace_id: &str, forge_id: &str) -> Option<&Repo> {
self.repos.iter().find(|r| {
r.namespace_id == namespace_id
&& r.state != super::model::RepoState::Detached
&& r.forge_id.as_deref() == Some(forge_id)
})
}
pub fn rows(&self, scope: &Scope) -> &[RightRow] {
self.rights.get(scope).map_or(&[], |s| s.rows.as_slice())
}
pub fn scope_resource(&self, scope: &Scope) -> Option<super::model::Resource> {
match scope {
Scope::Namespace(id) => self.namespace(id).map(Namespace::resource),
Scope::Repo(id) => self.repo(id).and_then(Repo::resource),
}
}
pub fn scope_namespace(&self, scope: &Scope) -> Option<&Namespace> {
match scope {
Scope::Namespace(id) => self.namespace(id),
Scope::Repo(id) => self.repo(id).and_then(|r| self.namespace(&r.namespace_id)),
}
}
}