use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::VtcRole;
pub use super::capability::AdminAuthority;
use super::capability::{CapRef, Capability, ResourceQualifier};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VtcAclEntry {
pub did: String,
pub role: VtcRole,
pub label: Option<String>,
#[serde(flatten)]
pub admin: AdminAuthority,
#[serde(default)]
pub delegated_by: Option<String>,
pub created_at: u64,
pub created_by: String,
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub updated_by: Option<String>,
#[serde(default)]
pub expires_at: Option<u64>,
#[serde(
default,
rename = "resourceGrants",
skip_serializing_if = "Vec::is_empty"
)]
pub resource_grants: Vec<super::resource_grant::ResourceGrant>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub label_set_by_subject: bool,
#[serde(skip)]
pub suspension: Option<Suspension>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Suspension {
pub action_id: String,
pub lands_at: u64,
pub requester: String,
}
impl Suspension {
pub fn lands_at_rfc3339(&self) -> String {
chrono::DateTime::from_timestamp(self.lands_at as i64, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| self.lands_at.to_string())
}
pub fn refusal(&self, did: &str) -> AppError {
AppError::Forbidden(self.message(did))
}
pub fn message(&self, did: &str) -> String {
format!(
"{did} is suspended pending its removal: a reduction of this entry is cooling off \
(action {}, requested by {}, lands {}), and until it lands or is cancelled the \
entry authorizes nothing (VTI-APV-019). You can still sign in and see the action \
(vtc/admin/actions/show)",
self.action_id,
self.requester,
self.lands_at_rfc3339(),
)
}
}
impl VtcAclEntry {
pub fn new(
did: impl Into<String>,
role: VtcRole,
admin: AdminAuthority,
created_by: impl Into<String>,
) -> Self {
Self {
did: did.into(),
role,
label: None,
admin,
delegated_by: None,
created_at: vti_common::auth::session::now_epoch(),
created_by: created_by.into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
}
}
pub fn can(&self, cap: Capability, resource: Option<&ResourceQualifier>) -> bool {
self.suspension.is_none()
&& !self.is_expired(vti_common::auth::session::now_epoch())
&& (self.admin.can(cap, resource) || self.resource_grant_confers(cap, resource))
}
fn resource_grant_confers(
&self,
cap: Capability,
resource: Option<&ResourceQualifier>,
) -> bool {
let now = chrono::Utc::now();
self.resource_grants
.iter()
.any(|g| g.is_live(now) && g.confers(cap, resource))
}
pub fn is_application(&self) -> bool {
self.role == VtcRole::Application
}
pub fn can_any(&self, cap: Capability) -> bool {
let now = chrono::Utc::now();
self.suspension.is_none()
&& !self.is_expired(vti_common::auth::session::now_epoch())
&& (self.admin.can_any(cap)
|| self
.resource_grants
.iter()
.any(|g| g.is_live(now) && g.confers(cap, Some(&g.resource))))
}
pub fn holds(&self, wanted: &CapRef) -> bool {
self.can(wanted.capability, wanted.resource.as_ref())
}
pub fn can_approve(&self, wanted: &CapRef) -> bool {
self.suspension.is_none()
&& !self.is_expired(vti_common::auth::session::now_epoch())
&& self.admin.can_approve(wanted)
}
pub fn is_suspended(&self) -> bool {
self.suspension.is_some()
}
pub fn is_administrator(&self) -> bool {
!self.is_expired(vti_common::auth::session::now_epoch()) && self.admin.is_administrator()
}
pub fn is_community_admin(&self) -> bool {
matches!(
self.admin.admin_role,
Some(super::capability::AdminRole::CommunityAdmin)
) && self.can(Capability::RolesAssign, None)
}
pub fn capability_list(&self) -> Vec<String> {
let mut out: Vec<String> = if self.admin.act.is_all() {
self.admin.effective().iter().map(CapRef::display).collect()
} else {
Vec::new()
};
let now = chrono::Utc::now();
out.extend(
self.resource_grants
.iter()
.filter(|g| g.is_live(now))
.map(|g| match g.grade {
Some(super::resource_grant::RepoGrade::Own) | None => g.cap_ref().display(),
Some(_) => g.display(),
}),
);
out
}
pub fn is_expired(&self, now_unix: u64) -> bool {
match self.expires_at {
Some(deadline) => now_unix >= deadline,
None => false,
}
}
}
pub(crate) fn decode(bytes: &[u8]) -> Result<VtcAclEntry, AppError> {
serde_json::from_slice(bytes)
.map_err(|e| AppError::Internal(format!("VtcAclEntry decode: {e}")))
}
pub(crate) async fn iter(ks: &KeyspaceHandle) -> Result<Vec<VtcAclEntry>, AppError> {
let raw = ks.prefix_iter_raw(b"acl:".to_vec()).await?;
let mut out = Vec::with_capacity(raw.len());
for (_k, v) in raw {
match decode(&v) {
Ok(entry) => out.push(entry),
Err(err) => {
tracing::warn!(error = %err, "skipping unparseable acl entry");
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::capability::{AdminRole, VtcActScope};
use serde_json::json;
#[test]
fn is_expired_returns_false_for_permanent_entries() {
let entry = sample_entry(None);
assert!(!entry.is_expired(u64::MAX));
}
#[test]
fn is_expired_returns_true_after_deadline() {
let entry = sample_entry(Some(100));
assert!(!entry.is_expired(50));
assert!(entry.is_expired(101));
}
#[test]
fn round_trip_through_json() {
let entry = sample_entry(Some(200));
let bytes = serde_json::to_vec(&entry).unwrap();
let parsed = decode(&bytes).unwrap();
assert_eq!(parsed, entry);
}
#[test]
fn a_pre_role_row_does_not_decode() {
let legacy = json!({
"did": "did:key:zAdmin",
"role": "admin",
"label": null,
"allowed_contexts": [],
"created_at": 0,
"created_by": "did:key:vtc-install"
});
assert!(decode(&serde_json::to_vec(&legacy).unwrap()).is_err());
}
#[test]
fn custom_role_round_trips() {
let mut entry = sample_entry(None);
entry.role = VtcRole::custom("editor").unwrap();
entry.label = Some("badge holder".into());
let bytes = serde_json::to_vec(&entry).unwrap();
let parsed = decode(&bytes).unwrap();
assert_eq!(parsed.role, VtcRole::Custom("editor".into()));
assert_eq!(parsed, entry);
}
#[test]
fn the_community_role_is_not_authority() {
let mut e = sample_entry(None);
e.role = VtcRole::Admin;
for c in Capability::ALL {
assert!(!e.can(c, None));
}
assert!(!e.is_administrator());
}
#[test]
fn an_expired_entry_can_do_nothing() {
let mut e = sample_entry(Some(1));
e.admin = AdminAuthority::community_admin();
assert!(!e.can(Capability::RolesAssign, None));
assert!(!e.is_administrator());
e.expires_at = None;
assert!(e.can(Capability::RolesAssign, None));
assert!(e.is_community_admin());
}
#[test]
fn a_moderator_is_an_administrator_but_not_a_community_admin() {
let mut e = sample_entry(None);
e.admin = AdminAuthority::for_role(AdminRole::Moderator);
assert!(e.is_administrator());
assert!(!e.is_community_admin());
assert_eq!(e.admin.act, VtcActScope::All);
}
#[test]
fn a_suspended_entry_authorizes_nothing() {
let mut e = sample_entry(None);
e.admin = AdminAuthority::community_admin();
assert!(e.can(Capability::RolesAssign, None));
e.suspension = Some(Suspension {
action_id: "act-1".into(),
lands_at: 1,
requester: "did:key:zOther".into(),
});
for c in Capability::ALL {
assert!(!e.can(c, None), "{c:?}");
assert!(!e.can_any(c), "{c:?}");
assert!(!e.can_approve(&CapRef::all(c)), "{c:?}");
}
assert!(e.is_administrator());
assert_eq!(e.admin, AdminAuthority::community_admin());
let bytes = serde_json::to_vec(&e).unwrap();
assert!(decode(&bytes).unwrap().suspension.is_none());
}
fn sample_entry(expires_at: Option<u64>) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zSomeMember".into(),
role: VtcRole::Member,
label: None,
admin: AdminAuthority::none(),
delegated_by: None,
created_at: 42,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
}
}
}