use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::capability::{CapRef, Capability, ResourceQualifier};
use crate::git_ns::model::{BreakGlassMark, Right, SingleAdminMark};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RepoGrade {
Create,
Own,
Maintain,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GrantReview {
pub granter: String,
pub raised_at: DateTime<Utc>,
pub deadline: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResourceGrant {
pub capability: Capability,
pub resource: ResourceQualifier,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grade: Option<RepoGrade>,
pub delegated_by: String,
pub granted_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default)]
pub subject_was_member: bool,
#[serde(default)]
pub granter_was_member: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub break_glass: Option<BreakGlassMark>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<GrantReview>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub single_admin: Option<SingleAdminMark>,
}
impl ResourceGrant {
pub fn git_right(&self) -> Option<Right> {
use ResourceQualifier as Q;
match (self.capability, &self.resource, self.grade) {
(Capability::GitNsAdmin, Q::GitNs(_), None) => Some(Right::NsAdmin),
(Capability::GitRepoManage, Q::GitNs(_), Some(RepoGrade::Create)) => {
Some(Right::RepoCreate)
}
(Capability::GitRepoManage, Q::GitRepo(_), Some(RepoGrade::Own)) => {
Some(Right::RepoOwn)
}
(Capability::GitRepoManage, Q::GitRepo(_), Some(RepoGrade::Maintain)) => {
Some(Right::RepoMaintain)
}
(Capability::GitCommitSign, Q::GitNs(_) | Q::GitRepo(_), None) => {
Some(Right::CommitSign)
}
_ => None,
}
}
pub fn display(&self) -> String {
match self.grade {
Some(g) => format!(
"{}/{}@{}",
self.capability,
serde_json::to_value(g)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default(),
self.resource
),
None => format!("{}@{}", self.capability, self.resource),
}
}
pub fn is_lapsed(&self, now: DateTime<Utc>) -> bool {
self.expires_at.is_some_and(|e| e <= now)
}
pub fn is_live(&self, now: DateTime<Utc>) -> bool {
!self.is_lapsed(now)
&& !self
.break_glass
.as_ref()
.and_then(|b| b.effective_at)
.is_some_and(|e| e > now)
}
pub fn confers(&self, cap: Capability, wanted: Option<&ResourceQualifier>) -> bool {
let Some(wanted) = wanted else {
return false;
};
let inside = self.resource.covers(wanted);
let exact = self.resource == *wanted;
match (self.capability, self.grade) {
(Capability::GitNsAdmin, _) => {
inside
&& matches!(
cap,
Capability::GitNsAdmin
| Capability::GitRepoManage
| Capability::GitCommitSign
)
}
(Capability::GitRepoManage, Some(RepoGrade::Own) | None) => {
inside && matches!(cap, Capability::GitRepoManage | Capability::GitCommitSign)
}
(Capability::GitRepoManage, Some(RepoGrade::Maintain)) => {
inside && cap == Capability::GitCommitSign
}
(Capability::GitRepoManage, Some(RepoGrade::Create)) => {
exact && cap == Capability::GitRepoManage
}
(Capability::GitCommitSign, _) => inside && cap == Capability::GitCommitSign,
_ => false,
}
}
pub fn cap_ref(&self) -> CapRef {
CapRef::new(self.capability, Some(self.resource.clone()))
}
}
pub fn capability_for(right: Right) -> (Capability, Option<RepoGrade>) {
match right {
Right::NsAdmin => (Capability::GitNsAdmin, None),
Right::RepoCreate => (Capability::GitRepoManage, Some(RepoGrade::Create)),
Right::RepoOwn => (Capability::GitRepoManage, Some(RepoGrade::Own)),
Right::RepoMaintain => (Capability::GitRepoManage, Some(RepoGrade::Maintain)),
Right::CommitSign => (Capability::GitCommitSign, None),
}
}
pub fn namespace_qualifier(forge: &str, owner: &str) -> ResourceQualifier {
ResourceQualifier::GitNs(format!("{forge}/{owner}"))
}
pub fn repo_qualifier(forge: &str, owner: &str, repo_id: &str) -> ResourceQualifier {
ResourceQualifier::GitRepo(format!("{forge}/{owner}/{repo_id}"))
}
pub fn repo_id_of(q: &ResourceQualifier) -> Option<&str> {
match q {
ResourceQualifier::GitRepo(v) => v.rsplit('/').next(),
_ => None,
}
}
pub fn granter_covers(granter: &super::VtcAclEntry, right: Right, at: &ResourceQualifier) -> bool {
let (cap, grade) = capability_for(right);
let now = Utc::now();
let epoch = vti_common::auth::session::now_epoch();
if granter.is_expired(epoch) {
return false;
}
if granter.admin.can(cap, Some(at)) || granter.admin.can(Capability::GitNsAdmin, Some(at)) {
return true;
}
granter.resource_grants.iter().any(|g| {
g.is_live(now)
&& match grade {
Some(RepoGrade::Maintain) => {
g.confers(Capability::GitRepoManage, Some(at))
|| (g.capability == Capability::GitRepoManage
&& g.grade == Some(RepoGrade::Maintain)
&& g.resource.covers(at))
}
Some(RepoGrade::Create) => g.confers(Capability::GitRepoManage, Some(at)),
_ => g.confers(cap, Some(at)),
}
})
}
const HOLDER_PREFIX: &str = "git-holder:";
const DEPARTED_PREFIX: &str = "git-departed:";
pub(crate) fn holder_key(did: &str) -> String {
format!("{HOLDER_PREFIX}{did}")
}
pub(crate) fn departed_key(did: &str) -> String {
format!("{DEPARTED_PREFIX}{did}")
}
pub async fn index_holder(
ks: &vti_common::store::KeyspaceHandle,
did: &str,
holds: bool,
) -> Result<(), vti_common::error::AppError> {
let key = format!("{HOLDER_PREFIX}{did}");
if holds {
ks.insert_raw(key.into_bytes(), b"1".to_vec()).await
} else {
ks.remove(key).await
}
}
pub async fn indexed_holders(
ks: &vti_common::store::KeyspaceHandle,
) -> Result<Vec<String>, vti_common::error::AppError> {
Ok(ks
.prefix_keys(HOLDER_PREFIX.as_bytes().to_vec())
.await?
.into_iter()
.map(|k| String::from_utf8_lossy(&k[HOLDER_PREFIX.len()..]).into_owned())
.collect())
}
pub async fn rebuild_holder_index(
ks: &vti_common::store::KeyspaceHandle,
) -> Result<usize, vti_common::error::AppError> {
let holders: std::collections::BTreeSet<String> = super::entry::iter(ks)
.await?
.into_iter()
.filter(|e| !e.resource_grants.is_empty())
.map(|e| e.did)
.collect();
for stale in indexed_holders(ks).await? {
if !holders.contains(&stale) {
index_holder(ks, &stale, false).await?;
}
}
for did in &holders {
index_holder(ks, did, true).await?;
}
Ok(holders.len())
}
pub async fn keep_departed(
ks: &vti_common::store::KeyspaceHandle,
did: &str,
grants: &[ResourceGrant],
) -> Result<(), vti_common::error::AppError> {
if grants.is_empty() {
return Ok(());
}
let key = format!("{DEPARTED_PREFIX}{did}");
let mut kept: Vec<ResourceGrant> = ks.get(key.clone()).await?.unwrap_or_default();
kept.extend(grants.iter().cloned());
ks.insert(key, &kept).await
}
pub async fn departed(
ks: &vti_common::store::KeyspaceHandle,
) -> Result<Vec<(String, Vec<ResourceGrant>)>, vti_common::error::AppError> {
let mut out = Vec::new();
for (k, v) in ks
.prefix_iter_raw(DEPARTED_PREFIX.as_bytes().to_vec())
.await?
{
let did = String::from_utf8_lossy(&k[DEPARTED_PREFIX.len()..]).into_owned();
match serde_json::from_slice::<Vec<ResourceGrant>>(&v) {
Ok(g) => out.push((did, g)),
Err(e) => tracing::warn!(did, error = %e, "unreadable departed resource grants"),
}
}
Ok(out)
}
pub async fn forget_departed(
ks: &vti_common::store::KeyspaceHandle,
did: &str,
) -> Result<(), vti_common::error::AppError> {
ks.remove(format!("{DEPARTED_PREFIX}{did}")).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AdminAuthority, VtcAclEntry, VtcRole};
fn grant(right: Right, q: &str) -> ResourceGrant {
let (capability, grade) = capability_for(right);
ResourceGrant {
capability,
resource: q.parse().unwrap(),
grade,
delegated_by: "did:key:zG".into(),
granted_at: Utc::now(),
expires_at: None,
reason: None,
subject_was_member: true,
granter_was_member: true,
break_glass: None,
review: None,
single_admin: None,
}
}
fn q(s: &str) -> ResourceQualifier {
s.parse().unwrap()
}
const NS: &str = "git-ns:github.com/acme";
const REPO: &str = "git-repo:github.com/acme/repo_1";
const OTHER: &str = "git-repo:github.com/acme/repo_2";
#[test]
fn every_right_round_trips_its_grant() {
for (right, at) in [
(Right::NsAdmin, NS),
(Right::RepoCreate, NS),
(Right::RepoOwn, REPO),
(Right::RepoMaintain, REPO),
(Right::CommitSign, NS),
(Right::CommitSign, REPO),
] {
let g = grant(right, at);
assert_eq!(g.git_right(), Some(right), "{right}@{at}");
let v = serde_json::to_value(&g).unwrap();
assert_eq!(serde_json::from_value::<ResourceGrant>(v).unwrap(), g);
}
}
#[test]
fn vti_acl_035_ns_admin_covers_its_repositories() {
let g = grant(Right::NsAdmin, NS);
assert!(g.confers(Capability::GitRepoManage, Some(&q(REPO))));
assert!(g.confers(Capability::GitCommitSign, Some(&q(REPO))));
assert!(g.confers(Capability::GitNsAdmin, Some(&q(NS))));
assert!(!g.confers(
Capability::GitRepoManage,
Some(&q("git-repo:github.com/acme-labs/repo_1"))
));
assert!(!g.confers(Capability::GitNsAdmin, None));
}
#[test]
fn creation_confers_nothing_inside_the_namespace() {
let g = grant(Right::RepoCreate, NS);
assert!(g.confers(Capability::GitRepoManage, Some(&q(NS))));
assert!(!g.confers(Capability::GitRepoManage, Some(&q(REPO))));
assert!(!g.confers(Capability::GitCommitSign, Some(&q(REPO))));
}
#[test]
fn a_repository_grant_is_confined_to_its_repository() {
let own = grant(Right::RepoOwn, REPO);
assert!(own.confers(Capability::GitRepoManage, Some(&q(REPO))));
assert!(!own.confers(Capability::GitRepoManage, Some(&q(OTHER))));
assert!(!own.confers(Capability::GitRepoManage, Some(&q(NS))));
let maintain = grant(Right::RepoMaintain, REPO);
assert!(!maintain.confers(Capability::GitRepoManage, Some(&q(REPO))));
assert!(maintain.confers(Capability::GitCommitSign, Some(&q(REPO))));
}
fn entry_with(grants: Vec<ResourceGrant>) -> VtcAclEntry {
let mut e = VtcAclEntry::new(
"did:key:zG",
VtcRole::Member,
AdminAuthority::none(),
"did:key:zI",
);
e.resource_grants = grants;
e
}
#[test]
fn vti_acl_037_a_grant_is_bounded_by_the_granters_holding() {
let owner = entry_with(vec![grant(Right::RepoOwn, REPO)]);
assert!(granter_covers(&owner, Right::RepoMaintain, &q(REPO)));
assert!(granter_covers(&owner, Right::CommitSign, &q(REPO)));
assert!(!granter_covers(&owner, Right::RepoOwn, &q(OTHER)));
assert!(!granter_covers(&owner, Right::CommitSign, &q(NS)));
assert!(!granter_covers(&owner, Right::NsAdmin, &q(NS)));
let admin = entry_with(vec![grant(Right::NsAdmin, NS)]);
assert!(granter_covers(&admin, Right::RepoOwn, &q(OTHER)));
assert!(granter_covers(&admin, Right::RepoCreate, &q(NS)));
assert!(!granter_covers(
&admin,
Right::NsAdmin,
&q("git-ns:github.com/other")
));
let mut ca = entry_with(vec![]);
ca.admin = AdminAuthority::community_admin();
assert!(granter_covers(&ca, Right::NsAdmin, &q(NS)));
let mut expired = entry_with(vec![grant(Right::NsAdmin, NS)]);
expired.expires_at = Some(1);
assert!(!granter_covers(&expired, Right::CommitSign, &q(NS)));
}
}