use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use trust_tasks_rs::specs::git_ns::bridge::event::v0_3 as event_wire;
use super::model::{Namespace, OwnerKind, Resource, Right};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForgeLevel {
None,
Read,
Triage,
Write,
Maintain,
Admin,
}
impl ForgeLevel {
pub fn as_str(self) -> &'static str {
match self {
ForgeLevel::None => "none",
ForgeLevel::Read => "read",
ForgeLevel::Triage => "triage",
ForgeLevel::Write => "write",
ForgeLevel::Maintain => "maintain",
ForgeLevel::Admin => "admin",
}
}
pub fn parse(role: &str) -> Option<ForgeLevel> {
Some(match role {
"none" => ForgeLevel::None,
"read" => ForgeLevel::Read,
"triage" => ForgeLevel::Triage,
"write" => ForgeLevel::Write,
"maintain" => ForgeLevel::Maintain,
"admin" => ForgeLevel::Admin,
_ => return None,
})
}
fn from_wire(r: event_wire::MappedRole) -> Result<ForgeLevel, String> {
ForgeLevel::parse(&r.to_string()).ok_or_else(|| format!("unknown forge role `{r}`"))
}
}
use ForgeLevel as L;
pub fn known_ladder(forge: &str, kind: Option<OwnerKind>) -> Option<&'static [ForgeLevel]> {
const GITHUB_ORG: [ForgeLevel; 5] = [L::Read, L::Triage, L::Write, L::Maintain, L::Admin];
const GITHUB_USER: [ForgeLevel; 1] = [L::Write];
const FORGEJO: [ForgeLevel; 4] = [L::Read, L::Write, L::Maintain, L::Admin];
match (forge, kind) {
("github.com", Some(OwnerKind::Organization)) => Some(&GITHUB_ORG),
("github.com", Some(OwnerKind::User)) => Some(&GITHUB_USER),
("codeberg.org", Some(_)) => Some(&FORGEJO),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RawRoleMap")]
pub struct RoleMap {
pub own: ForgeLevel,
pub maintain: ForgeLevel,
pub commit: ForgeLevel,
}
#[derive(Deserialize)]
struct RawRoleMap {
own: ForgeLevel,
maintain: ForgeLevel,
commit: ForgeLevel,
}
impl TryFrom<RawRoleMap> for RoleMap {
type Error = String;
fn try_from(r: RawRoleMap) -> Result<Self, String> {
RoleMap::new(r.own, r.maintain, r.commit)
}
}
impl RoleMap {
pub fn new(own: ForgeLevel, maintain: ForgeLevel, commit: ForgeLevel) -> Result<Self, String> {
if maintain > own {
return Err(format!(
"maintain (`{}`) is above own (`{}`)",
maintain.as_str(),
own.as_str()
));
}
if commit > maintain {
return Err(format!(
"commit (`{}`) is above maintain (`{}`)",
commit.as_str(),
maintain.as_str()
));
}
if commit > ForgeLevel::Write {
return Err(format!("commit (`{}`) is above write", commit.as_str()));
}
Ok(RoleMap {
own,
maintain,
commit,
})
}
fn from_wire(m: &event_wire::RoleMap) -> Result<RoleMap, String> {
RoleMap::new(
ForgeLevel::from_wire(m.own)?,
ForgeLevel::from_wire(m.maintain)?,
ForgeLevel::from_wire(m.commit)?,
)
}
fn on_ladder(&self, ladder: &[ForgeLevel]) -> Result<(), String> {
for (name, level) in [
("own", self.own),
("maintain", self.maintain),
("commit", self.commit),
] {
if level != ForgeLevel::None && !ladder.contains(&level) {
return Err(format!(
"{name} (`{}`) is not a level the forge offers here",
level.as_str()
));
}
}
Ok(())
}
pub fn role_for(&self, right: Right) -> ForgeLevel {
match right {
Right::RepoOwn => self.own,
Right::RepoMaintain => self.maintain,
Right::CommitSign => self.commit,
Right::NsAdmin | Right::RepoCreate => ForgeLevel::None,
}
}
pub fn projected_right(&self, role: &str) -> Option<Right> {
let level = ForgeLevel::parse(role)?;
if level == ForgeLevel::None {
return None;
}
[Right::CommitSign, Right::RepoMaintain, Right::RepoOwn]
.into_iter()
.find(|r| self.role_for(*r) == level)
}
pub fn revert_takes_ownership(&self, role: &str) -> bool {
self.own != ForgeLevel::None && ForgeLevel::parse(role).is_some_and(|l| l >= self.own)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoRoleMap {
pub resource: String,
pub role_map: RoleMap,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoleMapReport {
pub role_map: RoleMap,
pub ladder: Vec<ForgeLevel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repos: Vec<RepoRoleMap>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stale: Vec<String>,
pub bridge_did: String,
pub issued_at: DateTime<Utc>,
pub received_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Reported,
Unknown,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Reported => "reported",
Source::Unknown => "unknown",
}
}
}
pub fn current_report(ns: &Namespace) -> Option<&RoleMapReport> {
ns.role_map
.as_ref()
.filter(|r| ns.bridge_did.as_deref() == Some(r.bridge_did.as_str()))
}
pub fn source(ns: &Namespace) -> Source {
if current_report(ns).is_some() {
Source::Reported
} else {
Source::Unknown
}
}
pub fn for_repo(ns: &Namespace, repo: &str) -> Option<RoleMap> {
current_report(ns).map(|r| {
r.repos
.iter()
.find(|e| e.resource == repo)
.map(|e| e.role_map)
.unwrap_or(r.role_map)
})
}
pub fn for_namespace(ns: &Namespace) -> Option<RoleMap> {
current_report(ns).map(|r| r.role_map)
}
pub fn is_stale(ns: &Namespace, repo: &str) -> bool {
current_report(ns).is_some_and(|r| r.stale.iter().any(|s| s == repo))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unknown;
pub fn projected_right(ns: &Namespace, repo: &str, role: &str) -> Result<Option<Right>, Unknown> {
for_repo(ns, repo)
.map(|m| m.projected_right(role))
.ok_or(Unknown)
}
pub fn revert_takes_ownership(ns: &Namespace, repo: &str, role: &str) -> bool {
match for_repo(ns, repo) {
Some(m) => m.revert_takes_ownership(role),
None => ForgeLevel::parse(role).is_some_and(|l| l > ForgeLevel::None),
}
}
impl Right {
pub fn is_elevated_in(self, ns: &Namespace, resource: &str) -> bool {
if self.is_elevated() {
return true;
}
let Some(report) = current_report(ns) else {
return matches!(self, Right::RepoMaintain);
};
let admin = |m: &RoleMap| m.role_for(self) == ForgeLevel::Admin;
if resource == ns.resource().to_string() {
admin(&report.role_map) || report.repos.iter().any(|e| admin(&e.role_map))
} else {
for_repo(ns, resource).is_some_and(|m| admin(&m))
}
}
}
pub fn read_report<E>(
event: &serde_json::Value,
ns: &Namespace,
bridge_did: &str,
issued_at: DateTime<Utc>,
received_at: DateTime<Utc>,
mut inside: impl FnMut(&str) -> Result<Resource, E>,
) -> Result<Result<RoleMapReport, String>, E> {
let parsed: event_wire::ForgeEvent = match serde_json::from_value(event.clone()) {
Ok(p) => p,
Err(e) => return Ok(Err(format!("roleMapReported: {e}"))),
};
let event_wire::ForgeEvent::RoleMapReported {
ladder,
repos,
role_map,
stale,
} = parsed
else {
return Ok(Err("not a roleMapReported event".into()));
};
for e in &repos {
inside(&e.resource)?;
}
for s in stale.iter().flatten() {
inside(s)?;
}
let mut levels: Vec<ForgeLevel> = Vec::with_capacity(ladder.len());
for r in &ladder {
let l = match ForgeLevel::from_wire(*r) {
Ok(l) => l,
Err(e) => return Ok(Err(format!("ladder: {e}"))),
};
if l == ForgeLevel::None {
return Ok(Err("ladder lists `none`, which every forge has".into()));
}
if levels.last().is_some_and(|prev| *prev >= l) {
return Ok(Err("ladder is not strictly ascending".into()));
}
levels.push(l);
}
if levels.is_empty() {
return Ok(Err("ladder lists no level".into()));
}
if let Some(known) = known_ladder(&ns.forge, ns.kind)
&& known != levels.as_slice()
{
return Ok(Err(format!(
"ladder [{}] is not the forge's ladder for this namespace, [{}]",
levels
.iter()
.map(|l| l.as_str())
.collect::<Vec<_>>()
.join(", "),
known
.iter()
.map(|l| l.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
let role_map =
match RoleMap::from_wire(&role_map).and_then(|m| m.on_ladder(&levels).map(|()| m)) {
Ok(m) => m,
Err(e) => return Ok(Err(format!("roleMap: {e}"))),
};
let mut out_repos: Vec<RepoRoleMap> = Vec::with_capacity(repos.len());
for e in &repos {
let resource = e.resource.to_string();
if out_repos.iter().any(|r| r.resource == resource) {
return Ok(Err(format!("repos lists {resource} twice")));
}
match RoleMap::from_wire(&e.role_map).and_then(|m| m.on_ladder(&levels).map(|()| m)) {
Ok(m) => out_repos.push(RepoRoleMap {
resource,
role_map: m,
}),
Err(err) => return Ok(Err(format!("repos[{resource}].roleMap: {err}"))),
}
}
let mut out_stale: Vec<String> = Vec::new();
for s in stale.iter().flatten() {
let s = s.to_string();
if !out_stale.contains(&s) {
out_stale.push(s);
}
}
Ok(Ok(RoleMapReport {
role_map,
ladder: levels,
repos: out_repos,
stale: out_stale,
bridge_did: bridge_did.to_string(),
issued_at,
received_at,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use ForgeLevel::*;
fn map(own: ForgeLevel, maintain: ForgeLevel, commit: ForgeLevel) -> RoleMap {
RoleMap::new(own, maintain, commit).unwrap()
}
#[test]
fn a_map_is_ordered_and_a_committer_gets_at_most_write() {
assert!(RoleMap::new(Maintain, Admin, None).is_err());
assert!(RoleMap::new(Admin, Write, Maintain).is_err());
assert!(RoleMap::new(Admin, Admin, Admin).is_err());
assert!(RoleMap::new(Admin, Admin, Write).is_ok());
assert!(
serde_json::from_value::<RoleMap>(
serde_json::json!({ "own": "write", "maintain": "admin", "commit": "none" })
)
.is_err()
);
}
#[test]
fn the_default_map_as_reported_is_the_old_inverse() {
let org = map(Admin, Maintain, None);
assert_eq!(org.projected_right("admin"), Some(Right::RepoOwn));
assert_eq!(org.projected_right("maintain"), Some(Right::RepoMaintain));
assert_eq!(org.projected_right("write"), Option::None);
assert_eq!(org.projected_right("triage"), Option::None);
let user = map(Write, Write, None);
assert_eq!(user.projected_right("write"), Some(Right::RepoMaintain));
assert_eq!(user.projected_right("admin"), Option::None);
}
#[test]
fn the_projected_right_is_the_lowest_right_with_that_role() {
let m = map(Admin, Admin, None);
assert_eq!(m.projected_right("admin"), Some(Right::RepoMaintain));
let m = map(Admin, Maintain, Write);
assert_eq!(m.projected_right("write"), Some(Right::CommitSign));
let m = map(Maintain, Write, None);
assert_eq!(m.projected_right("admin"), Option::None);
assert_eq!(m.projected_right("maintain"), Some(Right::RepoOwn));
assert_eq!(m.projected_right("none"), Option::None);
assert_eq!(m.projected_right("owner"), Option::None);
}
#[test]
fn a_namespace_admin_projects_to_no_role_under_any_map() {
for m in [
map(Admin, Maintain, None),
map(Admin, Admin, Write),
map(Write, Write, None),
] {
assert_eq!(m.role_for(Right::NsAdmin), None);
assert_eq!(m.role_for(Right::RepoCreate), None);
}
}
#[test]
fn reverting_a_role_at_or_above_owns_weighs_as_revoking_own() {
let m = map(Maintain, Write, None);
assert!(m.revert_takes_ownership("maintain"));
assert!(m.revert_takes_ownership("admin"));
assert!(!m.revert_takes_ownership("write"));
assert!(!map(None, None, None).revert_takes_ownership("read"));
}
#[test]
fn a_map_is_on_its_ladder() {
let forgejo = [Read, Write, Maintain, Admin];
assert!(map(Admin, Maintain, Write).on_ladder(&forgejo).is_ok());
assert!(map(Admin, Triage, None).on_ladder(&forgejo).is_err());
let user = [Write];
assert!(map(Write, Write, None).on_ladder(&user).is_ok());
assert!(map(Admin, Write, None).on_ladder(&user).is_err());
}
}
#[cfg(test)]
mod source_tests {
use super::*;
use crate::git_ns::model::{Mode, NamespaceState};
use serde_json::json;
const WIDGETS: &str = "github.com/acme/widgets";
fn ns_kind(bridge: &str, report: Option<(&str, RoleMap)>, kind: OwnerKind) -> Namespace {
Namespace {
id: "ns_1".into(),
forge: "github.com".into(),
owner: "acme".into(),
mode: Mode::Bridge,
state: NamespaceState::Bound,
owner_id: None,
kind: Some(kind),
bridge_did: Some(bridge.into()),
bind_job_id: None,
bound_by: "did:key:z6Mk".into(),
requested_at: Utc::now(),
bound_at: None,
roles_digest: None,
installation_removed: false,
forge_status: None,
role_map: report.map(|(b, m)| RoleMapReport {
role_map: m,
ladder: vec![
ForgeLevel::Read,
ForgeLevel::Triage,
ForgeLevel::Write,
ForgeLevel::Maintain,
ForgeLevel::Admin,
],
repos: vec![],
stale: vec![WIDGETS.into()],
bridge_did: b.into(),
issued_at: Utc::now(),
received_at: Utc::now(),
}),
}
}
fn ns(bridge: &str, report_from: Option<&str>) -> Namespace {
let admin_map =
RoleMap::new(ForgeLevel::Admin, ForgeLevel::Admin, ForgeLevel::None).unwrap();
ns_kind(
bridge,
report_from.map(|b| (b, admin_map)),
OwnerKind::Organization,
)
}
#[test]
fn another_bridges_report_is_unknown_and_derives_nothing() {
let reseated = ns("did:key:new", Some("did:key:old"));
assert_eq!(source(&reseated), Source::Unknown);
assert_eq!(for_namespace(&reseated), Option::None);
assert!(!is_stale(&reseated, WIDGETS));
assert_eq!(projected_right(&reseated, WIDGETS, "admin"), Err(Unknown));
let fresh = ns("did:key:new", None);
assert_eq!(source(&fresh), Source::Unknown);
assert_eq!(projected_right(&fresh, WIDGETS, "admin"), Err(Unknown));
let held = ns("did:key:old", Some("did:key:old"));
assert_eq!(source(&held), Source::Reported);
assert_eq!(
projected_right(&held, WIDGETS, "admin"),
Ok(Some(Right::RepoMaintain))
);
}
#[test]
fn an_unknown_map_weighs_every_revert_as_revoking_own() {
let fresh = ns("did:key:new", None);
for role in ["read", "triage", "write", "maintain", "admin"] {
assert!(revert_takes_ownership(&fresh, WIDGETS, role), "{role}");
}
assert!(!revert_takes_ownership(&fresh, WIDGETS, "none"));
let held = ns("did:key:old", Some("did:key:old"));
assert!(revert_takes_ownership(&held, WIDGETS, "admin"));
assert!(!revert_takes_ownership(&held, WIDGETS, "maintain"));
}
#[test]
fn a_right_the_map_projects_to_admin_is_elevated() {
let b = "did:key:b";
let default =
RoleMap::new(ForgeLevel::Admin, ForgeLevel::Maintain, ForgeLevel::None).unwrap();
let maintain_admin =
RoleMap::new(ForgeLevel::Admin, ForgeLevel::Admin, ForgeLevel::None).unwrap();
let n = ns_kind(b, Some((b, maintain_admin)), OwnerKind::Organization);
assert!(Right::RepoMaintain.is_elevated_in(&n, WIDGETS));
assert!(Right::RepoMaintain.is_elevated_in(&n, "github.com/acme"));
assert!(!Right::CommitSign.is_elevated_in(&n, WIDGETS));
let n = ns_kind(b, Some((b, default)), OwnerKind::Organization);
assert!(!Right::RepoMaintain.is_elevated_in(&n, WIDGETS));
assert!(Right::RepoOwn.is_elevated_in(&n, WIDGETS));
let mut n = n;
n.role_map.as_mut().unwrap().repos.push(RepoRoleMap {
resource: WIDGETS.into(),
role_map: maintain_admin,
});
assert!(Right::RepoMaintain.is_elevated_in(&n, WIDGETS));
assert!(Right::RepoMaintain.is_elevated_in(&n, "github.com/acme"));
assert!(!Right::RepoMaintain.is_elevated_in(&n, "github.com/acme/gadgets"));
let n = ns_kind(b, Option::None, OwnerKind::Organization);
assert!(Right::RepoMaintain.is_elevated_in(&n, WIDGETS));
assert!(!Right::CommitSign.is_elevated_in(&n, WIDGETS));
assert!(Right::NsAdmin.is_elevated_in(&n, "github.com/acme"));
}
fn read(ns: &Namespace, event: serde_json::Value) -> Result<RoleMapReport, String> {
read_report(&event, ns, "did:key:b", Utc::now(), Utc::now(), |r| {
Resource::parse(r).map_err(|_| ())
})
.unwrap()
}
#[test]
fn a_report_is_held_to_the_forges_ladder() {
let org = ns("did:key:b", Option::None);
let user = ns_kind("did:key:b", Option::None, OwnerKind::User);
let org_ladder = json!(["read", "triage", "write", "maintain", "admin"]);
let ok = |n: &Namespace, m: serde_json::Value, l: serde_json::Value| {
read(
n,
json!({ "type": "roleMapReported", "roleMap": m, "ladder": l }),
)
};
assert!(
ok(
&org,
json!({ "own": "admin", "maintain": "maintain", "commit": "none" }),
org_ladder.clone()
)
.is_ok()
);
assert!(
ok(
&user,
json!({ "own": "admin", "maintain": "write", "commit": "none" }),
json!(["write"])
)
.is_err()
);
assert!(
ok(
&user,
json!({ "own": "write", "maintain": "write", "commit": "none" }),
json!(["write"])
)
.is_ok()
);
assert!(
ok(
&user,
json!({ "own": "admin", "maintain": "write", "commit": "none" }),
org_ladder.clone()
)
.is_err()
);
let mut forgejo = ns("did:key:b", Option::None);
forgejo.forge = "git.example.org".into();
forgejo.owner = "acme".into();
let fj = json!(["read", "write", "maintain", "admin"]);
assert!(
ok(
&forgejo,
json!({ "own": "admin", "maintain": "triage", "commit": "none" }),
fj.clone()
)
.is_err()
);
assert!(
ok(
&forgejo,
json!({ "own": "admin", "maintain": "maintain", "commit": "write" }),
fj
)
.is_ok()
);
assert!(
ok(
&forgejo,
json!({ "own": "admin", "maintain": "maintain", "commit": "none" }),
json!(["none", "admin"])
)
.is_err()
);
assert!(
ok(
&forgejo,
json!({ "own": "admin", "maintain": "maintain", "commit": "none" }),
json!(["admin", "maintain"])
)
.is_err()
);
let mut cb = ns("did:key:b", Option::None);
cb.forge = "codeberg.org".into();
assert!(
ok(
&cb,
json!({ "own": "admin", "maintain": "maintain", "commit": "none" }),
org_ladder
)
.is_err()
);
}
}