use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Read,
Write,
Deploy,
Admin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Resource {
Site,
Project,
Blobs,
Tokens,
Certs,
Cache,
System,
}
impl Resource {
pub const ALL: [Self; 7] = [
Self::Site,
Self::Project,
Self::Blobs,
Self::Tokens,
Self::Certs,
Self::Cache,
Self::System,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Site => "site",
Self::Project => "project",
Self::Blobs => "blobs",
Self::Tokens => "tokens",
Self::Certs => "certs",
Self::Cache => "cache",
Self::System => "system",
}
}
}
impl Action {
pub fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Deploy => "deploy",
Self::Admin => "admin",
}
}
}
impl std::fmt::Display for Resource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::fmt::Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Right {
pub resource: Resource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub action: Action,
}
impl Right {
pub fn new(resource: Resource, target: Option<String>, action: Action) -> Self {
Self {
resource,
target,
action,
}
}
pub fn target_term(&self) -> &str {
self.target.as_deref().unwrap_or("*")
}
pub fn satisfies(&self, required: &Self) -> bool {
self.resource == required.resource
&& (self.action == required.action || self.action == Action::Admin)
&& target_matches(self.target.as_deref(), required.target.as_deref())
}
pub fn required(method: &str, path: &str) -> Option<Self> {
let m = method.to_ascii_uppercase();
let get = m == "GET";
if path == "/api/auth/exchange" || path == "/api/auth/whoami" {
return None;
}
if path == "/api/cluster/join" {
return None;
}
if path == "/api/tokens/bootstrap" {
return None;
}
if path.starts_with("/api/blobs/") {
return Some(Self::new(Resource::Blobs, None, Action::Deploy));
}
if m == "POST" && path.contains("/domains/") && path.ends_with("/attach-unverified") {
return Some(Self::new(Resource::System, None, Action::Admin));
}
if let Some((proj, sub)) = project_api_path(path) {
if proj.is_empty() {
return Some(Self::new(Resource::System, None, Action::Read));
}
let sub: Vec<&str> = sub.split('/').filter(|s| !s.is_empty()).collect();
return Some(match sub.split_first() {
None => Self::new(
Resource::Project,
Some(proj.to_string()),
if get { Action::Read } else { Action::Admin },
),
Some((&"sites", tail)) => {
let site = tail.first().copied().unwrap_or("");
if site.is_empty() {
return Some(Self::new(
Resource::Project,
Some(proj.to_string()),
Action::Read,
));
}
let site_sub: Vec<&str> = tail.iter().skip(1).copied().collect();
match site_subpath_action(&m, get, &site_sub) {
Some(a) => Self::new(Resource::Site, Some(format!("{proj}/{site}")), a),
None => Self::new(Resource::System, None, Action::Admin),
}
}
Some(_) => Self::new(
Resource::Project,
Some(proj.to_string()),
if get { Action::Read } else { Action::Deploy },
),
});
}
if let Some(rest) = path.strip_prefix("/api/sites/") {
let mut segs = rest.split('/');
let site = segs.next().unwrap_or("");
if site.is_empty() {
return Some(Self::new(Resource::System, None, Action::Read));
}
let target = Some(format!("{}/{site}", crate::project::DEFAULT_PROJECT));
let sub: Vec<&str> = segs.filter(|s| !s.is_empty()).collect();
let action = site_subpath_action(&m, get, &sub);
return Some(match action {
Some(a) => Self::new(Resource::Site, target, a),
None => Self::new(Resource::System, None, Action::Admin),
});
}
let default_project = crate::project::DEFAULT_PROJECT.to_string();
let right = match path {
"/api/sites" => Self::new(Resource::System, None, Action::Read),
"/api/projects" => {
let action = if get { Action::Read } else { Action::Admin };
Self::new(Resource::System, None, action)
}
p if p == "/api/functions" || p.starts_with("/api/functions/") => {
let action = if get { Action::Read } else { Action::Deploy };
Self::new(Resource::Project, Some(default_project.clone()), action)
}
p if p == "/api/workflows" || p.starts_with("/api/workflows/") => {
let action = if get { Action::Read } else { Action::Deploy };
Self::new(Resource::Project, Some(default_project.clone()), action)
}
p if p == "/api/compute" || p.starts_with("/api/compute/") => {
let action = if get { Action::Read } else { Action::Deploy };
Self::new(Resource::Project, Some(default_project.clone()), action)
}
p if p == "/api/graphql" || p.starts_with("/api/graphql/") => {
let action = if get { Action::Read } else { Action::Deploy };
Self::new(Resource::Project, Some(default_project.clone()), action)
}
"/api/blobs" => Self::new(Resource::Blobs, None, Action::Deploy),
"/api/certs" => Self::new(Resource::Certs, None, Action::Read),
"/api/cache/invalidate" => Self::new(Resource::Cache, None, Action::Write),
"/api/metrics" => Self::new(Resource::System, None, Action::Read),
"/api/prune" | "/api/scrub" => Self::new(Resource::System, None, Action::Admin),
p if p == "/api/tokens" || p.starts_with("/api/tokens/") => {
Self::new(Resource::Tokens, None, Action::Admin)
}
p if p == "/api/authz/policy" || p.starts_with("/api/authz/") => {
Self::new(Resource::System, None, Action::Admin)
}
_ => Self::new(Resource::System, None, Action::Admin),
};
Some(right)
}
}
fn site_subpath_action(method: &str, get: bool, sub: &[&str]) -> Option<Action> {
match sub.first().copied() {
Some("deployments") => {
let activate = sub.last() == Some(&"activate");
if activate || method == "POST" {
Some(Action::Deploy) } else if get {
Some(Action::Read)
} else {
None
}
}
Some("current") if get => Some(Action::Read),
Some("config") => {
if get {
Some(Action::Read)
} else if method == "PUT" {
Some(Action::Write)
} else {
None
}
}
Some("domains") => {
let check = sub.last() == Some(&"check"); if get || check {
Some(Action::Read)
} else if method == "POST" || method == "DELETE" {
Some(Action::Write)
} else {
None
}
}
Some("domain-verifications") if get => Some(Action::Read),
Some("aliases") => {
if get {
Some(Action::Read)
} else if method == "PUT" || method == "DELETE" {
Some(Action::Write)
} else {
None
}
}
Some("_boatramp") => {
if get {
Some(Action::Read)
} else if method == "POST" && sub.get(1) == Some(&"dlq") {
Some(Action::Write)
} else {
None
}
}
_ => None,
}
}
pub fn project_of(target: &str) -> &str {
target.split_once('/').map_or(target, |(p, _)| p)
}
pub fn project_api_path(path: &str) -> Option<(&str, &str)> {
let rest = path.strip_prefix("/api/projects/")?;
Some(rest.split_once('/').unwrap_or((rest, "")))
}
fn target_matches(granted: Option<&str>, required: Option<&str>) -> bool {
match granted {
None => true,
Some(g) => match g.strip_suffix("/*") {
Some(project) => required.is_some_and(|r| project_of(r) == project),
None => required == Some(g),
},
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RightSet {
rights: Vec<Right>,
}
impl RightSet {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, right: Right) {
if !self.rights.contains(&right) {
self.rights.push(right);
}
}
pub fn allows(&self, required: &Right) -> bool {
self.rights.iter().any(|g| g.satisfies(required))
}
pub fn is_empty(&self) -> bool {
self.rights.is_empty()
}
pub fn rights(&self) -> &[Right] {
&self.rights
}
}
impl FromIterator<Right> for RightSet {
fn from_iter<I: IntoIterator<Item = Right>>(iter: I) -> Self {
let mut set = Self::new();
for r in iter {
set.insert(r);
}
set
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
Site,
Project,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantedRole {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl GrantedRole {
pub fn global(name: impl Into<String>) -> Self {
Self {
name: name.into(),
target: None,
}
}
pub fn scoped(name: impl Into<String>, target: impl Into<String>) -> Self {
Self {
name: name.into(),
target: Some(target.into()),
}
}
pub fn parse(spec: &str) -> Self {
match spec.split_once(':') {
Some((name, target)) if !target.trim().is_empty() => {
Self::scoped(name.trim(), target.trim())
}
_ => Self::global(spec.trim()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetScope {
AnyTarget,
RoleTarget,
ProjectWildcard,
}
impl TargetScope {
pub fn is_targeted(self) -> bool {
matches!(self, Self::RoleTarget | Self::ProjectWildcard)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RightTemplate {
pub resource: Resource,
pub action: Action,
pub scope: TargetScope,
}
impl RightTemplate {
pub fn any(resource: Resource, action: Action) -> Self {
Self {
resource,
action,
scope: TargetScope::AnyTarget,
}
}
pub fn scoped(resource: Resource, action: Action) -> Self {
Self {
resource,
action,
scope: TargetScope::RoleTarget,
}
}
pub fn project_wildcard(resource: Resource, action: Action) -> Self {
Self {
resource,
action,
scope: TargetScope::ProjectWildcard,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthzPolicy {
#[serde(default = "crate::schema_version")]
pub version: u32,
pub roles: BTreeMap<String, Vec<RightTemplate>>,
}
impl Default for AuthzPolicy {
fn default() -> Self {
Self::default_policy()
}
}
impl AuthzPolicy {
pub fn default_policy() -> Self {
let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
roles.insert(
"admin".to_string(),
Resource::ALL
.iter()
.map(|&r| RightTemplate::any(r, Action::Admin))
.collect(),
);
roles.insert(
"publisher".to_string(),
vec![
RightTemplate::scoped(Resource::Site, Action::Read),
RightTemplate::scoped(Resource::Site, Action::Write),
RightTemplate::scoped(Resource::Site, Action::Deploy),
RightTemplate::any(Resource::Blobs, Action::Deploy),
],
);
roles.insert(
"deployer".to_string(),
vec![
RightTemplate::scoped(Resource::Site, Action::Read),
RightTemplate::scoped(Resource::Site, Action::Deploy),
RightTemplate::any(Resource::Blobs, Action::Deploy),
],
);
roles.insert(
"viewer".to_string(),
vec![RightTemplate::scoped(Resource::Site, Action::Read)],
);
roles.insert(
"operator".to_string(),
vec![
RightTemplate::any(Resource::System, Action::Read),
RightTemplate::any(Resource::Certs, Action::Read),
RightTemplate::any(Resource::Cache, Action::Write),
],
);
roles.insert(
"project_admin".to_string(),
vec![
RightTemplate::scoped(Resource::Project, Action::Admin),
RightTemplate::project_wildcard(Resource::Site, Action::Admin),
RightTemplate::any(Resource::Blobs, Action::Deploy),
],
);
roles.insert(
"project_publisher".to_string(),
vec![
RightTemplate::scoped(Resource::Project, Action::Read),
RightTemplate::scoped(Resource::Project, Action::Write),
RightTemplate::scoped(Resource::Project, Action::Deploy),
RightTemplate::project_wildcard(Resource::Site, Action::Read),
RightTemplate::project_wildcard(Resource::Site, Action::Write),
RightTemplate::project_wildcard(Resource::Site, Action::Deploy),
RightTemplate::any(Resource::Blobs, Action::Deploy),
],
);
roles.insert(
"project_viewer".to_string(),
vec![
RightTemplate::scoped(Resource::Project, Action::Read),
RightTemplate::project_wildcard(Resource::Site, Action::Read),
],
);
Self {
version: crate::SCHEMA_VERSION,
roles,
}
}
pub fn role_takes_target(&self, role: &str) -> bool {
self.roles
.get(role)
.is_some_and(|ts| ts.iter().any(|t| t.scope.is_targeted()))
}
pub fn role_target_kind(&self, role: &str) -> Option<TargetKind> {
let templates = self.roles.get(role)?;
let mut site = false;
let mut project = false;
for t in templates {
match t.scope {
TargetScope::RoleTarget if t.resource == Resource::Site => site = true,
TargetScope::RoleTarget => project = true,
TargetScope::ProjectWildcard => project = true,
TargetScope::AnyTarget => {}
}
}
if site {
Some(TargetKind::Site)
} else if project {
Some(TargetKind::Project)
} else {
None
}
}
pub fn normalize_grants(&self, roles: &[GrantedRole]) -> Vec<GrantedRole> {
roles
.iter()
.map(|g| match (&g.target, self.role_target_kind(&g.name)) {
(Some(t), Some(TargetKind::Site)) if !t.contains('/') => {
GrantedRole::scoped(&g.name, format!("{}/{t}", crate::project::DEFAULT_PROJECT))
}
_ => g.clone(),
})
.collect()
}
pub fn rights_for(&self, roles: &[GrantedRole]) -> RightSet {
let mut set = RightSet::new();
for granted in roles {
let Some(templates) = self.roles.get(&granted.name) else {
continue;
};
for t in templates {
let target = match t.scope {
TargetScope::AnyTarget => None,
TargetScope::RoleTarget => match &granted.target {
Some(x) => Some(x.clone()),
None => continue,
},
TargetScope::ProjectWildcard => match &granted.target {
Some(x) => Some(format!("{x}/*")),
None => continue,
},
};
set.insert(Right::new(t.resource, target, t.action));
}
}
set
}
}
pub const POLICY_KEY: &str = "authz/policy";
pub const REVOKED_PREFIX: &str = "authz/revoked/";
pub const TOKEN_META_PREFIX: &str = "authz/tokens/";
pub fn revoked_key(revocation_id: &str) -> String {
format!("{REVOKED_PREFIX}{revocation_id}")
}
pub const ROOT_ANCHOR_PREFIX: &str = "auth/root/";
pub fn root_anchor_key(pubkey: &str) -> String {
format!("{ROOT_ANCHOR_PREFIX}{pubkey}")
}
pub fn token_meta_key(id: &str) -> String {
format!("{TOKEN_META_PREFIX}{id}")
}
pub fn bootstrap_key(secret_hash: &str) -> String {
format!("authz/bootstrap/{secret_hash}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenMeta {
#[serde(default = "crate::schema_version")]
pub version: u32,
pub label: String,
pub roles: Vec<GrantedRole>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
pub revocation_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admin_satisfies_every_action_on_its_resource() {
let admin_site = Right::new(Resource::Site, None, Action::Admin);
for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
let required = Right::new(Resource::Site, Some("blog".into()), action);
assert!(
admin_site.satisfies(&required),
"admin must satisfy {action:?}"
);
}
assert!(!admin_site.satisfies(&Right::new(Resource::Tokens, None, Action::Read)));
}
#[test]
fn target_scoping_is_exact_unless_wildcard() {
let blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
assert!(blog.satisfies(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Write
)));
assert!(!blog.satisfies(&Right::new(
Resource::Site,
Some("api".into()),
Action::Write
)));
let any = Right::new(Resource::Site, None, Action::Write);
assert!(any.satisfies(&Right::new(
Resource::Site,
Some("api".into()),
Action::Write
)));
}
#[test]
fn distinct_actions_do_not_imply_each_other() {
let write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
let deploy_req = Right::new(Resource::Site, Some("blog".into()), Action::Deploy);
assert!(
!write.satisfies(&deploy_req),
"write must not imply deploy (only admin does)"
);
}
#[test]
fn required_right_table() {
let cases: &[(&str, &str, Option<Right>)] = &[
("POST", "/api/auth/exchange", None),
("GET", "/api/auth/whoami", None),
(
"POST",
"/api/cluster/join-token",
Some(Right::new(Resource::System, None, Action::Admin)),
),
("POST", "/api/cluster/join", None),
(
"POST",
"/api/cluster/rotate-key",
Some(Right::new(Resource::System, None, Action::Admin)),
),
(
"POST",
"/api/cluster/revoke",
Some(Right::new(Resource::System, None, Action::Admin)),
),
(
"PUT",
"/api/blobs/abc123",
Some(Right::new(Resource::Blobs, None, Action::Deploy)),
),
(
"GET",
"/api/sites",
Some(Right::new(Resource::System, None, Action::Read)),
),
(
"POST",
"/api/sites/blog/deployments",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Deploy,
)),
),
(
"GET",
"/api/sites/blog/deployments",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"GET",
"/api/sites/blog/deployments/d1",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"POST",
"/api/sites/blog/deployments/d1/activate",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Deploy,
)),
),
(
"GET",
"/api/sites/blog/current",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"GET",
"/api/sites/blog/config",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"PUT",
"/api/sites/blog/config",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Write,
)),
),
(
"GET",
"/api/sites/blog/domains/x.example.com/verification",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"POST",
"/api/sites/blog/domains/x.example.com/verification",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Write,
)),
),
(
"DELETE",
"/api/sites/blog/domains/x.example.com/verification",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Write,
)),
),
(
"POST",
"/api/sites/blog/domains/x.example.com/verification/check",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"GET",
"/api/sites/blog/domain-verifications",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"PUT",
"/api/sites/blog/aliases/www",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Write,
)),
),
(
"GET",
"/api/sites/blog/aliases",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"GET",
"/api/sites/blog/_boatramp/handlers",
Some(Right::new(
Resource::Site,
Some("default/blog".into()),
Action::Read,
)),
),
(
"POST",
"/api/tokens",
Some(Right::new(Resource::Tokens, None, Action::Admin)),
),
(
"DELETE",
"/api/tokens/t1",
Some(Right::new(Resource::Tokens, None, Action::Admin)),
),
(
"GET",
"/api/prune",
Some(Right::new(Resource::System, None, Action::Admin)),
),
(
"POST",
"/api/scrub",
Some(Right::new(Resource::System, None, Action::Admin)),
),
(
"GET",
"/api/certs",
Some(Right::new(Resource::Certs, None, Action::Read)),
),
(
"POST",
"/api/cache/invalidate",
Some(Right::new(Resource::Cache, None, Action::Write)),
),
(
"GET",
"/api/metrics",
Some(Right::new(Resource::System, None, Action::Read)),
),
(
"GET",
"/api/graphql/supergraph",
Some(Right::new(
Resource::Project,
Some("default".into()),
Action::Read,
)),
),
(
"PUT",
"/api/graphql/subgraphs/catalog",
Some(Right::new(
Resource::Project,
Some("default".into()),
Action::Deploy,
)),
),
(
"POST",
"/api/graphql/safelist",
Some(Right::new(
Resource::Project,
Some("default".into()),
Action::Deploy,
)),
),
];
for (method, path, expected) in cases {
assert_eq!(
&Right::required(method, path),
expected,
"required({method}, {path})"
);
}
}
#[test]
fn unknown_site_subpath_is_deny_safe() {
assert_eq!(
Right::required("PATCH", "/api/sites/blog/frobnicate"),
Some(Right::new(Resource::System, None, Action::Admin))
);
}
#[test]
fn attach_unverified_is_admin_only() {
let required = Right::required(
"POST",
"/api/sites/blog/domains/evil.example.com/attach-unverified",
)
.expect("route is gated");
assert_eq!(required, Right::new(Resource::System, None, Action::Admin));
let site_write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
assert!(!site_write.satisfies(&required));
assert!(Right::new(Resource::System, None, Action::Admin).satisfies(&required));
}
#[test]
fn default_policy_publisher_can_deploy_and_write_its_site_only() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::scoped("publisher", "blog")]);
assert!(rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Deploy
)));
assert!(rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Write
)));
assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
assert!(!rights.allows(&Right::new(
Resource::Site,
Some("api".into()),
Action::Read
)));
assert!(!rights.allows(&Right::new(Resource::Tokens, None, Action::Admin)));
}
#[test]
fn default_policy_deployer_cannot_edit_config() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::scoped("deployer", "blog")]);
assert!(rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Deploy
)));
assert!(rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Read
)));
assert!(
!rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Write
)),
"deployer must not edit config"
);
}
#[test]
fn default_policy_admin_can_do_anything() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::global("admin")]);
for resource in Resource::ALL {
for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
let target = matches!(resource, Resource::Site).then(|| "any".to_string());
assert!(
rights.allows(&Right::new(resource, target, action)),
"admin must allow {resource:?}·{action:?}"
);
}
}
}
#[test]
fn site_role_without_target_grants_nothing_site_scoped() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::global("publisher")]);
assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
assert!(!rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Read
)));
}
#[test]
fn role_takes_target_classifies_roles() {
let policy = AuthzPolicy::default_policy();
assert!(policy.role_takes_target("publisher"));
assert!(policy.role_takes_target("viewer"));
assert!(!policy.role_takes_target("admin"));
assert!(!policy.role_takes_target("operator"));
}
#[test]
fn policy_round_trips_through_json() {
let policy = AuthzPolicy::default_policy();
let json = serde_json::to_string(&policy).unwrap();
let back: AuthzPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(policy, back);
assert_eq!(back.version, crate::SCHEMA_VERSION);
}
#[test]
fn unknown_role_is_ignored() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::global("nonesuch")]);
assert!(rights.is_empty());
}
#[test]
fn legacy_site_grant_normalizes_to_default_project() {
let policy = AuthzPolicy::default_policy();
let n = policy.normalize_grants(&[GrantedRole::scoped("publisher", "blog")]);
assert_eq!(n, vec![GrantedRole::scoped("publisher", "default/blog")]);
let untouched = [
GrantedRole::scoped("publisher", "acme/blog"),
GrantedRole::scoped("project_admin", "acme"),
GrantedRole::global("admin"),
];
assert_eq!(policy.normalize_grants(&untouched), untouched);
}
#[test]
fn project_admin_covers_its_project_but_not_another() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
assert!(
rights.allows(&Right::new(
Resource::Site,
Some("acme/blog".into()),
action
)),
"project-admin:acme covers acme/blog·{action:?}"
);
}
assert!(rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Deploy
)));
assert!(rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Admin
)));
assert!(!rights.allows(&Right::new(
Resource::Site,
Some("shop/blog".into()),
Action::Read
)));
assert!(!rights.allows(&Right::new(
Resource::Project,
Some("shop".into()),
Action::Read
)));
assert!(!rights.allows(&Right::new(
Resource::Site,
Some("blog".into()),
Action::Read
)));
}
#[test]
fn project_viewer_is_read_only_across_the_project() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
assert!(rights.allows(&Right::new(
Resource::Site,
Some("acme/blog".into()),
Action::Read
)));
assert!(rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Read
)));
assert!(!rights.allows(&Right::new(
Resource::Site,
Some("acme/blog".into()),
Action::Write
)));
assert!(!rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Deploy
)));
}
#[test]
fn project_publisher_ships_but_cannot_admin_the_project() {
let policy = AuthzPolicy::default_policy();
let rights = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
assert!(rights.allows(&Right::new(
Resource::Site,
Some("acme/blog".into()),
Action::Deploy
)));
assert!(rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Deploy
)));
assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
assert!(!rights.allows(&Right::new(
Resource::Project,
Some("acme".into()),
Action::Admin
)));
}
#[test]
fn required_maps_project_paths() {
assert_eq!(
Right::required("POST", "/api/projects/acme/sites/blog/deployments"),
Some(Right::new(
Resource::Site,
Some("acme/blog".into()),
Action::Deploy
))
);
assert_eq!(
Right::required("GET", "/api/projects/acme/functions/resize"),
Some(Right::new(
Resource::Project,
Some("acme".into()),
Action::Read
))
);
assert_eq!(
Right::required("POST", "/api/projects/acme/functions/resize/versions"),
Some(Right::new(
Resource::Project,
Some("acme".into()),
Action::Deploy
))
);
assert_eq!(
Right::required("DELETE", "/api/projects/acme"),
Some(Right::new(
Resource::Project,
Some("acme".into()),
Action::Admin
))
);
assert_eq!(
Right::required("GET", "/api/projects"),
Some(Right::new(Resource::System, None, Action::Read))
);
assert_eq!(
Right::required("POST", "/api/projects"),
Some(Right::new(Resource::System, None, Action::Admin))
);
assert_eq!(
Right::required("GET", "/api/functions"),
Some(Right::new(
Resource::Project,
Some("default".into()),
Action::Read
))
);
}
}