//! 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"). /// Over NATS, a question may name the person by phone number or /// address instead of listing their groups, and portal looks their /// memberships up (see `serve`). User { sub: String, groups: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] phone: Option, #[serde(default, skip_serializing_if = "Option::is_none")] email: Option, }, /// 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(), phone: None, email: None } } pub fn from_user(user: Option<&crate::auth::User>) -> Self { match user { Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.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, } /// 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, pub text: String, /// The dynamic pages' pattern ids, so a concrete `/shape/abc` /// resolves to the policy written for `/shape/[key]`. patterns: Vec, } 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, aggregates: &HashMap, ) -> Self { let mut text = String::new(); let mut rules = Vec::new(); let mut ids: HashMap = 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| { 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 { // One rule per state the move may be made from: a // person stays in dialogue with their case for as // long as it runs, and `from` says how long that // is. Saying nothing means the bucket's own // initial state - the bucket's, not the word // `open`, since a bucket may start anywhere. let initial = aggregates .get(&st.bucket) .map(|s| s.initial.clone()) .unwrap_or_else(|| crate::answers::OPEN_STATE.to_string()); for from in &st.from_states(&initial) { 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::>().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 = Vec::new(); let principal_uid = match principal { Principal::User { sub, groups, .. } => { let parents: HashSet = 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..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>, state: Option, ) { 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::(&msg.payload) { Ok(mut q) => { // A question that names a person and lists no groups is // asking portal what they belong to: their memberships, // matched on subject, phone number or address. if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) { if groups.is_empty() && sub != "anonymous" { let who = crate::memberships::Identity { sub: Some(sub.clone()), phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))), email: email.as_deref().map(|e| e.trim().to_lowercase()), }; *groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who); } } 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(), phone: None, email: None } } #[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); } // The applicant written to for a missing drawing sends it from // `incomplete`, and may withdraw at any point up to the signature. // Before `from`, every one of those moves was compiled as if it // could only be made on the day the case arrived. #[test] fn a_submitter_stays_in_dialogue_for_as_long_as_the_case_runs() { let files = vec![( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: Apply\n record_as: cases\n - name: Send the papers\n self_transition: { bucket: cases, to: complete, from: [open, incomplete], label: Send }\n - name: Withdraw\n self_transition: { bucket: cases, to: withdrawn, from: [open, incomplete, complete], label: Withdraw }\n".to_string(), )]; let questions = build_questions(&files).unwrap(); let aggregates = parse_aggregates_yaml( "aggregates:\n - bucket: cases\n initial: open\n states:\n open: { event: a }\n incomplete: { event: b }\n complete: { event: c }\n withdrawn: { event: d }\n transitions:\n open: [incomplete, complete, withdrawn]\n incomplete: [complete, withdrawn]\n complete: [withdrawn]\n", ) .unwrap(); let p = Policy::from_content(&questions, &aggregates); assert_eq!(p.validate(), Ok(()), "{}", p.text); let bucket = Resource::Bucket { name: "cases".into() }; let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() }; let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" }); assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow); assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow); // And only where it was said: the paperwork cannot be sent // after the case is already withdrawn. assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow); assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow); } // Omitted, `from` is the initial state alone - what every // unsubscribe link written before this existed already meant. #[test] fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() { let p = site(); assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed")); assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1); } // "On arrival" is the bucket's own first state, not the word // `open`. redoal's watchers arrive at `pending` and confirm // themselves into `open`; reading the literal made that rule // `open -> open`, which the engine declares nowhere, so the // confirm link in the mail could never have worked. #[test] fn arrival_is_wherever_the_bucket_actually_starts() { let files = vec![( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(), )]; let questions = build_questions(&files).unwrap(); let aggregates = parse_aggregates_yaml( "aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n", ) .unwrap(); let p = Policy::from_content(&questions, &aggregates); assert_eq!(p.validate(), Ok(()), "{}", p.text); assert!( p.rules.iter().any(|r| r.id == "self:watchers:pending>open"), "the rule must start where the records do: {:?}", p.rules.iter().map(|r| &r.id).collect::>() ); let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() }; let bucket = Resource::Bucket { name: "watchers".into() }; assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "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)); } }