use std::collections::{BTreeMap, BTreeSet};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::names;
mod store;
pub use store::Store;
pub type Id = u64;
pub const RESERVED_SLUGS: &[&str] = &["api", "enterprises", "graphql", "login", "repos", "settings"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Read,
Triage,
Write,
Maintain,
Admin,
}
impl Role {
pub fn from_github(permission: &str) -> Option<Role> {
match permission {
"read" | "pull" => Some(Role::Read),
"triage" => Some(Role::Triage),
"write" | "push" => Some(Role::Write),
"maintain" => Some(Role::Maintain),
"admin" => Some(Role::Admin),
_ => None,
}
}
pub fn from_gitlab(access_level: u32) -> Option<Role> {
match access_level {
0..=19 => None,
20..=29 => Some(Role::Read),
30..=39 => Some(Role::Write),
40..=49 => Some(Role::Maintain),
_ => Some(Role::Admin),
}
}
pub fn from_gitea(access_mode: u8) -> Option<Role> {
match access_mode {
1 => Some(Role::Read),
2 => Some(Role::Write),
3 | 4 => Some(Role::Admin),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrgRole {
BillingManager,
Member,
Owner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnterpriseRole {
BillingManager,
Owner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TeamRole {
Member,
Maintainer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TeamPrivacy {
Visible,
Secret,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
Private,
Internal,
Public,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AccountKind {
User {
managed_by: Option<Id>,
},
Org {
parent: Option<Id>,
enterprise: Option<Id>,
#[serde(default)]
base_role: Option<Role>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: Id,
pub slug: String,
pub visibility: Visibility,
#[serde(flatten)]
pub kind: AccountKind,
}
impl Account {
pub fn parent(&self) -> Option<Id> {
match self.kind {
AccountKind::Org { parent, .. } => parent,
AccountKind::User { .. } => None,
}
}
pub fn is_org(&self) -> bool {
matches!(self.kind, AccountKind::Org { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Enterprise {
pub id: Id,
pub slug: String,
pub shortcode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Team {
pub id: Id,
pub org: Id,
pub parent: Option<Id>,
pub slug: String,
pub privacy: TeamPrivacy,
pub members: BTreeMap<Id, TeamRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repo {
pub id: Id,
pub owner: Id,
pub slug: String,
pub visibility: Visibility,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomRole {
pub id: Id,
pub org: Id,
pub name: String,
pub base: Role,
pub permissions: BTreeSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "lowercase")]
pub enum Principal {
User(Id),
Team(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "lowercase")]
pub enum Resource {
Org(Id),
Repo(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum GrantRole {
Builtin(Role),
Custom(Id),
}
impl From<Role> for GrantRole {
fn from(r: Role) -> Self {
GrantRole::Builtin(r)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Grant {
pub principal: Principal,
pub resource: Resource,
pub role: GrantRole,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Access {
pub role: Option<Role>,
pub permissions: BTreeSet<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Directory {
next_id: Id,
accounts: BTreeMap<Id, Account>,
enterprises: BTreeMap<Id, Enterprise>,
teams: BTreeMap<Id, Team>,
repos: BTreeMap<Id, Repo>,
custom_roles: BTreeMap<Id, CustomRole>,
org_members: BTreeMap<Id, BTreeMap<Id, OrgRole>>,
enterprise_roles: BTreeMap<Id, BTreeMap<Id, EnterpriseRole>>,
blocks: BTreeSet<(Id, Id)>,
grants: Vec<Grant>,
}
impl Directory {
fn alloc(&mut self) -> Id {
self.next_id += 1;
self.next_id
}
pub fn account(&self, id: Id) -> Option<&Account> {
self.accounts.get(&id)
}
pub fn repo(&self, id: Id) -> Option<&Repo> {
self.repos.get(&id)
}
pub fn team(&self, id: Id) -> Option<&Team> {
self.teams.get(&id)
}
pub fn accounts(&self) -> impl Iterator<Item = &Account> {
self.accounts.values()
}
pub fn repos(&self) -> impl Iterator<Item = &Repo> {
self.repos.values()
}
pub fn repos_owned_by(&self, owner: Id) -> impl Iterator<Item = &Repo> {
self.repos.values().filter(move |r| r.owner == owner)
}
pub fn child_orgs(&self, org: Id) -> impl Iterator<Item = &Account> {
self.accounts.values().filter(move |a| a.parent() == Some(org))
}
fn org(&self, id: Id) -> Result<&Account> {
match self.accounts.get(&id) {
Some(a) if a.is_org() => Ok(a),
_ => bail!("no org with id {id}"),
}
}
fn user(&self, id: Id) -> Result<&Account> {
match self.accounts.get(&id) {
Some(a) if !a.is_org() => Ok(a),
_ => bail!("no user with id {id}"),
}
}
fn enterprise(&self, id: Id) -> Result<&Enterprise> {
self.enterprises.get(&id).with_context(|| format!("no enterprise with id {id}"))
}
fn check_slug(&self, parent: Option<Id>, slug: &str) -> Result<()> {
names::validate(slug)?;
if slug.to_ascii_lowercase().ends_with(".git") {
bail!("slug {slug:?} can't end in .git");
}
if parent.is_none() && RESERVED_SLUGS.iter().any(|r| r.eq_ignore_ascii_case(slug)) {
bail!("slug {slug:?} is reserved");
}
let taken = self.accounts.values().any(|a| a.parent() == parent && a.slug.eq_ignore_ascii_case(slug))
|| parent.is_some_and(|p| self.repos.values().any(|r| r.owner == p && r.slug.eq_ignore_ascii_case(slug)));
if taken {
bail!("slug {slug:?} is already taken");
}
Ok(())
}
fn insert_user(&mut self, slug: String, managed_by: Option<Id>) -> Id {
let id = self.alloc();
self.accounts
.insert(id, Account { id, slug, visibility: Visibility::Public, kind: AccountKind::User { managed_by } });
id
}
pub fn add_user(&mut self, slug: &str) -> Result<Id> {
self.check_slug(None, slug)?;
Ok(self.insert_user(slug.to_string(), None))
}
pub fn add_managed_user(&mut self, enterprise: Id, handle: &str) -> Result<Id> {
let shortcode =
self.enterprise(enterprise)?.shortcode.clone().context("only a managed enterprise has managed users")?;
let slug = format!("{handle}_{shortcode}");
self.check_slug(None, &slug)?;
Ok(self.insert_user(slug, Some(enterprise)))
}
pub fn add_org(&mut self, slug: &str, parent: Option<Id>, visibility: Visibility) -> Result<Id> {
if let Some(p) = parent {
self.org(p).context("parent must be an org")?;
}
self.check_slug(parent, slug)?;
if parent.is_some_and(|p| visibility > self.accounts[&p].visibility) {
bail!("org {slug:?} can't be more visible than its parent");
}
let id = self.alloc();
self.accounts.insert(
id,
Account {
id,
slug: slug.to_string(),
visibility,
kind: AccountKind::Org { parent, enterprise: None, base_role: None },
},
);
Ok(id)
}
pub fn add_org_at(&mut self, path: &str, visibility: Visibility) -> Result<Id> {
let (parent, slug) = match path.rsplit_once('/') {
Some((p, slug)) => (Some(self.resolve(p).with_context(|| format!("no org {p:?}"))?), slug),
None => (None, path),
};
self.add_org(slug, parent, visibility)
}
pub fn set_base_role(&mut self, org: Id, role: Option<Role>) -> Result<()> {
self.org(org)?;
if let AccountKind::Org { base_role, .. } = &mut self.accounts.get_mut(&org).unwrap().kind {
*base_role = role;
}
Ok(())
}
pub fn add_enterprise(&mut self, slug: &str, shortcode: Option<&str>) -> Result<Id> {
names::validate(slug)?;
if self.enterprises.values().any(|e| e.slug.eq_ignore_ascii_case(slug)) {
bail!("enterprise {slug:?} already exists");
}
if let Some(sc) = shortcode {
if sc.is_empty() || !sc.chars().all(|c| c.is_ascii_alphanumeric()) {
bail!("invalid shortcode {sc:?}");
}
if self.enterprises.values().any(|e| e.shortcode.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(sc))) {
bail!("shortcode {sc:?} is already taken");
}
}
let id = self.alloc();
self.enterprises
.insert(id, Enterprise { id, slug: slug.to_string(), shortcode: shortcode.map(str::to_string) });
Ok(id)
}
pub fn set_org_enterprise(&mut self, org: Id, enterprise: Option<Id>) -> Result<()> {
if let Some(e) = enterprise {
self.enterprise(e)?;
}
let prev = match self.org(org)?.kind {
AccountKind::Org { parent: None, enterprise, .. } => enterprise,
_ => bail!("only top-level orgs belong to an enterprise directly"),
};
self.set_org_enterprise_unchecked(org, enterprise);
let involved = self.users_involved_in(org);
if let Some(bad) = involved.into_iter().find(|&u| self.check_enterprise_boundary(org, u).is_err()) {
self.set_org_enterprise_unchecked(org, prev);
bail!("user {bad} can't belong to an org in this enterprise");
}
Ok(())
}
fn set_org_enterprise_unchecked(&mut self, org: Id, to: Option<Id>) {
if let AccountKind::Org { enterprise, .. } = &mut self.accounts.get_mut(&org).unwrap().kind {
*enterprise = to;
}
}
pub fn add_team(&mut self, org: Id, slug: &str, parent: Option<Id>, privacy: TeamPrivacy) -> Result<Id> {
self.org(org)?;
names::validate(slug)?;
if self.teams.values().any(|t| t.org == org && t.slug.eq_ignore_ascii_case(slug)) {
bail!("team {slug:?} already exists in this org");
}
if let Some(p) = parent {
let p = self.teams.get(&p).with_context(|| format!("no team with id {p}"))?;
if p.org != org {
bail!("a parent team must be in the same org");
}
if p.privacy == TeamPrivacy::Secret || privacy == TeamPrivacy::Secret {
bail!("secret teams can't be nested");
}
}
let id = self.alloc();
self.teams.insert(id, Team { id, org, parent, slug: slug.to_string(), privacy, members: BTreeMap::new() });
Ok(id)
}
pub fn add_repo(&mut self, owner: Id, slug: &str, visibility: Visibility) -> Result<Id> {
let o = self.accounts.get(&owner).with_context(|| format!("no account with id {owner}"))?;
self.check_slug(Some(owner), slug)?;
if visibility > o.visibility {
bail!("repo {slug:?} can't be more visible than its owner");
}
if matches!(o.kind, AccountKind::User { managed_by: Some(_) }) && visibility == Visibility::Public {
bail!("managed users can't own public repos");
}
let id = self.alloc();
self.repos.insert(id, Repo { id, owner, slug: slug.to_string(), visibility });
Ok(id)
}
pub fn add_repo_at(&mut self, path: &str, visibility: Visibility) -> Result<Id> {
let (owner, slug) = path.rsplit_once('/').with_context(|| format!("repo {path:?} needs an owner"))?;
let owner = self.resolve(owner).with_context(|| format!("no user or org {owner:?}"))?;
self.add_repo(owner, slug, visibility)
}
pub fn add_custom_role(&mut self, org: Id, name: &str, base: Role, permissions: &[&str]) -> Result<Id> {
self.org(org)?;
names::validate(name)?;
let builtin = serde_json::from_value::<Role>(serde_json::Value::String(name.to_ascii_lowercase())).is_ok();
if builtin || self.custom_roles.values().any(|r| r.org == org && r.name.eq_ignore_ascii_case(name)) {
bail!("role {name:?} already exists");
}
let id = self.alloc();
self.custom_roles.insert(
id,
CustomRole {
id,
org,
name: name.to_string(),
base,
permissions: permissions.iter().map(|p| p.to_string()).collect(),
},
);
Ok(id)
}
pub fn set_org_member(&mut self, org: Id, user: Id, role: Option<OrgRole>) -> Result<()> {
self.org(org)?;
self.user(user)?;
if role.is_some() {
self.check_can_touch(org, user)?;
}
let members = self.org_members.entry(org).or_default();
if members.get(&user) == Some(&OrgRole::Owner)
&& role != Some(OrgRole::Owner)
&& members.values().filter(|r| **r == OrgRole::Owner).count() == 1
{
bail!("can't remove an org's last owner");
}
match role {
Some(r) => members.insert(user, r),
None => members.remove(&user),
};
if role.is_none_or(|r| r < OrgRole::Member) {
self.prune_nonmember(org, user);
}
Ok(())
}
fn prune_nonmember(&mut self, org: Id, user: Id) {
let orphaned: BTreeSet<Id> = self.subtree(org).into_iter().filter(|&o| !self.is_org_member(o, user)).collect();
for t in self.teams.values_mut().filter(|t| orphaned.contains(&t.org)) {
t.members.remove(&user);
}
self.drop_user_grants_within(user, &orphaned);
}
pub fn org_role(&self, org: Id, user: Id) -> Option<OrgRole> {
self.ancestors(org).filter_map(|a| self.org_members.get(&a.id)?.get(&user).copied()).max()
}
pub fn is_org_member(&self, org: Id, user: Id) -> bool {
self.org_role(org, user).is_some_and(|r| r >= OrgRole::Member)
}
pub fn set_team_member(&mut self, team: Id, user: Id, role: Option<TeamRole>) -> Result<()> {
self.user(user)?;
let org = self.teams.get(&team).with_context(|| format!("no team with id {team}"))?.org;
if role.is_some() && !self.is_org_member(org, user) {
bail!("team members must be members of the team's org");
}
let members = &mut self.teams.get_mut(&team).unwrap().members;
match role {
Some(r) => members.insert(user, r),
None => members.remove(&user),
};
Ok(())
}
fn teams_of(&self, user: Id) -> BTreeSet<Id> {
let mut out = BTreeSet::new();
for t in self.teams.values().filter(|t| t.members.contains_key(&user)) {
let mut cur = Some(t);
while let Some(t) = cur {
if !out.insert(t.id) {
break;
}
cur = t.parent.and_then(|p| self.teams.get(&p));
}
}
out
}
pub fn set_enterprise_role(&mut self, enterprise: Id, user: Id, role: Option<EnterpriseRole>) -> Result<()> {
let e = self.enterprise(enterprise)?;
self.user(user)?;
if e.shortcode.is_some() && role.is_some() && self.enterprise_of(user) != Some(enterprise) {
bail!("a managed enterprise's roles can only go to its managed users");
}
let roles = self.enterprise_roles.entry(enterprise).or_default();
match role {
Some(r) => roles.insert(user, r),
None => roles.remove(&user),
};
Ok(())
}
pub fn enterprise_role(&self, enterprise: Id, user: Id) -> Option<EnterpriseRole> {
self.enterprise_roles.get(&enterprise)?.get(&user).copied()
}
pub fn is_enterprise_member(&self, enterprise: Id, user: Id) -> bool {
self.enterprise_of(user) == Some(enterprise)
|| self.org_members.iter().any(|(&org, members)| {
self.enterprise_of(org) == Some(enterprise) && members.get(&user).is_some_and(|r| *r >= OrgRole::Member)
})
}
pub fn enterprise_of(&self, account: Id) -> Option<Id> {
let root = self.ancestors(account).last()?;
match root.kind {
AccountKind::User { managed_by } => managed_by,
AccountKind::Org { enterprise, .. } => enterprise,
}
}
pub fn grant(&mut self, principal: Principal, resource: Resource, role: impl Into<GrantRole>) -> Result<()> {
let role = role.into();
let within = match resource {
Resource::Org(o) => self.org(o)?.id,
Resource::Repo(r) => self.repos.get(&r).with_context(|| format!("no repo with id {r}"))?.owner,
};
match principal {
Principal::User(u) => {
self.user(u)?;
self.check_can_touch(within, u)?;
if let Resource::Org(o) = resource {
if !self.is_org_member(o, u) {
bail!("org-wide grants to a user require org membership");
}
}
}
Principal::Team(t) => {
let org = self.teams.get(&t).with_context(|| format!("no team with id {t}"))?.org;
if !self.is_within(within, org) {
bail!("a team can only be granted access within its own org");
}
}
}
if let GrantRole::Custom(c) = role {
let org = self.custom_roles.get(&c).with_context(|| format!("no custom role with id {c}"))?.org;
if !self.is_within(within, org) {
bail!("a custom role can only be used within the org that defines it");
}
}
self.revoke(principal, resource);
self.grants.push(Grant { principal, resource, role });
Ok(())
}
pub fn revoke(&mut self, principal: Principal, resource: Resource) {
self.grants.retain(|g| !(g.principal == principal && g.resource == resource));
}
pub fn block(&mut self, blocker: Id, user: Id) -> Result<()> {
self.accounts.get(&blocker).with_context(|| format!("no account with id {blocker}"))?;
self.user(user)?;
if blocker == user {
bail!("can't block yourself");
}
if self.org_role(blocker, user).is_some() {
bail!("remove them from the org before blocking them");
}
self.blocks.insert((blocker, user));
let subtree = self.subtree(blocker).into_iter().collect();
self.drop_user_grants_within(user, &subtree);
Ok(())
}
pub fn unblock(&mut self, blocker: Id, user: Id) {
self.blocks.remove(&(blocker, user));
}
fn is_blocked_within(&self, account: Id, user: Id) -> bool {
self.ancestors(account).any(|a| self.blocks.contains(&(a.id, user)))
}
fn check_can_touch(&self, account: Id, user: Id) -> Result<()> {
if self.is_blocked_within(account, user) {
bail!("user {user} is blocked here");
}
self.check_enterprise_boundary(account, user)
}
fn check_enterprise_boundary(&self, account: Id, user: Id) -> Result<()> {
let home = self.enterprise_of(user);
let here = self.enterprise_of(account);
if home.is_some() && home != here {
bail!("managed users can't join or collaborate outside their enterprise");
}
if let Some(e) = here {
if self.enterprises[&e].shortcode.is_some() && home != Some(e) {
bail!("a managed enterprise only admits its own managed users");
}
}
Ok(())
}
fn drop_user_grants_within(&mut self, user: Id, accounts: &BTreeSet<Id>) {
let repos = &self.repos;
self.grants.retain(|g| {
let owner = match g.resource {
Resource::Org(o) => o,
Resource::Repo(r) => repos[&r].owner,
};
!(g.principal == Principal::User(user) && accounts.contains(&owner))
});
}
fn users_involved_in(&self, org: Id) -> BTreeSet<Id> {
let subtree: BTreeSet<Id> = self.subtree(org).into_iter().collect();
let mut users = BTreeSet::new();
for o in &subtree {
users.extend(self.org_members.get(o).into_iter().flat_map(|m| m.keys()));
}
for g in &self.grants {
let owner = match g.resource {
Resource::Org(o) => o,
Resource::Repo(r) => self.repos[&r].owner,
};
if let (Principal::User(u), true) = (g.principal, subtree.contains(&owner)) {
users.insert(u);
}
}
users
}
fn ancestors(&self, account: Id) -> impl Iterator<Item = &Account> {
std::iter::successors(self.accounts.get(&account), |a| a.parent().and_then(|p| self.accounts.get(&p)))
}
fn is_within(&self, account: Id, ancestor: Id) -> bool {
self.ancestors(account).any(|a| a.id == ancestor)
}
fn subtree(&self, account: Id) -> Vec<Id> {
self.accounts.keys().copied().filter(|&a| self.is_within(a, account)).collect()
}
pub fn path(&self, account: Id) -> Option<String> {
let mut segs: Vec<&str> = self.ancestors(account).map(|a| a.slug.as_str()).collect();
if segs.is_empty() {
return None;
}
segs.reverse();
Some(segs.join("/"))
}
pub fn repo_path(&self, repo: Id) -> Option<String> {
let r = self.repos.get(&repo)?;
Some(format!("{}/{}", self.path(r.owner)?, r.slug))
}
pub fn resolve(&self, path: &str) -> Option<Id> {
let mut parent = None;
for seg in path.split('/') {
let a = self.accounts.values().find(|a| a.parent() == parent && a.slug.eq_ignore_ascii_case(seg))?;
parent = Some(a.id);
}
parent
}
pub fn resolve_repo(&self, path: &str) -> Option<Id> {
let (owner, slug) = path.rsplit_once('/')?;
let owner = self.resolve(owner)?;
self.repos.values().find(|r| r.owner == owner && r.slug.eq_ignore_ascii_case(slug)).map(|r| r.id)
}
pub fn effective_access(&self, user: Option<Id>, repo: Id) -> Access {
let Some(r) = self.repos.get(&repo) else { return Access::default() };
let enterprise = self.enterprise_of(r.owner);
let floor = match r.visibility {
Visibility::Public => Some(Role::Read),
Visibility::Internal => {
user.filter(|&u| enterprise.is_none_or(|e| self.is_enterprise_member(e, u))).map(|_| Role::Read)
}
Visibility::Private => None,
};
let mut access = Access { role: floor, permissions: BTreeSet::new() };
let Some(user) = user else { return access };
let home = self.enterprise_of(user);
if self.is_blocked_within(r.owner, user) || (home.is_some() && home != enterprise) {
return access;
}
if r.owner == user || self.org_role(r.owner, user) == Some(OrgRole::Owner) {
access.role = Some(Role::Admin);
return access;
}
for a in self.ancestors(r.owner) {
if let AccountKind::Org { base_role: Some(base), .. } = a.kind {
if self.is_org_member(a.id, user) {
access.role = access.role.max(Some(base));
}
}
}
let teams = self.teams_of(user);
for g in &self.grants {
let holds = match g.principal {
Principal::User(id) => id == user,
Principal::Team(id) => teams.contains(&id),
};
let covers = match g.resource {
Resource::Repo(id) => id == repo,
Resource::Org(id) => self.is_within(r.owner, id),
};
if !(holds && covers) {
continue;
}
let role = match g.role {
GrantRole::Builtin(role) => role,
GrantRole::Custom(id) => {
let c = &self.custom_roles[&id];
access.permissions.extend(c.permissions.iter().cloned());
c.base
}
};
access.role = access.role.max(Some(role));
}
access
}
pub fn effective_role(&self, user: Option<Id>, repo: Id) -> Option<Role> {
self.effective_access(user, repo).role
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
dir: Directory,
ent: Id,
acme: Id,
platform: Id,
api: Id,
site: Id,
alice: Id,
bob: Id,
carol: Id,
devs: Id,
}
fn fixture() -> Fixture {
let mut dir = Directory::default();
let ent = dir.add_enterprise("acme-corp", None).unwrap();
let acme = dir.add_org("acme", None, Visibility::Internal).unwrap();
dir.set_org_enterprise(acme, Some(ent)).unwrap();
let platform = dir.add_org("platform", Some(acme), Visibility::Private).unwrap();
let api = dir.add_repo(platform, "api", Visibility::Private).unwrap();
let site = dir.add_repo(acme, "site", Visibility::Internal).unwrap();
let alice = dir.add_user("alice").unwrap();
let bob = dir.add_user("bob").unwrap();
let carol = dir.add_user("carol").unwrap();
dir.set_org_member(acme, alice, Some(OrgRole::Owner)).unwrap();
dir.set_org_member(acme, bob, Some(OrgRole::Member)).unwrap();
let devs = dir.add_team(acme, "devs", None, TeamPrivacy::Visible).unwrap();
dir.set_team_member(devs, bob, Some(TeamRole::Member)).unwrap();
Fixture { dir, ent, acme, platform, api, site, alice, bob, carol, devs }
}
#[test]
fn users_and_top_level_orgs_share_a_case_insensitive_namespace() {
let mut f = fixture();
assert!(f.dir.add_user("ACME").is_err());
assert!(f.dir.add_org("Alice", None, Visibility::Public).is_err());
assert!(f.dir.add_org("alice", Some(f.acme), Visibility::Private).is_ok());
}
#[test]
fn sub_orgs_and_repos_share_a_path_space() {
let mut f = fixture();
assert!(f.dir.add_repo(f.acme, "platform", Visibility::Private).is_err());
assert!(f.dir.add_org("site", Some(f.acme), Visibility::Private).is_err());
}
#[test]
fn rejects_reserved_and_malformed_slugs() {
let mut dir = Directory::default();
assert!(dir.add_user("repos").is_err());
assert!(dir.add_user("foo.git").is_err());
assert!(dir.add_user("-foo").is_err());
assert!(dir.add_user("a/b").is_err());
}
#[test]
fn structural_invariants() {
let mut f = fixture();
assert!(f.dir.add_org("x", Some(f.alice), Visibility::Private).is_err());
assert!(f.dir.add_org("x", Some(f.platform), Visibility::Public).is_err());
assert!(f.dir.add_repo(f.platform, "x", Visibility::Internal).is_err());
assert!(f.dir.set_org_enterprise(f.platform, Some(f.ent)).is_err());
assert_eq!(f.dir.enterprise_of(f.platform), Some(f.ent));
assert_eq!(f.dir.enterprise_of(f.alice), None);
}
#[test]
fn paths_round_trip() {
let f = fixture();
assert_eq!(f.dir.path(f.platform).as_deref(), Some("acme/platform"));
assert_eq!(f.dir.repo_path(f.api).as_deref(), Some("acme/platform/api"));
assert_eq!(f.dir.resolve("ACME/Platform"), Some(f.platform));
assert_eq!(f.dir.resolve_repo("acme/platform/api"), Some(f.api));
assert_eq!(f.dir.resolve_repo("acme/api"), None);
}
#[test]
fn internal_means_enterprise_members_inside_an_enterprise() {
let mut f = fixture();
assert_eq!(f.dir.effective_role(None, f.site), None);
assert_eq!(f.dir.effective_role(Some(f.carol), f.site), None);
assert_eq!(f.dir.effective_role(Some(f.bob), f.site), Some(Role::Read));
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), None);
let other = f.dir.add_org("other", None, Visibility::Public).unwrap();
f.dir.set_org_enterprise(other, Some(f.ent)).unwrap();
f.dir.set_org_member(other, f.carol, Some(OrgRole::Member)).unwrap();
assert_eq!(f.dir.effective_role(Some(f.carol), f.site), Some(Role::Read));
}
#[test]
fn internal_means_any_signed_in_user_outside_an_enterprise() {
let mut f = fixture();
let gl = f.dir.add_org("gl", None, Visibility::Internal).unwrap();
let repo = f.dir.add_repo(gl, "x", Visibility::Internal).unwrap();
assert_eq!(f.dir.effective_role(None, repo), None);
assert_eq!(f.dir.effective_role(Some(f.carol), repo), Some(Role::Read));
}
#[test]
fn org_owners_are_admin_everywhere_beneath() {
let f = fixture();
assert_eq!(f.dir.effective_role(Some(f.alice), f.api), Some(Role::Admin));
assert_eq!(f.dir.org_role(f.platform, f.alice), Some(OrgRole::Owner));
}
#[test]
fn base_role_applies_to_members_only() {
let mut f = fixture();
f.dir.set_base_role(f.acme, Some(Role::Write)).unwrap();
f.dir.set_org_member(f.acme, f.carol, Some(OrgRole::BillingManager)).unwrap();
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), Some(Role::Write));
assert_eq!(f.dir.effective_role(Some(f.carol), f.api), None);
assert!(!f.dir.is_enterprise_member(f.ent, f.carol));
}
#[test]
fn grants_inherit_down_the_tree_and_take_the_max() {
let mut f = fixture();
f.dir.grant(Principal::User(f.bob), Resource::Org(f.acme), Role::Triage).unwrap();
f.dir.grant(Principal::User(f.bob), Resource::Org(f.platform), Role::Maintain).unwrap();
f.dir.grant(Principal::Team(f.devs), Resource::Repo(f.api), Role::Write).unwrap();
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), Some(Role::Maintain));
assert_eq!(f.dir.effective_role(Some(f.bob), f.site), Some(Role::Triage));
f.dir.grant(Principal::User(f.bob), Resource::Org(f.platform), Role::Read).unwrap();
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), Some(Role::Write));
f.dir.revoke(Principal::Team(f.devs), Resource::Repo(f.api));
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), Some(Role::Triage));
}
#[test]
fn outside_collaborators_get_repo_grants_but_not_org_grants() {
let mut f = fixture();
assert!(f.dir.grant(Principal::User(f.carol), Resource::Org(f.acme), Role::Read).is_err());
f.dir.grant(Principal::User(f.carol), Resource::Repo(f.api), Role::Write).unwrap();
assert_eq!(f.dir.effective_role(Some(f.carol), f.api), Some(Role::Write));
assert!(!f.dir.is_org_member(f.acme, f.carol));
}
#[test]
fn nested_teams_inherit_parent_access() {
let mut f = fixture();
let backend = f.dir.add_team(f.acme, "backend", Some(f.devs), TeamPrivacy::Visible).unwrap();
f.dir.set_org_member(f.acme, f.carol, Some(OrgRole::Member)).unwrap();
f.dir.set_team_member(backend, f.carol, Some(TeamRole::Maintainer)).unwrap();
f.dir.grant(Principal::Team(f.devs), Resource::Repo(f.api), Role::Write).unwrap();
assert_eq!(f.dir.effective_role(Some(f.carol), f.api), Some(Role::Write));
f.dir.grant(Principal::Team(backend), Resource::Repo(f.api), Role::Admin).unwrap();
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), Some(Role::Write));
}
#[test]
fn team_rules() {
let mut f = fixture();
let secret = f.dir.add_team(f.acme, "secret", None, TeamPrivacy::Secret).unwrap();
assert!(f.dir.add_team(f.acme, "x", Some(secret), TeamPrivacy::Visible).is_err());
assert!(f.dir.add_team(f.acme, "y", Some(f.devs), TeamPrivacy::Secret).is_err());
assert!(f.dir.set_team_member(f.devs, f.carol, Some(TeamRole::Member)).is_err());
let other = f.dir.add_org("other", None, Visibility::Public).unwrap();
let theirs = f.dir.add_repo(other, "x", Visibility::Public).unwrap();
assert!(f.dir.grant(Principal::Team(f.devs), Resource::Repo(theirs), Role::Read).is_err());
}
#[test]
fn removing_a_member_prunes_their_access() {
let mut f = fixture();
f.dir.grant(Principal::User(f.bob), Resource::Repo(f.api), Role::Write).unwrap();
f.dir.grant(Principal::Team(f.devs), Resource::Org(f.acme), Role::Read).unwrap();
f.dir.set_org_member(f.acme, f.bob, None).unwrap();
assert!(!f.dir.team(f.devs).unwrap().members.contains_key(&f.bob));
assert_eq!(f.dir.effective_role(Some(f.bob), f.api), None);
f.dir.set_org_member(f.acme, f.carol, Some(OrgRole::Member)).unwrap();
f.dir.set_org_member(f.platform, f.carol, Some(OrgRole::Member)).unwrap();
f.dir.set_org_member(f.platform, f.carol, None).unwrap();
assert!(f.dir.is_org_member(f.platform, f.carol));
}
#[test]
fn last_owner_cant_leave() {
let mut f = fixture();
assert!(f.dir.set_org_member(f.acme, f.alice, None).is_err());
assert!(f.dir.set_org_member(f.acme, f.alice, Some(OrgRole::Member)).is_err());
f.dir.set_org_member(f.acme, f.bob, Some(OrgRole::Owner)).unwrap();
f.dir.set_org_member(f.acme, f.alice, None).unwrap();
}
#[test]
fn custom_roles_add_permissions_on_a_base() {
let mut f = fixture();
let hooks = f.dir.add_custom_role(f.acme, "hook-admin", Role::Read, &["manage_webhooks"]).unwrap();
assert!(f.dir.add_custom_role(f.acme, "Write", Role::Write, &[]).is_err());
f.dir.grant(Principal::Team(f.devs), Resource::Repo(f.api), GrantRole::Custom(hooks)).unwrap();
let access = f.dir.effective_access(Some(f.bob), f.api);
assert_eq!(access.role, Some(Role::Read));
assert!(access.permissions.contains("manage_webhooks"));
let other = f.dir.add_org("other", None, Visibility::Public).unwrap();
let theirs = f.dir.add_repo(other, "x", Visibility::Public).unwrap();
assert!(f.dir.grant(Principal::User(f.carol), Resource::Repo(theirs), GrantRole::Custom(hooks)).is_err());
}
#[test]
fn enterprise_roles_grant_no_repo_access() {
let mut f = fixture();
f.dir.set_enterprise_role(f.ent, f.carol, Some(EnterpriseRole::Owner)).unwrap();
assert_eq!(f.dir.enterprise_role(f.ent, f.carol), Some(EnterpriseRole::Owner));
assert_eq!(f.dir.effective_role(Some(f.carol), f.api), None);
}
#[test]
fn managed_users_are_confined_to_their_enterprise() {
let mut f = fixture();
let emu = f.dir.add_enterprise("globex", Some("gx")).unwrap();
let gx = f.dir.add_org("globex", None, Visibility::Public).unwrap();
f.dir.set_org_enterprise(gx, Some(emu)).unwrap();
let hank = f.dir.add_managed_user(emu, "hank").unwrap();
assert_eq!(f.dir.account(hank).unwrap().slug, "hank_gx");
assert!(f.dir.add_managed_user(f.ent, "x").is_err());
f.dir.set_org_member(gx, hank, Some(OrgRole::Owner)).unwrap();
assert!(f.dir.set_org_member(gx, f.carol, Some(OrgRole::Member)).is_err());
let gx_repo = f.dir.add_repo(gx, "x", Visibility::Private).unwrap();
assert!(f.dir.grant(Principal::User(f.carol), Resource::Repo(gx_repo), Role::Read).is_err());
assert!(f.dir.set_org_member(f.acme, hank, Some(OrgRole::Member)).is_err());
let public = f.dir.add_repo(f.carol, "pub", Visibility::Public).unwrap();
assert!(f.dir.grant(Principal::User(hank), Resource::Repo(public), Role::Write).is_err());
assert_eq!(f.dir.effective_role(Some(hank), public), Some(Role::Read));
assert!(f.dir.add_repo(hank, "p", Visibility::Public).is_err());
assert!(f.dir.add_repo(hank, "p", Visibility::Private).is_ok());
assert!(f.dir.set_org_enterprise(f.acme, Some(emu)).is_err());
assert_eq!(f.dir.enterprise_of(f.acme), Some(f.ent));
}
#[test]
fn blocks() {
let mut f = fixture();
let gl = f.dir.add_org("gl", None, Visibility::Public).unwrap();
let open = f.dir.add_repo(gl, "open", Visibility::Public).unwrap();
f.dir.grant(Principal::User(f.carol), Resource::Repo(open), Role::Write).unwrap();
f.dir.block(gl, f.carol).unwrap();
assert_eq!(f.dir.effective_role(Some(f.carol), open), Some(Role::Read));
assert!(f.dir.grant(Principal::User(f.carol), Resource::Repo(open), Role::Write).is_err());
assert!(f.dir.block(f.acme, f.bob).is_err());
f.dir.unblock(gl, f.carol);
f.dir.grant(Principal::User(f.carol), Resource::Repo(open), Role::Write).unwrap();
}
#[test]
fn user_owns_their_repos() {
let mut f = fixture();
let dots = f.dir.add_repo(f.alice, "dotfiles", Visibility::Private).unwrap();
assert_eq!(f.dir.effective_role(Some(f.alice), dots), Some(Role::Admin));
assert_eq!(f.dir.effective_role(Some(f.bob), dots), None);
assert_eq!(f.dir.repo_path(dots).as_deref(), Some("alice/dotfiles"));
}
#[test]
fn role_mappings() {
assert_eq!(Role::from_github("push"), Some(Role::Write));
assert_eq!(Role::from_gitlab(10), None);
assert_eq!(Role::from_gitlab(20), Some(Role::Read));
assert_eq!(Role::from_gitlab(50), Some(Role::Admin));
assert_eq!(Role::from_gitea(4), Some(Role::Admin));
assert_eq!(Role::from_gitea(0), None);
}
#[test]
fn add_by_path() {
let mut f = fixture();
let tools = f.dir.add_org_at("acme/platform/tools", Visibility::Private).unwrap();
let cli = f.dir.add_repo_at("ACME/platform/tools/cli", Visibility::Private).unwrap();
assert_eq!(f.dir.account(tools).unwrap().parent(), Some(f.platform));
assert_eq!(f.dir.repo_path(cli).as_deref(), Some("acme/platform/tools/cli"));
assert!(f.dir.add_repo_at("nobody/x", Visibility::Public).is_err());
assert!(f.dir.add_repo_at("flat", Visibility::Public).is_err());
assert!(f.dir.add_org_at("nobody/x", Visibility::Public).is_err());
}
#[test]
fn serde_round_trip() {
let mut f = fixture();
let hooks = f.dir.add_custom_role(f.acme, "hook-admin", Role::Read, &["manage_webhooks"]).unwrap();
f.dir.grant(Principal::Team(f.devs), Resource::Repo(f.api), GrantRole::Custom(hooks)).unwrap();
f.dir.grant(Principal::User(f.bob), Resource::Repo(f.site), Role::Write).unwrap();
let json = serde_json::to_string(&f.dir).unwrap();
let back: Directory = serde_json::from_str(&json).unwrap();
assert_eq!(back.repo_path(f.api), f.dir.repo_path(f.api));
assert_eq!(back.effective_access(Some(f.bob), f.api), f.dir.effective_access(Some(f.bob), f.api));
assert_eq!(back.effective_role(Some(f.bob), f.site), Some(Role::Write));
}
}