use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Right {
#[serde(rename = "git.ns.admin")]
NsAdmin,
#[serde(rename = "git.repo.create")]
RepoCreate,
#[serde(rename = "git.repo.own")]
RepoOwn,
#[serde(rename = "git.repo.maintain")]
RepoMaintain,
#[serde(rename = "git.commit.sign")]
CommitSign,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Namespace,
Repository,
Either,
}
impl Right {
pub const ALL: [Right; 5] = [
Right::NsAdmin,
Right::RepoCreate,
Right::RepoOwn,
Right::RepoMaintain,
Right::CommitSign,
];
pub fn as_str(self) -> &'static str {
match self {
Right::NsAdmin => "git.ns.admin",
Right::RepoCreate => "git.repo.create",
Right::RepoOwn => "git.repo.own",
Right::RepoMaintain => "git.repo.maintain",
Right::CommitSign => "git.commit.sign",
}
}
pub fn parse(s: &str) -> Option<Right> {
Right::ALL.into_iter().find(|r| r.as_str() == s)
}
pub fn level(self) -> Level {
match self {
Right::NsAdmin | Right::RepoCreate => Level::Namespace,
Right::RepoOwn | Right::RepoMaintain => Level::Repository,
Right::CommitSign => Level::Either,
}
}
pub fn rank(self) -> u8 {
match self {
Right::NsAdmin => 5,
Right::RepoCreate => 4,
Right::RepoOwn => 3,
Right::RepoMaintain => 2,
Right::CommitSign => 1,
}
}
pub fn is_namespace_right(self) -> bool {
matches!(self, Right::NsAdmin | Right::RepoCreate)
}
pub fn is_elevated(self) -> bool {
matches!(self, Right::NsAdmin | Right::RepoCreate | Right::RepoOwn)
}
}
impl std::fmt::Display for Right {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Resource {
pub forge: String,
pub owner: String,
pub repo: Option<String>,
}
impl Resource {
pub fn parse(raw: &str) -> Result<Resource, String> {
if raw != raw.to_lowercase() {
return Err(format!(
"`{raw}` is not lowercase; resources are lowercase on the wire"
));
}
let parts: Vec<&str> = raw.split('/').collect();
let (forge, owner, repo) = match parts.as_slice() {
[f, o] => (*f, *o, None),
[f, o, r] => (*f, *o, Some(*r)),
_ => {
return Err(format!(
"`{raw}` is not `<forge-host>/<owner>` or `<forge-host>/<owner>/<repo>`"
));
}
};
if !is_forge_host(forge) {
return Err(format!(
"`{forge}` is not a forge host; a resource always names its forge \
(`github.com/acme`, never `acme`)"
));
}
if !is_segment(owner) {
return Err(format!("`{owner}` is not a valid owner name"));
}
if let Some(r) = repo
&& !is_segment(r)
{
return Err(format!("`{r}` is not a valid repository name"));
}
Ok(Resource {
forge: forge.to_string(),
owner: owner.to_string(),
repo: repo.map(str::to_string),
})
}
pub fn namespace(forge: &str, owner: &str) -> Resource {
Resource {
forge: forge.to_string(),
owner: owner.to_string(),
repo: None,
}
}
pub fn is_namespace(&self) -> bool {
self.repo.is_none()
}
pub fn namespace_resource(&self) -> Resource {
Resource::namespace(&self.forge, &self.owner)
}
pub fn child(&self, repo: &str) -> Resource {
Resource {
forge: self.forge.clone(),
owner: self.owner.clone(),
repo: Some(repo.to_string()),
}
}
pub fn contains(&self, other: &Resource) -> bool {
vta_sdk::context_path::is_ancestor_or_self(&self.to_string(), &other.to_string())
}
}
impl std::fmt::Display for Resource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.repo {
Some(r) => write!(f, "{}/{}/{}", self.forge, self.owner, r),
None => write!(f, "{}/{}", self.forge, self.owner),
}
}
}
pub fn is_forge_host(s: &str) -> bool {
if s.len() < 3 || s.len() > 253 {
return false;
}
let labels: Vec<&str> = s.split('.').collect();
labels.len() >= 2
&& labels.iter().all(|l| {
!l.is_empty()
&& l.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !l.starts_with('-')
&& !l.ends_with('-')
})
}
pub fn is_segment(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
s.len() <= 100
&& (first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_' || first == '-')
&& chars.all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-'
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Mode {
Bridge,
Manual,
}
impl Mode {
pub fn as_str(self) -> &'static str {
match self {
Mode::Bridge => "bridge",
Mode::Manual => "manual",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OwnerKind {
Organization,
User,
}
impl OwnerKind {
pub fn as_str(self) -> &'static str {
match self {
OwnerKind::Organization => "organization",
OwnerKind::User => "user",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NamespaceState {
Pending,
Bound,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Namespace {
pub id: String,
pub forge: String,
pub owner: String,
pub mode: Mode,
pub state: NamespaceState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<OwnerKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_job_id: Option<String>,
pub bound_by: String,
pub requested_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roles_digest: Option<String>,
#[serde(default)]
pub installation_removed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forge_status: Option<NamespaceForgeStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_map: Option<super::role_map::RoleMapReport>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamespaceForgeStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_registration: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_upgrade_pending: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_rulesets: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_workflow: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_posted_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reported_at: Option<DateTime<Utc>>,
}
pub const FORGE_REPORT_EXT: &str = "org.openvtc.git-ns";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoForgeReport {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guard: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_check: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<Value>,
}
impl Namespace {
pub fn resource(&self) -> Resource {
Resource::namespace(&self.forge, &self.owner)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Visibility {
Public,
Private,
}
impl Visibility {
pub fn as_str(self) -> &'static str {
match self {
Visibility::Public => "public",
Visibility::Private => "private",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RepoState {
PendingCreate,
Active,
Archived,
Detached,
Orphaned,
Unmanaged,
}
impl RepoState {
pub fn as_str(self) -> &'static str {
match self {
RepoState::PendingCreate => "pendingCreate",
RepoState::Active => "active",
RepoState::Archived => "archived",
RepoState::Detached => "detached",
RepoState::Orphaned => "orphaned",
RepoState::Unmanaged => "unmanaged",
}
}
pub fn publishes(self) -> bool {
matches!(
self,
RepoState::Active | RepoState::Orphaned | RepoState::Archived
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bootstrap {
pub workflow: bool,
pub keyring: bool,
pub variables: bool,
pub required_check: bool,
}
impl Bootstrap {
pub fn missing_steps(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if !self.workflow {
out.push("workflow");
}
if !self.keyring {
out.push("keyring");
}
if !self.variables {
out.push("variables");
}
if !self.required_check {
out.push("requiredCheck");
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SyncState {
InSync,
Drift,
Pending,
Unchecked,
}
impl SyncState {
pub fn as_str(self) -> &'static str {
match self {
SyncState::InSync => "inSync",
SyncState::Drift => "drift",
SyncState::Pending => "pending",
SyncState::Unchecked => "unchecked",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStatus {
pub state: SyncState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_at: Option<DateTime<Utc>>,
#[serde(default)]
pub drift: Vec<Value>,
}
impl SyncStatus {
pub fn new(state: SyncState) -> Self {
Self {
state,
checked_at: None,
drift: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Repo {
pub id: String,
pub namespace_id: String,
pub resource: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forge_id: Option<String>,
pub visibility: Visibility,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub state: RepoState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
pub created_at: DateTime<Utc>,
pub bootstrap: Bootstrap,
pub sync: SyncStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failed_step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roles_digest: Option<String>,
#[serde(default)]
pub forge_report: RepoForgeReport,
}
impl Repo {
pub fn resource(&self) -> Option<Resource> {
Resource::parse(&self.resource).ok()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RightRow {
pub subject: String,
pub right: Right,
pub granted_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<crate::acl::resource_grant::GrantReview>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub single_admin: Option<SingleAdminMark>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SingleAdminMark {
pub at: DateTime<Utc>,
pub task: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BreakGlassMark {
pub by: String,
pub at: DateTime<Utc>,
pub justification: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratified_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratified_at: Option<DateTime<Utc>>,
}
impl RightRow {
pub fn is_lapsed(&self, now: DateTime<Utc>) -> bool {
self.expires_at.is_some_and(|e| e <= now)
}
pub fn is_recorded(&self, now: DateTime<Utc>) -> bool {
!self.is_lapsed(now)
}
pub fn is_pending(&self, now: DateTime<Utc>) -> bool {
self.break_glass
.as_ref()
.and_then(|b| b.effective_at)
.is_some_and(|e| e > now)
}
pub fn is_live(&self, now: DateTime<Utc>) -> bool {
self.is_recorded(now) && !self.is_pending(now)
}
pub fn is_unratified_break_glass(&self) -> bool {
self.break_glass
.as_ref()
.is_some_and(|b| b.ratified_by.is_none())
}
pub fn counts_for_invariants(&self, now: DateTime<Utc>) -> bool {
self.is_live(now) && self.expires_at.is_none() && !self.is_unratified_break_glass()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "id")]
pub enum Scope {
Namespace(String),
Repo(String),
}
impl Scope {
pub fn key(&self) -> String {
match self {
Scope::Namespace(id) => format!("ns:{id}"),
Scope::Repo(id) => format!("repo:{id}"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RightsSet {
#[serde(default)]
pub rows: Vec<RightRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ForgeAccount {
pub forge: String,
pub id: String,
pub login: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LinkState {
Pending,
Linked,
Expired,
Failed,
}
impl LinkState {
pub fn as_str(self) -> &'static str {
match self {
LinkState::Pending => "pending",
LinkState::Linked => "linked",
LinkState::Expired => "expired",
LinkState::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkAttempt {
pub id: String,
pub member: String,
pub forge: String,
pub namespace_id: String,
pub job_id: String,
pub state: LinkState,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<ForgeAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
}
pub fn validate_did_core(label: &str, value: &str) -> Result<(), String> {
vta_sdk::identifier::validate_did_core(label, value).map_err(|e| e.0)
}
pub fn new_id(prefix: &str) -> String {
let u = uuid::Uuid::new_v4().simple().to_string();
format!("{prefix}_{}", &u[..20])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_resource_names_its_forge() {
assert!(Resource::parse("acme/widgets").is_err());
assert!(Resource::parse("acme").is_err());
let r = Resource::parse("github.com/acme/widgets").unwrap();
assert_eq!(r.forge, "github.com");
assert_eq!(r.owner, "acme");
assert_eq!(r.repo.as_deref(), Some("widgets"));
assert_eq!(r.to_string(), "github.com/acme/widgets");
}
#[test]
fn a_mixed_case_resource_is_refused_not_normalised() {
assert!(Resource::parse("github.com/Acme/widgets").is_err());
assert!(Resource::parse("GitHub.com/acme").is_err());
}
#[test]
fn a_dot_segment_is_refused() {
assert!(Resource::parse("github.com/acme/..").is_err());
assert!(Resource::parse("github.com/./x").is_err());
assert!(Resource::parse("github.com/acme/x/y").is_err());
}
#[test]
fn containment_is_by_whole_segment_and_never_crosses_forges() {
let ns = Resource::parse("github.com/acme").unwrap();
assert!(ns.contains(&Resource::parse("github.com/acme/widgets").unwrap()));
assert!(ns.contains(&ns));
assert!(!ns.contains(&Resource::parse("github.com/acme-labs/x").unwrap()));
assert!(!ns.contains(&Resource::parse("codeberg.org/acme/widgets").unwrap()));
let repo = Resource::parse("github.com/acme/widgets").unwrap();
assert!(
!repo.contains(&ns),
"a repository does not contain its namespace"
);
assert!(!repo.contains(&Resource::parse("github.com/acme/widgets-core").unwrap()));
}
#[test]
fn rights_carry_their_wire_spelling() {
for r in Right::ALL {
assert_eq!(Right::parse(r.as_str()), Some(r));
assert_eq!(
serde_json::to_value(r).unwrap(),
serde_json::json!(r.as_str())
);
}
}
}