575 lines
26 KiB
Rust
575 lines
26 KiB
Rust
//! The one place a request is allowed or refused.
|
|||
|
|
//!
|
||
|
|
//! Content already says who may do what: `qualifies` on a page,
|
||
|
|
//! `requires_group`/`public` on a resource, the transitions a desk
|
||
|
|
//! offers, a `self_transition` a submitter may fire with their own
|
||
|
|
//! link, an `invite` a group may extend. Until now those were five
|
||
|
|
//! checks in five files. Here they are compiled, on every content
|
||
|
|
//! load, into one Cedar policy set, and every server entry point asks
|
||
|
|
//! the same question: may this principal take this action on this
|
||
|
|
//! resource? The answer carries the ids of the rules that granted it,
|
||
|
|
//! and every decision that matters is published on NATS.
|
||
|
|
//!
|
||
|
|
//! The policy text is derived, never hand-written: content is the
|
||
|
|
//! source, this module is the compiler, iris prints the result as a
|
||
|
|
//! matrix and validates it against the schema.
|
||
|
|
#![cfg(feature = "ssr")]
|
||
|
|
|
||
|
|
use std::collections::{HashMap, HashSet};
|
||
|
|
use std::str::FromStr;
|
||
|
|
|
||
|
|
use cedar_policy::{
|
||
|
|
Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
|
||
|
|
RestrictedExpression, Schema, ValidationMode, Validator,
|
||
|
|
};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
|
||
|
|
use crate::aggregates::AggregateSchema;
|
||
|
|
use crate::content::{path_matches, Question};
|
||
|
|
|
||
|
|
/// What every entity and action may look like. The generated policies
|
||
|
|
/// are validated against this, so a content shape the compiler does
|
||
|
|
/// not know how to express fails loudly instead of granting nothing.
|
||
|
|
pub const SCHEMA: &str = r#"
|
||
|
|
entity Group;
|
||
|
|
entity User in [Group];
|
||
|
|
entity Submitter { email: String };
|
||
|
|
entity Service;
|
||
|
|
entity Page;
|
||
|
|
entity Bucket;
|
||
|
|
entity Feature;
|
||
|
|
action open, submit appliesTo { principal: [User], resource: [Page] };
|
||
|
|
action read appliesTo { principal: [User], resource: [Feature] };
|
||
|
|
action transition appliesTo { principal: [User], resource: [Bucket], context: { from: String, to: String } };
|
||
|
|
action self_transition appliesTo { principal: [Submitter], resource: [Bucket], context: { from: String, to: String, stored_email: String } };
|
||
|
|
action automation_read appliesTo { principal: [Service], resource: [Bucket] };
|
||
|
|
action invite appliesTo { principal: [User], resource: [Group] };
|
||
|
|
"#;
|
||
|
|
|
||
|
|
/// Who is asking.
|
||
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||
|
|
pub enum Principal {
|
||
|
|
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
|
||
|
|
User { sub: String, groups: Vec<String> },
|
||
|
|
/// Whoever holds one record's own link and knows the email on it.
|
||
|
|
Submitter { record: String, email: String },
|
||
|
|
/// A machine with a shared token.
|
||
|
|
Service { name: String },
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Principal {
|
||
|
|
pub fn anonymous() -> Self {
|
||
|
|
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||
|
|
match user {
|
||
|
|
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
|
||
|
|
None => Principal::anonymous(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "snake_case")]
|
||
|
|
pub enum Action {
|
||
|
|
Open,
|
||
|
|
Submit,
|
||
|
|
Read,
|
||
|
|
Transition,
|
||
|
|
SelfTransition,
|
||
|
|
AutomationRead,
|
||
|
|
Invite,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Action {
|
||
|
|
fn name(self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Action::Open => "open",
|
||
|
|
Action::Submit => "submit",
|
||
|
|
Action::Read => "read",
|
||
|
|
Action::Transition => "transition",
|
||
|
|
Action::SelfTransition => "self_transition",
|
||
|
|
Action::AutomationRead => "automation_read",
|
||
|
|
Action::Invite => "invite",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||
|
|
pub enum Resource {
|
||
|
|
Page { id: String },
|
||
|
|
Bucket { name: String },
|
||
|
|
/// One resource block on one alternative of one page. `feature` is
|
||
|
|
/// the feature name, or `feature|requirement` (`|bind` appended)
|
||
|
|
/// for a requirement's own resource.
|
||
|
|
Feature { page: String, alternative: String, feature: String },
|
||
|
|
Group { name: String },
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
|
|
pub struct Decision {
|
||
|
|
pub allow: bool,
|
||
|
|
/// The ids of the rules that granted it (empty on a refusal).
|
||
|
|
pub reasons: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// One row of the matrix iris prints: who may do what to what.
|
||
|
|
#[derive(Clone, Debug, Serialize)]
|
||
|
|
pub struct Rule {
|
||
|
|
pub id: String,
|
||
|
|
pub who: String,
|
||
|
|
pub action: String,
|
||
|
|
pub resource: String,
|
||
|
|
pub when: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct Policy {
|
||
|
|
policies: PolicySet,
|
||
|
|
schema: Schema,
|
||
|
|
pub rules: Vec<Rule>,
|
||
|
|
pub text: String,
|
||
|
|
/// The dynamic pages' pattern ids, so a concrete `/shape/abc`
|
||
|
|
/// resolves to the policy written for `/shape/[key]`.
|
||
|
|
patterns: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn quote(s: &str) -> String {
|
||
|
|
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Policy {
|
||
|
|
/// Compiles content into policy. Infallible on valid content: every
|
||
|
|
/// rule below is a shape the schema declares; the validation in
|
||
|
|
/// `validate` is what iris runs to prove that stays true.
|
||
|
|
pub fn from_content(
|
||
|
|
questions: &HashMap<String, Question>,
|
||
|
|
aggregates: &HashMap<String, AggregateSchema>,
|
||
|
|
) -> Self {
|
||
|
|
let mut text = String::new();
|
||
|
|
let mut rules = Vec::new();
|
||
|
|
let mut ids: HashMap<String, usize> = HashMap::new();
|
||
|
|
// Each rule is parsed on its own under its own id, so a
|
||
|
|
// decision's reasons name rules, not `policy17`.
|
||
|
|
let mut sources: Vec<(String, String)> = Vec::new();
|
||
|
|
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
|
||
|
|
let n = ids.entry(id_base.clone()).or_insert(0);
|
||
|
|
*n += 1;
|
||
|
|
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
|
||
|
|
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
|
||
|
|
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
|
||
|
|
sources.push((id, policy));
|
||
|
|
};
|
||
|
|
|
||
|
|
let mut pages: Vec<&Question> = questions.values().collect();
|
||
|
|
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||
|
|
for q in &pages {
|
||
|
|
let page = quote(&q.id);
|
||
|
|
let (who, principal) = match &q.qualifies {
|
||
|
|
None => ("anyone".to_string(), "principal".to_string()),
|
||
|
|
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||
|
|
};
|
||
|
|
emit(
|
||
|
|
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
|
||
|
|
format!("permit({principal}, action in [Action::\"open\", Action::\"submit\"], resource == Page::{page});"),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
for alt in &q.alternatives {
|
||
|
|
// A feature's own resource, and the resources its
|
||
|
|
// requirements read (select options, bound fields):
|
||
|
|
// each is a Feature entity of its own.
|
||
|
|
let mut specs: Vec<(String, &crate::content::ResourceSpec)> = Vec::new();
|
||
|
|
for feature in &alt.features {
|
||
|
|
if let Some(spec) = &feature.resource {
|
||
|
|
specs.push((format!("{}|{}|{}", q.id, alt.name, feature.name), spec));
|
||
|
|
}
|
||
|
|
for req in &feature.requirements {
|
||
|
|
if let Some(spec) = &req.resource {
|
||
|
|
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
|
||
|
|
}
|
||
|
|
if let Some(bind) = &req.bind {
|
||
|
|
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (fid, spec) in specs {
|
||
|
|
let (who, principal) = match (&spec.requires_group, spec.public) {
|
||
|
|
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||
|
|
(None, true) => ("anyone".to_string(), "principal".to_string()),
|
||
|
|
// Neither: unreachable by design (fail closed).
|
||
|
|
(None, false) => continue,
|
||
|
|
};
|
||
|
|
emit(
|
||
|
|
format!("read:{fid}"), who.clone(), "read", format!("feature {fid}"), String::new(),
|
||
|
|
format!("permit({principal}, action == Action::\"read\", resource == Feature::{});", quote(&fid)),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
if let (Some(bucket), Some(g)) = (spec.bucket(), &spec.requires_group) {
|
||
|
|
for t in &spec.transitions {
|
||
|
|
emit(
|
||
|
|
format!("move:{bucket}:{}>{}", t.from, t.to), format!("group {g}"), "transition",
|
||
|
|
format!("bucket {bucket}"), format!("{} -> {}", t.from, t.to),
|
||
|
|
format!(
|
||
|
|
"permit(principal in Group::{}, action == Action::\"transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} }};",
|
||
|
|
quote(g), quote(bucket), quote(&t.from), quote(&t.to)
|
||
|
|
),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(st) = &alt.self_transition {
|
||
|
|
let from = crate::answers::OPEN_STATE;
|
||
|
|
emit(
|
||
|
|
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||
|
|
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
||
|
|
format!(
|
||
|
|
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
|
||
|
|
quote(&st.bucket), quote(from), quote(&st.to)
|
||
|
|
),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if let (Some(invite), Some(g)) = (&alt.invite, &q.qualifies) {
|
||
|
|
emit(
|
||
|
|
format!("invite:{}", invite.group), format!("group {g}"), "invite", format!("group {}", invite.group), String::new(),
|
||
|
|
format!("permit(principal in Group::{}, action == Action::\"invite\", resource == Group::{});", quote(g), quote(&invite.group)),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// The one machine principal: the automation read endpoint, which
|
||
|
|
// authenticates by shared token and may read any bucket.
|
||
|
|
let mut buckets: Vec<&String> = aggregates.keys().collect();
|
||
|
|
buckets.sort();
|
||
|
|
emit(
|
||
|
|
"automation".to_string(), "service automation".to_string(), "automation_read", "any bucket".to_string(), String::new(),
|
||
|
|
"permit(principal == Service::\"automation\", action == Action::\"automation_read\", resource);".to_string(),
|
||
|
|
&mut text, &mut rules,
|
||
|
|
);
|
||
|
|
let _ = buckets;
|
||
|
|
|
||
|
|
let mut policies = PolicySet::new();
|
||
|
|
for (id, source) in &sources {
|
||
|
|
let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), source)
|
||
|
|
.expect("generated policy text always parses");
|
||
|
|
policies.add(policy).expect("rule ids are unique");
|
||
|
|
}
|
||
|
|
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
|
||
|
|
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
|
||
|
|
Policy { policies, schema, rules, text, patterns }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every generated rule type-checks against the schema. A rule the
|
||
|
|
/// schema cannot express is a compiler bug, and this is where it
|
||
|
|
/// shows.
|
||
|
|
pub fn validate(&self) -> Result<(), String> {
|
||
|
|
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
||
|
|
if result.validation_passed() {
|
||
|
|
Ok(())
|
||
|
|
} else {
|
||
|
|
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn page_entity(&self, id: &str) -> String {
|
||
|
|
if self.patterns.iter().any(|p| p == id) {
|
||
|
|
return id.to_string();
|
||
|
|
}
|
||
|
|
self.patterns
|
||
|
|
.iter()
|
||
|
|
.find(|p| path_matches(p, id).is_some())
|
||
|
|
.cloned()
|
||
|
|
.unwrap_or_else(|| id.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The decision. `context` holds what the action needs: `from`/`to`
|
||
|
|
/// for a transition, plus `stored_email` for a self transition.
|
||
|
|
pub fn decide(
|
||
|
|
&self,
|
||
|
|
principal: &Principal,
|
||
|
|
action: Action,
|
||
|
|
resource: &Resource,
|
||
|
|
context: serde_json::Value,
|
||
|
|
) -> Decision {
|
||
|
|
let uid = |ty: &str, id: &str| {
|
||
|
|
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
|
||
|
|
};
|
||
|
|
let mut entities: Vec<Entity> = Vec::new();
|
||
|
|
let principal_uid = match principal {
|
||
|
|
Principal::User { sub, groups } => {
|
||
|
|
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
|
||
|
|
for g in &parents {
|
||
|
|
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
|
||
|
|
}
|
||
|
|
let me = uid("User", sub);
|
||
|
|
entities.push(Entity::new_no_attrs(me.clone(), parents));
|
||
|
|
me
|
||
|
|
}
|
||
|
|
Principal::Submitter { record, email } => {
|
||
|
|
let me = uid("Submitter", record);
|
||
|
|
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
|
||
|
|
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
|
||
|
|
me
|
||
|
|
}
|
||
|
|
Principal::Service { name } => {
|
||
|
|
let me = uid("Service", name);
|
||
|
|
entities.push(Entity::new_no_attrs(me.clone(), HashSet::new()));
|
||
|
|
me
|
||
|
|
}
|
||
|
|
};
|
||
|
|
let resource_uid = match resource {
|
||
|
|
Resource::Page { id } => uid("Page", &self.page_entity(id)),
|
||
|
|
Resource::Bucket { name } => uid("Bucket", name),
|
||
|
|
Resource::Feature { page, alternative, feature } => {
|
||
|
|
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
|
||
|
|
}
|
||
|
|
Resource::Group { name } => uid("Group", name),
|
||
|
|
};
|
||
|
|
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
|
||
|
|
let action_uid = uid("Action", action.name());
|
||
|
|
|
||
|
|
let Ok(entities) = Entities::from_entities(entities, None) else {
|
||
|
|
return Decision::default();
|
||
|
|
};
|
||
|
|
// An absent context is an empty one, not a malformed one.
|
||
|
|
let context = if context.is_object() { context } else { serde_json::json!({}) };
|
||
|
|
let Ok(context) = Context::from_json_value(context, None) else {
|
||
|
|
return Decision::default();
|
||
|
|
};
|
||
|
|
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
|
||
|
|
return Decision::default();
|
||
|
|
};
|
||
|
|
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
|
||
|
|
Decision {
|
||
|
|
allow: response.decision() == cedar_policy::Decision::Allow,
|
||
|
|
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
|
||
|
|
self.decide(principal, action, resource, serde_json::json!({})).allow
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// What gets published on `portal.access.decided`: the question and
|
||
|
|
/// the answer. Page opens and reads are published only when refused;
|
||
|
|
/// every mutation is published either way.
|
||
|
|
#[derive(Clone, Debug, Serialize)]
|
||
|
|
pub struct Decided<'a> {
|
||
|
|
pub principal: &'a Principal,
|
||
|
|
pub action: Action,
|
||
|
|
pub resource: &'a Resource,
|
||
|
|
pub context: &'a serde_json::Value,
|
||
|
|
pub allow: bool,
|
||
|
|
pub reasons: &'a [String],
|
||
|
|
pub at_ms: i64,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub const DECIDED_SUBJECT: &str = "portal.access.decided";
|
||
|
|
|
||
|
|
pub fn worth_publishing(action: Action, allow: bool) -> bool {
|
||
|
|
!allow || !matches!(action, Action::Open | Action::Read)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Decides and, when it matters, publishes. Fire and forget: a NATS
|
||
|
|
/// hiccup never changes an answer.
|
||
|
|
pub fn decide_and_publish(
|
||
|
|
nats: &async_nats::Client,
|
||
|
|
policy: &Policy,
|
||
|
|
principal: &Principal,
|
||
|
|
action: Action,
|
||
|
|
resource: &Resource,
|
||
|
|
context: serde_json::Value,
|
||
|
|
) -> Decision {
|
||
|
|
let decision = policy.decide(principal, action, resource, context.clone());
|
||
|
|
if worth_publishing(action, decision.allow) {
|
||
|
|
let event = Decided {
|
||
|
|
principal,
|
||
|
|
action,
|
||
|
|
resource,
|
||
|
|
context: &context,
|
||
|
|
allow: decision.allow,
|
||
|
|
reasons: &decision.reasons,
|
||
|
|
at_ms: chrono::Utc::now().timestamp_millis(),
|
||
|
|
};
|
||
|
|
if let Ok(bytes) = serde_json::to_vec(&event) {
|
||
|
|
let nats = nats.clone();
|
||
|
|
tokio::spawn(async move {
|
||
|
|
let _ = nats.publish(DECIDED_SUBJECT, bytes.into()).await;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
decision
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The request-reply face for other apps: a JSON question in, a
|
||
|
|
/// decision out. Same policy, same answer as the site gives itself.
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
pub struct Query {
|
||
|
|
pub principal: Principal,
|
||
|
|
pub action: Action,
|
||
|
|
pub resource: Resource,
|
||
|
|
#[serde(default)]
|
||
|
|
pub context: serde_json::Value,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn subject_for(site: &str) -> String {
|
||
|
|
format!("portal.access.{site}.may")
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn policy_subject_for(site: &str) -> String {
|
||
|
|
format!("portal.access.{site}.policy")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Answers `portal.access.<site>.may` for as long as the process runs,
|
||
|
|
/// with the policy as it is at the moment of each question (it is
|
||
|
|
/// swapped on content reload). A malformed question gets a refusal
|
||
|
|
/// with the parse error as its reason, never silence.
|
||
|
|
pub async fn serve(
|
||
|
|
nats: async_nats::Client,
|
||
|
|
site: String,
|
||
|
|
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
|
||
|
|
) {
|
||
|
|
use futures::StreamExt;
|
||
|
|
let subject = subject_for(&site);
|
||
|
|
let mut sub = match nats.subscribe(subject.clone()).await {
|
||
|
|
Ok(sub) => sub,
|
||
|
|
Err(e) => {
|
||
|
|
tracing::error!(error = %e, %subject, "cannot serve access decisions");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
tracing::info!(%subject, "answering access questions");
|
||
|
|
while let Some(msg) = sub.next().await {
|
||
|
|
let Some(reply) = msg.reply else { continue };
|
||
|
|
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
|
||
|
|
Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
|
||
|
|
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
||
|
|
};
|
||
|
|
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||
|
|
let _ = nats.publish(reply, bytes.into()).await;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use crate::aggregates::parse_aggregates_yaml;
|
||
|
|
use crate::content::build_questions;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
fn site() -> Policy {
|
||
|
|
let files = vec![
|
||
|
|
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: Apply\n action: /applied\n record_as: applicants\n - name: Unsubscribe\n self_transition: { bucket: subscribers, to: unsubscribed, label: Go }\n".to_string()),
|
||
|
|
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||
|
|
("shape/[key].yaml".to_string(), "name: Shape\n".to_string()),
|
||
|
|
("review/_section.yaml".to_string(), "qualifies: owners\n".to_string()),
|
||
|
|
("review/index.yaml".to_string(), "name: Review\nalternatives:\n - name: Applicants\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: applicants }\n requires_group: owners\n transitions:\n - { to: invited, label: Invite }\n - { from: invited, to: active, label: Onboard }\n - name: Releases\n resource:\n public: true\n source: { kind: gitea_releases, owner: o, repo: r }\n - name: Invite a colleague\n invite: { group: owners }\n features:\n - name: \"\"\n requirements:\n - { name: name }\n - { name: email, type: email }\n".to_string()),
|
||
|
|
];
|
||
|
|
let questions = build_questions(&files).unwrap();
|
||
|
|
let aggregates = parse_aggregates_yaml("aggregates:\n - bucket: applicants\n initial: open\n states:\n open: { event: a }\n invited: { event: b }\n active: { event: c }\n transitions:\n open: [invited]\n invited: [active]\n").unwrap();
|
||
|
|
Policy::from_content(&questions, &aggregates)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn user(groups: &[&str]) -> Principal {
|
||
|
|
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn the_generated_policy_validates_against_the_schema() {
|
||
|
|
let p = site();
|
||
|
|
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||
|
|
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn pages_open_to_anyone_unless_qualified() {
|
||
|
|
let p = site();
|
||
|
|
let page = |id: &str| Resource::Page { id: id.into() };
|
||
|
|
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
|
||
|
|
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
|
||
|
|
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
|
||
|
|
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
|
||
|
|
assert!(d.allow);
|
||
|
|
assert_eq!(d.reasons, vec!["open:/review"]);
|
||
|
|
// A concrete dynamic path resolves to its pattern's rule.
|
||
|
|
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/shape/abc")));
|
||
|
|
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/nowhere")));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn reads_follow_requires_group_or_public() {
|
||
|
|
let p = site();
|
||
|
|
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
|
||
|
|
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
|
||
|
|
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
|
||
|
|
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
|
||
|
|
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn transitions_are_exactly_the_desks_buttons() {
|
||
|
|
let p = site();
|
||
|
|
let bucket = Resource::Bucket { name: "applicants".into() };
|
||
|
|
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
|
||
|
|
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||
|
|
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
|
||
|
|
// Right target, wrong starting state: no rule.
|
||
|
|
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
|
||
|
|
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
|
||
|
|
let p = site();
|
||
|
|
let bucket = Resource::Bucket { name: "subscribers".into() };
|
||
|
|
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
||
|
|
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
||
|
|
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||
|
|
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
|
||
|
|
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
|
||
|
|
// A signed-in owner has no such rule: the link is the credential.
|
||
|
|
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn invites_and_automation() {
|
||
|
|
let p = site();
|
||
|
|
let owners = Resource::Group { name: "owners".into() };
|
||
|
|
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
|
||
|
|
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
|
||
|
|
let bucket = Resource::Bucket { name: "applicants".into() };
|
||
|
|
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
|
||
|
|
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
|
||
|
|
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_question_over_nats_is_the_same_question() {
|
||
|
|
let p = site();
|
||
|
|
let q: Query = serde_json::from_str(
|
||
|
|
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||
|
|
let q: Query = serde_json::from_str(
|
||
|
|
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||
|
|
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn only_refusals_and_mutations_are_worth_publishing() {
|
||
|
|
assert!(!worth_publishing(Action::Open, true));
|
||
|
|
assert!(worth_publishing(Action::Open, false));
|
||
|
|
assert!(worth_publishing(Action::Transition, true));
|
||
|
|
assert!(!worth_publishing(Action::Read, true));
|
||
|
|
}
|
||
|
|
}
|