use crate::platform::model::{ProjectAccessRolePreset, ProjectCapability, ProjectPolicy};
const fn role_additions(role: ProjectAccessRolePreset) -> &'static [ProjectCapability] {
use ProjectCapability as C;
match role {
ProjectAccessRolePreset::Guest => &[
C::ProjectRead,
C::MembersRead,
C::TemplatesRead,
C::PipelinesRead,
C::FilesRead,
C::LibrariesRead,
],
ProjectAccessRolePreset::Reporter => &[C::TablesRead, C::SettingsRead],
ProjectAccessRolePreset::Developer => &[
C::TemplatesWrite,
C::TemplatesCreate,
C::TemplatesDelete,
C::TemplatesMove,
C::TemplatesDiagnostics,
C::PipelinesWrite,
C::PipelinesCreate,
C::PipelinesDelete,
C::PipelinesMove,
C::PipelinesExecute,
C::FilesWrite,
C::FilesDelete,
],
ProjectAccessRolePreset::Maintainer => &[
C::MembersWrite,
C::CredentialsRead,
C::CredentialsWrite,
C::TablesWrite,
C::LibrariesInstall,
C::LibrariesRemove,
C::SettingsWrite,
C::McpSessionCreate,
C::McpSessionRevoke,
],
ProjectAccessRolePreset::Owner => &[C::ProjectDelete],
}
}
pub const ROLE_LADDER: [ProjectAccessRolePreset; 5] = [
ProjectAccessRolePreset::Guest,
ProjectAccessRolePreset::Reporter,
ProjectAccessRolePreset::Developer,
ProjectAccessRolePreset::Maintainer,
ProjectAccessRolePreset::Owner,
];
pub fn role_capabilities(role: ProjectAccessRolePreset) -> Vec<ProjectCapability> {
if role == ProjectAccessRolePreset::Owner {
return ProjectCapability::all();
}
let mut out: Vec<ProjectCapability> = Vec::new();
for rung in ROLE_LADDER {
out.extend_from_slice(role_additions(rung));
if rung == role {
break;
}
}
out.dedup();
out
}
pub fn can_grant(actor: ProjectAccessRolePreset, granted: ProjectAccessRolePreset) -> bool {
let rank = |role| ROLE_LADDER.iter().position(|entry| *entry == role);
match (rank(actor), rank(granted)) {
(Some(actor_rank), Some(granted_rank)) => granted_rank <= actor_rank,
_ => false,
}
}
pub fn managed_role_policies(owner: &str, project: &str, now: i64) -> Vec<ProjectPolicy> {
[
ProjectAccessRolePreset::Owner,
ProjectAccessRolePreset::Guest,
ProjectAccessRolePreset::Reporter,
ProjectAccessRolePreset::Developer,
ProjectAccessRolePreset::Maintainer,
]
.into_iter()
.map(|role| ProjectPolicy {
project_id: String::new(),
owner: owner.to_string(),
project: project.to_string(),
policy_id: role.policy_id().to_string(),
title: role.title().to_string(),
capabilities: role_capabilities(role),
managed: true,
created_at: now,
updated_at: now,
})
.collect()
}
pub fn managed_role_alias_policies(owner: &str, project: &str, now: i64) -> Vec<ProjectPolicy> {
vec![
ProjectPolicy {
project_id: String::new(),
owner: owner.to_string(),
project: project.to_string(),
policy_id: "viewer".to_string(),
title: "Viewer".to_string(),
capabilities: role_capabilities(ProjectAccessRolePreset::Reporter),
managed: true,
created_at: now,
updated_at: now,
},
ProjectPolicy {
project_id: String::new(),
owner: owner.to_string(),
project: project.to_string(),
policy_id: "editor".to_string(),
title: "Editor".to_string(),
capabilities: role_capabilities(ProjectAccessRolePreset::Developer),
managed: true,
created_at: now,
updated_at: now,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ladder_never_loses_a_capability_going_up() {
for pair in ROLE_LADDER.windows(2) {
let (lower, upper) = (pair[0], pair[1]);
let lower_caps = role_capabilities(lower);
let upper_caps = role_capabilities(upper);
for capability in &lower_caps {
assert!(
upper_caps.contains(capability),
"{} has '{}' and {} does not",
lower.title(),
capability.key(),
upper.title()
);
}
}
}
#[test]
fn every_capability_is_reachable_by_some_role() {
for capability in ProjectCapability::all() {
assert!(
ROLE_LADDER
.iter()
.any(|role| role_capabilities(*role).contains(&capability)),
"no role grants '{}', so nothing guarded by it can be used",
capability.key()
);
}
}
#[test]
fn each_rung_is_worth_climbing() {
for pair in ROLE_LADDER.windows(2) {
let (lower, upper) = (pair[0], pair[1]);
assert!(
role_capabilities(upper).len() > role_capabilities(lower).len(),
"{} grants no more than {}, so one of them has no reason to exist",
upper.title(),
lower.title()
);
}
}
#[test]
fn a_role_cannot_grant_above_itself() {
use ProjectAccessRolePreset::*;
assert!(can_grant(Owner, Owner));
assert!(can_grant(Owner, Maintainer));
assert!(can_grant(Maintainer, Developer));
assert!(can_grant(Maintainer, Maintainer));
assert!(!can_grant(Maintainer, Owner), "a maintainer must not mint an owner");
assert!(!can_grant(Developer, Maintainer));
assert!(!can_grant(Guest, Reporter));
}
#[test]
fn owner_role_covers_all_capabilities() {
assert_eq!(
role_capabilities(ProjectAccessRolePreset::Owner),
ProjectCapability::all()
);
}
#[test]
fn role_aliases_point_at_stable_capability_bundles() {
let aliases = managed_role_alias_policies("superadmin", "default", 1);
assert_eq!(aliases.len(), 2);
assert_eq!(aliases[0].policy_id, "viewer");
assert_eq!(
aliases[0].capabilities,
role_capabilities(ProjectAccessRolePreset::Reporter)
);
assert_eq!(aliases[1].policy_id, "editor");
assert_eq!(
aliases[1].capabilities,
role_capabilities(ProjectAccessRolePreset::Developer)
);
}
}
#[cfg(test)]
mod contract_tests {
use super::*;
#[test]
fn the_contract_quotes_the_real_role_sizes() {
let sizes: Vec<usize> = ROLE_LADDER
.iter()
.map(|role| role_capabilities(*role).len())
.collect();
assert_eq!(
sizes,
vec![6, 8, 20, 29, 30],
"guest/reporter/developer/maintainer/owner sizes changed — update \
docs/contracts/kinds/project-access/README.md to match"
);
}
}