use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid,
PolicySet, Request, RestrictedExpression,
};
use boatramp_types::authz::{Action, AuthzPolicy, GrantedRole, Resource, Right, TargetScope};
const NS: &str = "BR";
#[derive(Debug, thiserror::Error)]
pub enum CedarError {
#[error("unsafe role name: {0:?}")]
RoleName(String),
#[error("policy generation failed: {0}")]
Policy(String),
}
pub struct CompiledCedar {
policies: PolicySet,
authorizer: Authorizer,
scoped_roles: Vec<String>,
}
impl CompiledCedar {
pub fn compile(policy: &AuthzPolicy) -> Result<Self, CedarError> {
for name in policy.roles.keys() {
if !is_safe_ident(name) {
return Err(CedarError::RoleName(name.clone()));
}
}
let text = generate_policy_text(policy);
let policies = PolicySet::from_str(&text)
.map_err(|e| CedarError::Policy(format!("{e}\n--- text ---\n{text}")))?;
let scoped_roles = policy
.roles
.iter()
.filter(|(_, templates)| templates.iter().any(|t| t.scope == TargetScope::RoleTarget))
.map(|(name, _)| name.clone())
.collect();
Ok(Self {
policies,
authorizer: Authorizer::new(),
scoped_roles,
})
}
pub fn authorize(&self, roles: &[GrantedRole], required: &Right) -> bool {
matches!(self.decide(roles, required), Ok(Decision::Allow))
}
fn decide(&self, roles: &[GrantedRole], required: &Right) -> Result<Decision, Box<dyn Error>> {
let principal_uid = uid("Principal", "self");
let action_uid = uid("Action", required.action.as_str());
let (resource_uid, resource) = resource_entity(required)?;
let principal = principal_entity(&principal_uid, roles, &self.scoped_roles)?;
let mut entities: Vec<Entity> = vec![principal, resource];
entities.extend(action_entities()?);
for r in roles {
if is_safe_ident(&r.name) {
entities.push(Entity::new(
uid("Role", &r.name),
HashMap::new(),
HashSet::new(),
)?);
}
}
let entities = Entities::from_entities(entities, None)?;
let request = Request::new(
principal_uid,
action_uid,
resource_uid,
Context::empty(),
None,
)?;
Ok(self
.authorizer
.is_authorized(&request, &self.policies, &entities)
.decision())
}
}
fn generate_policy_text(policy: &AuthzPolicy) -> String {
let mut out = String::new();
for (role, templates) in &policy.roles {
for t in templates {
let action = action_scope(t.action);
let resource_ty = resource_type(t.resource);
let guard = match t.scope {
TargetScope::RoleTarget => {
format!(" when {{ principal.{role}_sites.contains(resource.name) }}")
}
TargetScope::AnyTarget => String::new(),
};
out.push_str(&format!(
"permit(principal in {NS}::Role::\"{role}\", {action}, resource is {NS}::{resource_ty}){guard};\n"
));
}
}
out
}
fn action_scope(action: Action) -> String {
match action {
Action::Admin => format!("action in [{NS}::Action::\"admin\"]"),
other => format!("action == {NS}::Action::\"{}\"", other.as_str()),
}
}
fn resource_type(resource: Resource) -> &'static str {
match resource {
Resource::Site => "Site",
Resource::Blobs => "Blobs",
Resource::Tokens => "Tokens",
Resource::Certs => "Certs",
Resource::Cache => "Cache",
Resource::System => "System",
}
}
fn principal_entity(
principal_uid: &EntityUid,
roles: &[GrantedRole],
scoped_roles: &[String],
) -> Result<Entity, Box<dyn Error>> {
let mut parents: HashSet<EntityUid> = HashSet::new();
for r in roles {
if is_safe_ident(&r.name) {
parents.insert(uid("Role", &r.name));
}
}
let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
for role in scoped_roles {
let sites = roles
.iter()
.filter(|r| &r.name == role)
.filter_map(|r| r.target.as_ref())
.map(|t| RestrictedExpression::new_string(t.clone()));
attrs.insert(
format!("{role}_sites"),
RestrictedExpression::new_set(sites),
);
}
Ok(Entity::new(principal_uid.clone(), attrs, parents)?)
}
fn resource_entity(required: &Right) -> Result<(EntityUid, Entity), Box<dyn Error>> {
let ty = resource_type(required.resource);
let (id, attrs) = match required.resource {
Resource::Site => {
let name = required.target.clone().unwrap_or_default();
let mut a = HashMap::new();
a.insert(
"name".to_string(),
RestrictedExpression::new_string(name.clone()),
);
(name, a)
}
other => (other.as_str().to_string(), HashMap::new()),
};
let entity_uid = uid(ty, &id);
let entity = Entity::new(entity_uid.clone(), attrs, HashSet::new())?;
Ok((entity_uid, entity))
}
fn action_entities() -> Result<Vec<Entity>, Box<dyn Error>> {
let admin = uid("Action", "admin");
let mut out = Vec::with_capacity(4);
out.push(Entity::new(admin.clone(), HashMap::new(), HashSet::new())?);
for a in ["read", "write", "deploy"] {
let parents = HashSet::from([admin.clone()]);
out.push(Entity::new(uid("Action", a), HashMap::new(), parents)?);
}
Ok(out)
}
fn uid(type_name: &str, id: &str) -> EntityUid {
let tn =
EntityTypeName::from_str(&format!("{NS}::{type_name}")).expect("static Cedar type name");
EntityUid::from_type_name_and_id(tn, EntityId::new(id))
}
fn is_safe_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_types::authz::{RightSet, RightTemplate};
use std::collections::BTreeMap;
fn assert_faithful(policy: &AuthzPolicy, rolesets: &[Vec<GrantedRole>]) {
let cedar = CompiledCedar::compile(policy).expect("compile");
let targets = [None, Some("blog".to_string()), Some("shop".to_string())];
for roles in rolesets {
let expected_set: RightSet = policy.rights_for(roles);
for &resource in &Resource::ALL {
for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
for target in &targets {
let required = Right::new(resource, target.clone(), action);
let oracle = expected_set.allows(&required);
let got = cedar.decide(roles, &required).expect("cedar decide")
== Decision::Allow;
assert_eq!(
got, oracle,
"mismatch: roles={roles:?} required={required:?} (cedar={got} oracle={oracle})"
);
}
}
}
}
}
fn default_rolesets() -> Vec<Vec<GrantedRole>> {
vec![
vec![],
vec![GrantedRole::global("admin")],
vec![GrantedRole::global("operator")],
vec![GrantedRole::global("publisher")],
vec![GrantedRole::global("deployer")],
vec![GrantedRole::global("viewer")],
vec![GrantedRole::scoped("publisher", "blog")],
vec![GrantedRole::scoped("publisher", "shop")],
vec![GrantedRole::scoped("deployer", "blog")],
vec![GrantedRole::scoped("viewer", "blog")],
vec![
GrantedRole::scoped("publisher", "blog"),
GrantedRole::scoped("publisher", "shop"),
],
vec![
GrantedRole::scoped("publisher", "blog"),
GrantedRole::scoped("viewer", "shop"),
],
vec![
GrantedRole::scoped("deployer", "blog"),
GrantedRole::global("operator"),
],
vec![GrantedRole::global("ghost")],
vec![
GrantedRole::global("ghost"),
GrantedRole::scoped("viewer", "blog"),
],
]
}
#[test]
fn cedar_matches_oracle_default_policy() {
assert_faithful(&AuthzPolicy::default_policy(), &default_rolesets());
}
#[test]
fn cedar_matches_oracle_custom_policy() {
let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
roles.insert(
"editor".to_string(),
vec![
RightTemplate::scoped(Resource::Site, Action::Read),
RightTemplate::scoped(Resource::Site, Action::Write),
],
);
roles.insert(
"auditor".to_string(),
vec![
RightTemplate::any(Resource::System, Action::Read),
RightTemplate::any(Resource::Tokens, Action::Read),
],
);
roles.insert(
"sitelead".to_string(),
vec![
RightTemplate::scoped(Resource::Site, Action::Admin),
RightTemplate::any(Resource::Certs, Action::Admin),
],
);
let policy = AuthzPolicy {
version: boatramp_types::SCHEMA_VERSION,
roles,
};
let rolesets = vec![
vec![],
vec![GrantedRole::scoped("editor", "blog")],
vec![GrantedRole::global("editor")], vec![GrantedRole::global("auditor")],
vec![GrantedRole::scoped("sitelead", "blog")],
vec![GrantedRole::global("sitelead")], vec![
GrantedRole::scoped("editor", "blog"),
GrantedRole::global("auditor"),
],
vec![
GrantedRole::scoped("sitelead", "shop"),
GrantedRole::scoped("editor", "blog"),
],
];
assert_faithful(&policy, &rolesets);
}
#[test]
fn compile_rejects_unsafe_role_name() {
for bad in [
"has space",
"quote\"inject",
"dash-role",
"dot.role",
"",
"1leading",
] {
let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
roles.insert(
bad.to_string(),
vec![RightTemplate::any(Resource::System, Action::Read)],
);
let policy = AuthzPolicy {
version: boatramp_types::SCHEMA_VERSION,
roles,
};
assert!(
matches!(
CompiledCedar::compile(&policy),
Err(CedarError::RoleName(_))
),
"expected rejection of role name {bad:?}"
);
}
}
#[test]
fn admin_is_superuser_across_actions() {
let cedar = CompiledCedar::compile(&AuthzPolicy::default_policy()).unwrap();
let admin = [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-site".to_string());
let required = Right::new(resource, target, action);
assert!(
cedar.authorize(&admin, &required),
"admin denied {required:?}"
);
}
}
}
#[test]
fn publisher_scoped_to_its_site_only() {
let cedar = CompiledCedar::compile(&AuthzPolicy::default_policy()).unwrap();
let roles = [GrantedRole::scoped("publisher", "blog")];
assert!(cedar.authorize(
&roles,
&Right::new(Resource::Site, Some("blog".into()), Action::Write)
));
assert!(!cedar.authorize(
&roles,
&Right::new(Resource::Site, Some("shop".into()), Action::Write)
));
assert!(cedar.authorize(&roles, &Right::new(Resource::Blobs, None, Action::Deploy)));
assert!(!cedar.authorize(&roles, &Right::new(Resource::System, None, Action::Admin)));
}
}