use std::collections::BTreeSet;
use chrono::Utc;
use serde_json::Value;
use vti_common::audit::{AuditEvent, GroupGrantsChangedData};
use vti_common::error::AppError;
use super::bindings::{self, Binding};
use super::{Group, JoinPolicy};
use crate::acl::group_grant::GroupGrant;
use crate::acl::{AdminRole, CapRef, VtcAclEntry, get_acl_entry, store_acl_entry};
use crate::server::AppState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorityBinding {
Grants(Vec<CapRef>),
Role(String),
}
impl AuthorityBinding {
pub fn from_spec(spec: &Value) -> Option<Self> {
match spec.get("kind").and_then(Value::as_str)? {
"grants" => serde_json::from_value(spec.get("grants")?.clone())
.ok()
.map(Self::Grants),
"role" => spec
.get("role")
.and_then(Value::as_str)
.map(|r| Self::Role(r.to_string())),
_ => None,
}
}
}
pub fn parse_grants(spec: &Value) -> Result<Vec<CapRef>, Vec<String>> {
let mut out = Vec::new();
let mut unknown = Vec::new();
for g in spec
.get("grants")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = g.get("capability").and_then(Value::as_str).unwrap_or("");
let resource = g.get("resource").and_then(Value::as_str);
let shown = match resource {
Some(r) => format!("{name}@{r}"),
None => name.to_string(),
};
let Ok(capability) = name.parse::<crate::acl::Capability>() else {
unknown.push(shown);
continue;
};
let resource = match resource.map(str::parse::<crate::acl::ResourceQualifier>) {
None => None,
Some(Ok(q)) if capability.admits(&q) => Some(q),
Some(_) => {
unknown.push(shown);
continue;
}
};
out.push(CapRef::new(capability, resource));
}
if unknown.is_empty() {
Ok(out)
} else {
Err(unknown)
}
}
pub fn confers_authority(spec: &Value) -> bool {
matches!(
spec.get("kind").and_then(Value::as_str),
Some("grants" | "role")
)
}
pub async fn role_capabilities(
state: &AppState,
name: &str,
) -> Result<Option<Vec<CapRef>>, AppError> {
let Ok(role) = name.parse::<AdminRole>() else {
return Ok(None);
};
if let AdminRole::Custom(custom) = &role {
return Ok(crate::acl::roles::get(&state.acl_ks, custom)
.await?
.map(|d| d.ceiling));
}
Ok(Some(
role.ceiling()
.into_iter()
.filter(|i| i.qualification != crate::acl::capability::Qualification::Required)
.map(|i| CapRef::all(i.capability))
.collect(),
))
}
pub async fn capabilities_of(
state: &AppState,
binding: &AuthorityBinding,
) -> Result<Vec<CapRef>, AppError> {
Ok(match binding {
AuthorityBinding::Grants(g) => g.clone(),
AuthorityBinding::Role(name) => role_capabilities(state, name).await?.unwrap_or_default(),
})
}
pub fn binder_holds(entry: &VtcAclEntry, cap: &CapRef) -> bool {
let mut own = entry.clone();
own.group_grants.clear();
own.holds(cap)
}
pub fn binder(b: &Binding) -> &str {
b.delegated_by.as_deref().unwrap_or(&b.created_by)
}
async fn provisionable(state: &AppState, entry: &VtcAclEntry) -> Result<bool, AppError> {
if entry.is_expired(vti_common::auth::session::now_epoch()) || entry.is_application() {
return Ok(false);
}
Ok(crate::members::get_member(&state.members_ks, &entry.did)
.await?
.is_some_and(|m| !m.is_removed()))
}
async fn desired(state: &AppState, entry: &VtcAclEntry) -> Result<Vec<GroupGrant>, AppError> {
if !provisionable(state, entry).await? {
return Ok(Vec::new());
}
let ks = state.directory.ks();
let Some(entry_id) = crate::directory::cards::entry_of(ks, &entry.did).await? else {
return Ok(Vec::new());
};
let now = Utc::now();
let mut out: Vec<GroupGrant> = Vec::new();
for m in super::groups_of(ks, &entry_id).await? {
if m.state != "active" {
continue;
}
let Some(group) = super::get(ks, &m.group_id).await? else {
continue;
};
out.extend(rows_for(state, entry, &group, now).await?);
}
Ok(out)
}
pub(crate) async fn rows_for(
state: &AppState,
entry: &VtcAclEntry,
group: &Group,
now: chrono::DateTime<Utc>,
) -> Result<Vec<GroupGrant>, AppError> {
if group.kind == super::GroupKind::Dynamic || group.join_policy != JoinPolicy::Closed {
return Ok(Vec::new());
}
let mut out = Vec::new();
for b in bindings::of_group(state.directory.ks(), &group.group_id).await? {
if b.state != Binding::ACTIVE {
continue;
}
let Some(ab) = AuthorityBinding::from_spec(&b.binding) else {
continue;
};
let binder_did = binder(&b);
let Some(binder_entry) = get_acl_entry(&state.acl_ks, binder_did).await? else {
continue;
};
for cap in capabilities_of(state, &ab).await? {
if !binder_holds(&binder_entry, &cap) {
continue;
}
let mut row = GroupGrant {
capability: cap.capability,
resource: cap.resource,
group_id: group.group_id.clone(),
binding_id: b.binding_id.clone(),
delegated_by: binder_did.to_string(),
granted_at: now,
};
if let Some(held) = entry.group_grants.iter().find(|g| g.same_row(&row)) {
row.granted_at = held.granted_at;
}
if !out.iter().any(|g: &GroupGrant| g.same_row(&row)) {
out.push(row);
}
}
}
Ok(out)
}
pub fn without(entry: &VtcAclEntry, group_id: &str, binding_id: Option<&str>) -> VtcAclEntry {
let mut after = entry.clone();
after
.group_grants
.retain(|g| g.group_id != group_id || binding_id.is_some_and(|b| g.binding_id != b));
after
}
pub fn with(entry: &VtcAclEntry, rows: &[GroupGrant]) -> VtcAclEntry {
let mut after = entry.clone();
for r in rows {
if !after.group_grants.iter().any(|g| g.same_row(r)) {
after.group_grants.push(r.clone());
}
}
after
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Provisioned {
pub added: Vec<GroupGrant>,
pub removed: Vec<GroupGrant>,
}
impl Provisioned {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
pub async fn provision(state: &AppState, did: &str) -> Result<Provisioned, AppError> {
let changed = {
let _guard = crate::git_ns::store::write_lock().await;
let Some(mut entry) = get_acl_entry(&state.acl_ks, did).await? else {
return Ok(Provisioned::default());
};
let want = desired(state, &entry).await?;
let changed = Provisioned {
added: want
.iter()
.filter(|w| !entry.group_grants.iter().any(|g| g.same_row(w)))
.cloned()
.collect(),
removed: entry
.group_grants
.iter()
.filter(|g| !want.iter().any(|w| w.same_row(g)))
.cloned()
.collect(),
};
if changed.is_empty() {
return Ok(changed);
}
entry.group_grants = want;
store_acl_entry(&state.acl_ks, &entry).await?;
changed
};
audit(state, did, &changed).await;
Ok(changed)
}
pub async fn provision_all(state: &AppState) -> Result<usize, AppError> {
let ks = state.directory.ks();
let mut dids: BTreeSet<String> = crate::acl::list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(|e| !e.group_grants.is_empty())
.map(|e| e.did)
.collect();
let mut authority_groups = BTreeSet::new();
for b in bindings::all(ks).await? {
if confers_authority(&b.binding) {
authority_groups.insert(b.group_id);
}
}
for group_id in &authority_groups {
for m in super::members_of(ks, group_id).await? {
if let Some(card) = crate::directory::cards::get(ks, &m.entry_id).await? {
dids.insert(card.did);
}
}
}
let mut n = 0;
for did in dids {
match provision(state, &did).await {
Ok(p) if !p.is_empty() => n += 1,
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "a member's group grants could not be provisioned")
}
}
}
forget_removed(state).await?;
Ok(n)
}
pub async fn forget_removed(state: &AppState) -> Result<usize, AppError> {
let ks = state.directory.ks();
let removing: Vec<Binding> = bindings::all(ks)
.await?
.into_iter()
.filter(|b| b.state == Binding::REMOVING && confers_authority(&b.binding))
.collect();
if removing.is_empty() {
return Ok(0);
}
let named: BTreeSet<String> = crate::acl::list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.flat_map(|e| e.group_grants.into_iter().map(|g| g.binding_id))
.collect();
let mut n = 0;
for b in removing {
if !named.contains(&b.binding_id) {
bindings::delete(ks, &b.group_id, &b.binding_id).await?;
n += 1;
}
}
Ok(n)
}
pub async fn provision_group(state: &AppState, group_id: &str) -> Result<(), AppError> {
for did in super::member_dids(state, group_id).await? {
provision(state, &did).await?;
}
forget_removed(state).await?;
Ok(())
}
pub async fn carries_authority(state: &AppState, group_id: &str) -> Result<bool, AppError> {
Ok(bindings::of_group(state.directory.ks(), group_id)
.await?
.iter()
.any(|b| confers_authority(&b.binding)))
}
async fn audit(state: &AppState, did: &str, changed: &Provisioned) {
let Some(writer) = state.audit_writer.as_ref() else {
tracing::warn!("audit writer not configured: a group grant change is unaudited");
return;
};
let entry_id = crate::directory::cards::entry_of(state.directory.ks(), did)
.await
.ok()
.flatten()
.unwrap_or_default();
let mut sources: BTreeSet<(String, String)> = BTreeSet::new();
for g in changed.added.iter().chain(&changed.removed) {
sources.insert((g.group_id.clone(), g.binding_id.clone()));
}
for (group_id, binding_id) in sources {
let of = |rows: &[GroupGrant]| -> Vec<String> {
rows.iter()
.filter(|g| g.group_id == group_id && g.binding_id == binding_id)
.map(|g| g.cap_ref().display())
.collect()
};
let event = AuditEvent::GroupGrantsChanged(GroupGrantsChangedData {
group_id: group_id.clone(),
binding_id: binding_id.clone(),
entry_id: entry_id.clone(),
added: of(&changed.added),
removed: of(&changed.removed),
});
let community = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_default();
if let Err(e) = writer.write(&community, None, event).await {
tracing::warn!(error = %e, "a group grant change could not be audited");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Capability;
use serde_json::json;
#[test]
fn an_authority_binding_reads_its_terms() {
assert_eq!(
AuthorityBinding::from_spec(&json!({
"kind": "grants",
"grants": [
{ "capability": "vtc.invitations.manage" },
{ "capability": "vtc.groups.manage", "resource": "group:grp_x" },
],
})),
Some(AuthorityBinding::Grants(vec![
CapRef::all(Capability::InvitationsManage),
CapRef::new(
Capability::GroupsManage,
Some(crate::acl::ResourceQualifier::Group("grp_x".into()))
),
]))
);
assert_eq!(
AuthorityBinding::from_spec(&json!({ "kind": "role", "role": "custom:moderator" })),
Some(AuthorityBinding::Role("custom:moderator".into()))
);
let room =
json!({ "kind": "room", "roomId": "did:x", "actions": ["read"], "validFor": "P30D" });
assert_eq!(AuthorityBinding::from_spec(&room), None);
assert!(!confers_authority(&room));
}
#[test]
fn vti_dir_102_a_binder_holds_only_what_no_group_gave_them() {
let mut admin = crate::acl::AdminAuthority::community_admin();
admin.capabilities = crate::acl::CapabilityScope::Listed {
grants: vec![crate::acl::CapabilityGrant::from(CapRef::all(
Capability::AuditRead,
))],
};
let mut binder = VtcAclEntry::new(
"did:key:zB",
crate::acl::VtcRole::Member,
admin,
"did:key:zI",
);
let invite = CapRef::all(Capability::InvitationsManage);
assert!(binder_holds(&binder, &CapRef::all(Capability::AuditRead)));
assert!(!binder_holds(&binder, &invite));
binder.group_grants = vec![GroupGrant {
capability: Capability::InvitationsManage,
resource: None,
group_id: "grp_a".into(),
binding_id: "gb_a".into(),
delegated_by: "did:key:zB".into(),
granted_at: Utc::now(),
}];
assert!(binder.holds(&invite));
assert!(!binder_holds(&binder, &invite));
}
}