From 1d82e5a756f67492085485c9e45ae88f837602c2 Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Sun, 10 Dec 2023 15:22:23 -0500 Subject: [PATCH 1/9] name policies by @id() annotation --- tinytodo/TUTORIAL.md | 36 ++++++++++++----- tinytodo/policies.cedar | 7 ++-- tinytodo/src/context.rs | 90 +++++++++++++++++++++++++++++++++++------ 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/tinytodo/TUTORIAL.md b/tinytodo/TUTORIAL.md index 56d43e6..cde3e82 100644 --- a/tinytodo/TUTORIAL.md +++ b/tinytodo/TUTORIAL.md @@ -41,7 +41,7 @@ We specify and enforce these access permissions using Cedar. Cedar is a language Here is one of TinyTodo’s Cedar policies. ``` -// Policy 1: A User can perform any action on a List they own +// Policy 1: A User can perform any action on a List they own permit(principal, action, resource) when { resource is List && resource.owner == principal @@ -363,6 +363,7 @@ Let’s see how we can extend the Cedar policies to effect permission changes to ``` // Policy 4: Admins can perform any action on any resource +@id("admin-omnipotence") permit ( principal in Team::"admin", action, @@ -370,28 +371,45 @@ permit ( ); ``` -As per Figure 2, user ``emina`` is a member of ``Team::"admin"`` so if we start TinyTodo with this new policy added to `policies.cedar` we can see ``emina`` is able to view and edit any list, even without it being explicitly shared. +In this policy we have used a [Cedar policy _annotation_](https://docs.cedarpolicy.com/policies/syntax-policy.html#term-parc-annotations) `@id(...)`. Annotations have no built-in semantics in Cedar, so it is up to the application to decide how to use them. The [Cedar command-line interface (CLI)](https://github.com/cedar-policy/cedar/tree/main/cedar-policy-cli) uses the `@id(...)` annotation to give a name to the target policy, and we adopt the same convention for TinyTodo. By default, policies are named `policy`_N_ where _N_ is a number starting at 0 that increases with each policy added to the policy set. We adopt code from the CLI that post-processes parsed policies and renames them according to any present `@id` annotation. This name will be used in an authorization's response diagnostics, as we will see below. + +Per Figure 2, user ``emina`` is a member of ``Team::"admin"`` so if we start TinyTodo with this new policy added to `policies.cedar`, ``emina`` is able to view and edit any list, even without it being explicitly shared. We see this in the transcript below, which shows TinyTodo run with output logging enabled via the environment variable `RUST_LOG` to level `info`. The log messages show the requests sent to Cedar's authorization engine and the responses it sends back. (We have elided some logging messages and simplified others, for readability.) ```shell -> python -i tinytodo.py +> RUST_LOG=info python -i tinytodo.py >>> start_server() -=== TinyTodo started on port 8080 +TinyTodo server started on port 8080 + INFO (messages elided ...) + >>> set_user(andrew) User is now andrew >>> create_list("Cedar blog post") + INFO tiny_todo_server::context: is_authorized request: principal: User::"andrew", action: Action::"CreateList", resource: Application::"TinyTodo" + + INFO tiny_todo_server::context: Auth response: Response { decision: Allow, diagnostics: Diagnostics { reason: {PolicyId(PolicyID("policy 0")), PolicyId(PolicyID("admin-omnipotence"))}, errors: [] } } + Created list ID 0 >>> set_user(emina) User is now emina ->>> get_list(0) +>>> get_list(0) + INFO tiny_todo_server::context: is_authorized request: principal: User::"emina", action: Action::"GetList", resource: List::"0" + + INFO tiny_todo_server::context: Auth response: Response { decision: Allow, diagnostics: Diagnostics { reason: {PolicyId(PolicyID("admin-omnipotence"))}, errors: [] } } + === Cedar blog post === List ID: 0 Owner: User::"andrew" Tasks: >>> delete_list(0) + INFO tiny_todo_server::context: is_authorized request: principal: User::"emina", action: Action::"DeleteList", resource: List::"0" + + INFO tiny_todo_server::context: Auth response: Response { decision: Allow, diagnostics: Diagnostics { reason: {PolicyId(PolicyID("admin-omnipotence"))}, errors: [] } } + List Deleted >>> stop_server() TinyTodo server stopped on port 8080 ``` +Notice in the `INFO` messages that Andrew's creation of the list is authorized by `PolicyID` `"policy0"`, the default ID for the first policy, whereas Emina's reading and updating of the list is authorized by `PolicyID` `"admin-omnipotence"`, the name given via `@id` annotation to our newly-added policy. As another extension, we can add this policy: @@ -404,8 +422,7 @@ forbid ( ); ``` -This policy states that any principal who is an intern (in `Team::"interns"`) is *forbidden* from creating a new -task list (`Action::"CreateList"`) using TinyTodo (`Application::"TinyTodo"`). In Cedar, `forbid` policies always take precedence over `permit` policies, i.e., even if a `permit` policy might authorize a request, a `forbid` policy can override that authorization. For TinyTodo, *policy 0* permits *any* user to create a task list, but *policy 5* overrides that permission for users in `Team::"interns"`. Note that the order of Cedar policies doesn’t affect this behavior. +This policy states that any principal who is an intern (in `Team::"interns"`) is *forbidden* from creating a new task list (`Action::"CreateList"`) using TinyTodo (`Application::"TinyTodo"`). In Cedar, `forbid` policies always take precedence over `permit` policies, i.e., even if a `permit` policy might authorize a request, a `forbid` policy can override that authorization. For TinyTodo, *policy 0* permits *any* user to create a task list, but *policy 5* overrides that permission for users in `Team::"interns"`. Note that the order of Cedar policies doesn’t affect this behavior. As per Figure 2, user ``aaron`` is a member of ``Team::"interns"`` so if we start TinyTodo with this new policy added to `policies.cedar` we can see ``aaron`` is not able to create a task list. @@ -427,7 +444,7 @@ TinyTodo server stopped on port 8080 ## Validating Cedar policies -When the `AppContext::spawn(...)` method reads in the Cedar policies, it also reads in a Cedar *schema* against which it validates the policies. After reading in the `schema` from the file `tinytodo.cedarschema.json`, the method creates a `Validator` object (from the `cedar-policy-validator` package) and invokes its `validator.validate` method. Here is the `spawn` method, where you can see the code for this. +When the `AppContext::spawn(...)` method reads in the Cedar policies (and renames them according to their `@id` annotations, if present), it also reads in a Cedar *schema* against which it validates the policies. After reading in the `schema` from the file `tinytodo.cedarschema.json`, the method creates a `Validator` object (from the `cedar-policy-validator` package) and invokes its `validator.validate` method. Here is the `spawn` method, where you can see the code for this. ```rust pub fn spawn( @@ -440,7 +457,8 @@ pub fn spawn( let schema = Schema::from_file(schema_file)?; ... let policy_src = std::fs::read_to_string(&policies_path)?; - let policies = policy_src.parse()?; + let policies0 = policy_src.parse()?; + let policies = rename_from_id_annotation(policies0)?; let validator = Validator::new(schema.clone()); let output = validator.validate(&policies, ValidationMode::default()); if output.validation_passed() { diff --git a/tinytodo/policies.cedar b/tinytodo/policies.cedar index a0e8db2..373d0b2 100644 --- a/tinytodo/policies.cedar +++ b/tinytodo/policies.cedar @@ -30,10 +30,11 @@ permit ( when { principal in resource.editors }; // Policy 4: Admins can perform any action on any resource +// @id("admin-omnipotence") // permit ( -// principal in Team::"admin", -// action, -// resource in Application::"TinyTodo" +// principal in Team::"admin", +// action, +// resource in Application::"TinyTodo" // ); // // Policy 5: Interns may not create new task lists diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index 5f6277a..cb95e97 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -21,7 +21,7 @@ use tracing::{info, trace}; use cedar_policy::{ Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicySet, Request, - Schema, SchemaError, ValidationMode, Validator, + Schema, SchemaError, ValidationMode, Validator, PolicySetError }; use thiserror::Error; use tokio::sync::{ @@ -148,6 +148,22 @@ impl AppQuery { type Result = std::result::Result; +#[derive(Debug, Error)] +pub enum ContextError { + #[error("{0}")] + IO(#[from] std::io::Error), + #[error("Error Parsing Schema: {0}")] + Schema(#[from] SchemaError), + #[error("Error Parsing PolicySet: {0}")] + Policy(#[from] ParseErrors), + #[error("Error Processing PolicySet: {0}")] + PolicySet(#[from] PolicySetError), + #[error("Validation Failed: {0}")] + Validation(String), + #[error("Error Deserializing Json: {0}")] + Json(#[from] serde_json::Error), +} + #[derive(Debug, Error)] pub enum Error { #[error("No Such Entity: {0}")] @@ -168,6 +184,8 @@ pub enum Error { IO(#[from] std::io::Error), #[error("Error Parsing PolicySet: {0}")] Policy(#[from] ParseErrors), + #[error("Error updating PolicySet: {0}")] + PolicySet(#[from] PolicySetError), #[error("Error constructing authorization request: {0}")] Request(String), } @@ -206,17 +224,61 @@ impl std::fmt::Debug for AppContext { } #[derive(Debug, Error)] -pub enum ContextError { +enum ReadError { #[error("{0}")] - IO(#[from] std::io::Error), - #[error("Error Parsing Schema: {0}")] - Schema(#[from] SchemaError), - #[error("Error Parsing PolicySet: {0}")] - Policy(#[from] ParseErrors), - #[error("Validation Failed: {0}")] - Validation(String), - #[error("Error Deserializing Json: {0}")] - Json(#[from] serde_json::Error), + Parse(#[from] ParseErrors), + #[error("{0}")] + Semantics(#[from] PolicySetError) +} + +impl From for ContextError { + fn from(error: ReadError) -> Self { + match error { + ReadError::Parse(e) => ContextError::Policy(e), + ReadError::Semantics(e) => ContextError::PolicySet(e) + } + } +} + +impl From for Error { + fn from(error: ReadError) -> Self { + match error { + ReadError::Parse(e) => Error::Policy(e), + ReadError::Semantics(e) => Error::PolicySet(e) + } + } +} +/// Renames policies and templates based on (@id("new_id") annotation. +/// If no such annotation exists, it keeps the current id. +/// +/// This will rename template-linked policies to the id of their template, which may +/// cause id conflicts, so only call this function before linking +/// templates into the policy set. +fn rename_from_id_annotation(ps: PolicySet) -> std::result::Result { + let mut new_ps = PolicySet::new(); + let t_iter = ps.templates().map(|t| match t.annotation("id") { + None => Ok(t.clone()), + Some(anno) => { + //info!("Found template with ID {}!",anno); + anno.parse().map(|a| t.new_id(a)) + } + }); + for t in t_iter { + let template = t?; + new_ps.add_template(template)?; + } + let p_iter = ps.policies().map(|p| match p.annotation("id") { + None => Ok(p.clone()), + Some(anno) => { + //info!("Found policy with ID {}!",anno); + anno.parse().map(|a| p.new_id(a)) + } + }); + for p in p_iter { + let policy = p?; + new_ps.add(policy)?; + } + Ok(new_ps) } impl AppContext { @@ -235,7 +297,8 @@ impl AppContext { let entities = serde_json::from_reader(entities_file)?; let policy_src = std::fs::read_to_string(&policies_path)?; - let policies = policy_src.parse()?; + let policies0 = policy_src.parse()?; + let policies = rename_from_id_annotation(policies0)?; let validator = Validator::new(schema.clone()); let output = validator.validate(&policies, ValidationMode::default()); if output.validation_passed() { @@ -292,7 +355,8 @@ impl AppContext { #[tracing::instrument(skip(policy_set))] fn update_policy_set(&mut self, policy_set: PolicySet) -> Result { - self.policies = policy_set; + let policies = rename_from_id_annotation(policy_set)?; + self.policies = policies; info!("Reloaded policy set"); Ok(AppResponse::Unit(())) } From aa930242a83a17b0d8b817a6f19bd3f561803094 Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Sun, 10 Dec 2023 15:29:51 -0500 Subject: [PATCH 2/9] format --- tinytodo/src/context.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index cb95e97..5e82fef 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -20,8 +20,8 @@ use std::path::PathBuf; use tracing::{info, trace}; use cedar_policy::{ - Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicySet, Request, - Schema, SchemaError, ValidationMode, Validator, PolicySetError + Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicySet, + PolicySetError, Request, Schema, SchemaError, ValidationMode, Validator, }; use thiserror::Error; use tokio::sync::{ @@ -228,14 +228,14 @@ enum ReadError { #[error("{0}")] Parse(#[from] ParseErrors), #[error("{0}")] - Semantics(#[from] PolicySetError) + Semantics(#[from] PolicySetError), } impl From for ContextError { fn from(error: ReadError) -> Self { match error { ReadError::Parse(e) => ContextError::Policy(e), - ReadError::Semantics(e) => ContextError::PolicySet(e) + ReadError::Semantics(e) => ContextError::PolicySet(e), } } } @@ -244,7 +244,7 @@ impl From for Error { fn from(error: ReadError) -> Self { match error { ReadError::Parse(e) => Error::Policy(e), - ReadError::Semantics(e) => Error::PolicySet(e) + ReadError::Semantics(e) => Error::PolicySet(e), } } } @@ -254,7 +254,7 @@ impl From for Error { /// This will rename template-linked policies to the id of their template, which may /// cause id conflicts, so only call this function before linking /// templates into the policy set. -fn rename_from_id_annotation(ps: PolicySet) -> std::result::Result { +fn rename_from_id_annotation(ps: PolicySet) -> std::result::Result { let mut new_ps = PolicySet::new(); let t_iter = ps.templates().map(|t| match t.annotation("id") { None => Ok(t.clone()), From 9cafeb5c91d1550ace95fda975d0c5a2424d7304 Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Mon, 18 Dec 2023 15:58:08 -0500 Subject: [PATCH 3/9] Update TinyTodo to use templates --- tinytodo/policies.cedar | 23 +++--- tinytodo/src/context.rs | 109 ++++++++++++++++++++++++----- tinytodo/src/entitystore.rs | 1 + tinytodo/src/objects.rs | 32 +-------- tinytodo/tinytodo.cedarschema.json | 8 --- tinytodo/tinytodo.py | 2 +- 6 files changed, 109 insertions(+), 66 deletions(-) diff --git a/tinytodo/policies.cedar b/tinytodo/policies.cedar index 373d0b2..1d8640e 100644 --- a/tinytodo/policies.cedar +++ b/tinytodo/policies.cedar @@ -9,25 +9,26 @@ permit ( permit (principal, action, resource) when { resource is List && resource.owner == principal }; -// Policy 2: A User can see a List if they are either a reader or editor +// Policy 2: Users who are members of [?principal] are readers of [?resource] +@id("reader-template") permit ( - principal, + principal in ?principal, action == Action::"GetList", - resource -) -when { principal in resource.readers || principal in resource.editors }; + resource == ?resource +); -// Policy 3: A User can update a List and its tasks if they are an editor +// Policy 3: Users who are members of [?principal] are editors of [?resource] +@id("editor-template") permit ( - principal, + principal in ?principal, action in - [Action::"UpdateList", + [Action::"GetList", + Action::"UpdateList", Action::"CreateTask", Action::"UpdateTask", Action::"DeleteTask"], - resource -) -when { principal in resource.editors }; + resource == ?resource +); // Policy 4: Admins can perform any action on any resource // @id("admin-omnipotence") diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index 5e82fef..08e2ea1 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -16,12 +16,12 @@ use itertools::Itertools; use lazy_static::lazy_static; -use std::path::PathBuf; -use tracing::{info, trace}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; +use tracing::{error, info, trace}; use cedar_policy::{ - Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicySet, - PolicySetError, Request, Schema, SchemaError, ValidationMode, Validator, + Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicyId, PolicySet, + PolicySetError, Request, Schema, SchemaError, SlotId, ValidationMode, Validator, }; use thiserror::Error; use tokio::sync::{ @@ -32,7 +32,7 @@ use tokio::sync::{ use crate::{ api::{ AddShare, CreateList, CreateTask, DeleteList, DeleteShare, DeleteTask, Empty, GetList, - GetLists, UpdateList, UpdateTask, + GetLists, ShareRole, UpdateList, UpdateTask, }, entitystore::{EntityDecodeError, EntityStore}, objects::List, @@ -308,6 +308,7 @@ impl AppContext { let tx = send.clone(); tokio::spawn(async move { info!("Serving application server!"); + // FIXME: probably should pass in [schema] not [schema_path] policy_store::spawn_watcher(policies_path, schema_path, tx).await; let c = Self { entities, @@ -355,27 +356,101 @@ impl AppContext { #[tracing::instrument(skip(policy_set))] fn update_policy_set(&mut self, policy_set: PolicySet) -> Result { - let policies = rename_from_id_annotation(policy_set)?; - self.policies = policies; - info!("Reloaded policy set"); + let mut new_policies = rename_from_id_annotation(policy_set)?; + let mut err = false; + let mut updated = false; + // for each existing template-linked policy, + // link against the new version of the template in the new policy set if present + for p in self.policies.policies() { + match p.template_id() { + None => (), // not a template-linked policy + Some(tid) => { + // template-linked policy + match new_policies.template(tid) { + None => { + // template not in new policy set + let tidx = tid.clone(); + let pidx = p.id().clone(); + err = true; + error!("Error when reloading policies: Could not find policy template {tidx} to link {pidx}") + } + Some(_) => { + // found template in new policy set + match p.template_links() { + None => error!("Error when reloading policies: Template with no matching links"), + Some(vals) => { + // link against new template, using the same policy ID as the old one + updated = true; + new_policies.link(tid.clone(), p.id().clone(), vals)? + } + } + } + } + } + } + } + // no error during relinking + if !err { + // check that re-linked policies validate properly + if updated { + let validator = Validator::new(self.schema.clone()); + let output = validator.validate(&new_policies, ValidationMode::default()); + if !output.validation_passed() { + for e in output.validation_errors() { + error!("Error validating linked policies: {e}") + } + } else { + self.policies = new_policies; + info!("Reloaded policy set") + } + } + }; Ok(AppResponse::Unit(())) } fn add_share(&mut self, r: AddShare) -> Result { self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; - let list = self.entities.get_list(&r.list)?; - let team_uid = list.get_team(r.role).clone(); - let target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; - target_entity.insert_parent(team_uid); + // Confirm that the identified list and sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; + // Link a template to register the new permission + let (tid, pid_prefix) = match r.role { + ShareRole::Reader => (PolicyId::from_str("reader-template")?, "reader"), + ShareRole::Editor => (PolicyId::from_str("editor-template")?, "editor"), + }; + // Construct template linking values + let target_euid: &cedar_policy::EntityUid = r.share_with.as_ref(); + let list_euid: &cedar_policy::EntityUid = r.list.as_ref(); + let env: HashMap = [ + (SlotId::principal(), target_euid.clone()), + (SlotId::resource(), list_euid.clone()), + ] + .into_iter() + .collect(); + // Construct policy ID; assumes no policy in the set has it already + let target_eid = target_euid.id(); + let list_eid = list_euid.id(); + let pid = PolicyId::from_str(&format!("{pid_prefix}[{target_eid}][{list_eid}]"))?; + // Link it! + self.policies.link(tid, pid.clone(), env)?; + info!("Created policy {pid}"); Ok(AppResponse::Unit(())) } fn delete_share(&mut self, r: DeleteShare) -> Result { self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; - let list = self.entities.get_list(&r.list)?; - let team_uid = list.get_team(r.role).clone(); - let target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; - target_entity.delete_parent(&team_uid); + // Confirm that the identified list and un-sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; + // Unlink the policy that provided the permission + let pid_prefix = match r.role { + ShareRole::Reader => "reader", + ShareRole::Editor => "editor", + }; + let target_eid = r.unshare_with.as_ref().id(); + let list_eid = r.list.as_ref().id(); + let pid = PolicyId::from_str(&format!("{pid_prefix}[{target_eid}][{list_eid}]"))?; + self.policies.unlink(pid)?; Ok(AppResponse::Unit(())) } @@ -431,7 +506,7 @@ impl AppContext { .entities .fresh_euid::(TYPE_LIST.clone()) .unwrap(); - let l = List::new(&mut self.entities, euid.clone(), r.uid, r.name); + let l = List::new(euid.clone(), r.uid, r.name); self.entities.insert_list(l); Ok(AppResponse::euid(euid)) diff --git a/tinytodo/src/entitystore.rs b/tinytodo/src/entitystore.rs index 3200bab..616b1b1 100644 --- a/tinytodo/src/entitystore.rs +++ b/tinytodo/src/entitystore.rs @@ -36,6 +36,7 @@ pub struct EntityStore { uid: usize, } +#[allow(dead_code)] impl EntityStore { pub fn euids(&self) -> impl Iterator { self.users diff --git a/tinytodo/src/objects.rs b/tinytodo/src/objects.rs index 1cf0269..7a457b0 100644 --- a/tinytodo/src/objects.rs +++ b/tinytodo/src/objects.rs @@ -20,10 +20,9 @@ use cedar_policy::{Entity, EvalResult, RestrictedExpression}; use serde::{Deserialize, Serialize}; use crate::{ - api::ShareRole, context::APPLICATION_TINY_TODO, - entitystore::{EntityDecodeError, EntityStore}, - util::{EntityUid, ListUid, TeamUid, UserUid, TYPE_TEAM}, + entitystore::EntityDecodeError, + util::{EntityUid, ListUid, TeamUid, UserUid}, }; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -142,25 +141,15 @@ pub struct List { owner: UserUid, name: String, tasks: Vec, // Invariant, `tasks` must be sorted - readers: TeamUid, - editors: TeamUid, } impl List { - pub fn new(store: &mut EntityStore, uid: ListUid, owner: UserUid, name: String) -> Self { - let readers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); - let readers = Team::new(readers_uid.clone()); - let writers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); - let writers = Team::new(writers_uid.clone()); - store.insert_team(readers); - store.insert_team(writers); + pub fn new(uid: ListUid, owner: UserUid, name: String) -> Self { Self { uid, owner, name, tasks: vec![], - readers: readers_uid, - editors: writers_uid, } } @@ -192,13 +181,6 @@ impl List { pub fn update_name(&mut self, name: String) { self.name = name; } - - pub fn get_team(&self, role: ShareRole) -> &TeamUid { - match role { - ShareRole::Reader => &self.readers, - ShareRole::Editor => &self.editors, - } - } } impl From for Entity { @@ -213,14 +195,6 @@ impl From for Entity { "tasks", RestrictedExpression::new_set(value.tasks.into_iter().map(|t| t.into())), ), - ( - "readers", - format!("{}", value.readers.as_ref()).parse().unwrap(), - ), - ( - "editors", - format!("{}", value.editors.as_ref()).parse().unwrap(), - ), ] .into_iter() .map(|(x, v)| (x.into(), v)) diff --git a/tinytodo/tinytodo.cedarschema.json b/tinytodo/tinytodo.cedarschema.json index 5da4ec0..edc23c2 100644 --- a/tinytodo/tinytodo.cedarschema.json +++ b/tinytodo/tinytodo.cedarschema.json @@ -28,14 +28,6 @@ "name": { "type": "String" }, - "readers": { - "type": "Entity", - "name": "Team" - }, - "editors": { - "type": "Entity", - "name": "Team" - }, "tasks": { "type": "Set", "element": { diff --git a/tinytodo/tinytodo.py b/tinytodo/tinytodo.py index 3d9d87c..1c9356e 100644 --- a/tinytodo/tinytodo.py +++ b/tinytodo/tinytodo.py @@ -205,7 +205,7 @@ def process_response(name, resp, f, args): tup = (current_user, name, args) print('Access denied. User %s is not authorized to %s on [%s]' % tup ) else: - print('Error: %s' % resp['error']) + print('Error: %s' % body['error']) else: print(f(body)) else: From 09a5053b1baa6f914423ed35fa7d73b2829f1cdd Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Tue, 19 Dec 2023 11:29:34 -0500 Subject: [PATCH 4/9] factored out linked policy ID --- tinytodo/src/context.rs | 43 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index 08e2ea1..c22ea50 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -37,7 +37,7 @@ use crate::{ entitystore::{EntityDecodeError, EntityStore}, objects::List, policy_store, - util::{EntityUid, ListUid, Lists, TYPE_LIST}, + util::{EntityUid, ListUid, Lists, UserOrTeamUid, TYPE_LIST}, }; // There's almost certainly a nicer way to do this than having separate `sender` fields @@ -408,17 +408,34 @@ impl AppContext { Ok(AppResponse::Unit(())) } + // Computes the name of the template-linked policy. + // This function is injective, ensuring that different share permissions will have different policy IDs + fn linked_policy_id( + role: ShareRole, + target: UserOrTeamUid, + list: ListUid, + ) -> std::result::Result { + let pid_prefix = match role { + ShareRole::Reader => "reader", + ShareRole::Editor => "editor", + }; + let target_eid = target.as_ref().id(); + // Note: A List EID is controlled by TinyTodo, and will always be a number + let list_eid = list.as_ref().id(); + PolicyId::from_str(&format!("{pid_prefix}[{target_eid}][{list_eid}]")) + } + fn add_share(&mut self, r: AddShare) -> Result { self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; // Confirm that the identified list and sharer are known let _list = self.entities.get_list(&r.list)?; let _target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; // Link a template to register the new permission - let (tid, pid_prefix) = match r.role { - ShareRole::Reader => (PolicyId::from_str("reader-template")?, "reader"), - ShareRole::Editor => (PolicyId::from_str("editor-template")?, "editor"), + let tid = match r.role { + ShareRole::Reader => PolicyId::from_str("reader-template")?, + ShareRole::Editor => PolicyId::from_str("editor-template")?, }; - // Construct template linking values + // Construct template linking environment let target_euid: &cedar_policy::EntityUid = r.share_with.as_ref(); let list_euid: &cedar_policy::EntityUid = r.list.as_ref(); let env: HashMap = [ @@ -427,11 +444,8 @@ impl AppContext { ] .into_iter() .collect(); - // Construct policy ID; assumes no policy in the set has it already - let target_eid = target_euid.id(); - let list_eid = list_euid.id(); - let pid = PolicyId::from_str(&format!("{pid_prefix}[{target_eid}][{list_eid}]"))?; // Link it! + let pid = Self::linked_policy_id(r.role, r.share_with, r.list)?; self.policies.link(tid, pid.clone(), env)?; info!("Created policy {pid}"); Ok(AppResponse::Unit(())) @@ -443,14 +457,9 @@ impl AppContext { let _list = self.entities.get_list(&r.list)?; let _target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; // Unlink the policy that provided the permission - let pid_prefix = match r.role { - ShareRole::Reader => "reader", - ShareRole::Editor => "editor", - }; - let target_eid = r.unshare_with.as_ref().id(); - let list_eid = r.list.as_ref().id(); - let pid = PolicyId::from_str(&format!("{pid_prefix}[{target_eid}][{list_eid}]"))?; - self.policies.unlink(pid)?; + let pid = Self::linked_policy_id(r.role, r.unshare_with, r.list)?; + self.policies.unlink(pid.clone())?; + info!("Removed policy {pid}"); Ok(AppResponse::Unit(())) } From 864956bf38cc47b3ba9b81e14c895cf1c2ddc338 Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Tue, 19 Dec 2023 20:41:37 +0000 Subject: [PATCH 5/9] CI updates from `main` --- .github/ISSUE_TEMPLATE/new_example.yml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/new_example.yml diff --git a/.github/ISSUE_TEMPLATE/new_example.yml b/.github/ISSUE_TEMPLATE/new_example.yml new file mode 100644 index 0000000..9dac0b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_example.yml @@ -0,0 +1,38 @@ +name: Suggest a new example +description: Suggest a new example +labels: feature-request + +body: + - type: textarea + attributes: + label: Summary + description: In one or two sentences, why should this example exist? + validations: + required: true + + - type: textarea + attributes: + label: Motivation + description: Why is this example needed? + validations: + required: true + + + - type: textarea + attributes: + label: Detailed Design + description: Provide enough detail on _how_ this should be implemented such that someone other than yourself could build it. + validations: + required: true + + - type: textarea + attributes: + label: Related Issues + description: Add Github issue numbers/URLs that informed or would be impacted by this proposal. + + - type: textarea + attributes: + label: References + description: List articles, resources, prior art, and inspiration for this proposal. + + From b7b22146b0695934d027bcee237c69c13119ac21 Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Wed, 20 Dec 2023 17:12:10 -0500 Subject: [PATCH 6/9] templated policies a compile-time feature --- tinytodo/Cargo.toml | 5 + tinytodo/policies-templates.cedar | 46 ++++++ tinytodo/policies.cedar | 23 ++- tinytodo/src/context.rs | 127 +++++++++------- tinytodo/src/entitystore.rs | 1 - tinytodo/src/main.rs | 15 +- tinytodo/src/objects.rs | 48 +++++- tinytodo/src/policy_store.rs | 42 +----- tinytodo/tinytodo-templates.cedarschema.json | 145 +++++++++++++++++++ tinytodo/tinytodo.cedarschema.json | 8 + 10 files changed, 348 insertions(+), 112 deletions(-) create mode 100644 tinytodo/policies-templates.cedar create mode 100644 tinytodo/tinytodo-templates.cedarschema.json diff --git a/tinytodo/Cargo.toml b/tinytodo/Cargo.toml index 53d18df..eb19825 100644 --- a/tinytodo/Cargo.toml +++ b/tinytodo/Cargo.toml @@ -19,3 +19,8 @@ notify = { version = "5.1.0", default-features = false, features = ["macos_kqueu [dependencies.cedar-policy] version = "3.0.0" +git = "https://github.com/cedar-policy/cedar" +branch = "main" + +[features] +use-templates = [] diff --git a/tinytodo/policies-templates.cedar b/tinytodo/policies-templates.cedar new file mode 100644 index 0000000..1198c16 --- /dev/null +++ b/tinytodo/policies-templates.cedar @@ -0,0 +1,46 @@ +// Policy 0: Any User can create a list and see what lists they own +permit ( + principal, + action in [Action::"CreateList", Action::"GetLists"], + resource == Application::"TinyTodo" +); + +// Policy 1: A User can perform any action on a List they own +permit (principal, action, resource) +when { resource is List && resource.owner == principal }; + +// Policy 2: Users who are members of [?principal] are readers of [?resource] +@id("reader-template") +permit ( + principal in ?principal, + action == Action::"GetList", + resource == ?resource +); + +// Policy 3: Users who are members of [?principal] are editors of [?resource] +@id("editor-template") +permit ( + principal in ?principal, + action in + [Action::"GetList", + Action::"UpdateList", + Action::"CreateTask", + Action::"UpdateTask", + Action::"DeleteTask"], + resource == ?resource +); + +// Policy 4: Admins can perform any action on any resource +// @id("admin-omnipotence") +// permit ( +// principal in Team::"admin", +// action, +// resource in Application::"TinyTodo" +// ); +// +// Policy 5: Interns may not create new task lists +// forbid ( +// principal in Team::"interns", +// action == Action::"CreateList", +// resource == Application::"TinyTodo" +// ); diff --git a/tinytodo/policies.cedar b/tinytodo/policies.cedar index 1d8640e..373d0b2 100644 --- a/tinytodo/policies.cedar +++ b/tinytodo/policies.cedar @@ -9,26 +9,25 @@ permit ( permit (principal, action, resource) when { resource is List && resource.owner == principal }; -// Policy 2: Users who are members of [?principal] are readers of [?resource] -@id("reader-template") +// Policy 2: A User can see a List if they are either a reader or editor permit ( - principal in ?principal, + principal, action == Action::"GetList", - resource == ?resource -); + resource +) +when { principal in resource.readers || principal in resource.editors }; -// Policy 3: Users who are members of [?principal] are editors of [?resource] -@id("editor-template") +// Policy 3: A User can update a List and its tasks if they are an editor permit ( - principal in ?principal, + principal, action in - [Action::"GetList", - Action::"UpdateList", + [Action::"UpdateList", Action::"CreateTask", Action::"UpdateTask", Action::"DeleteTask"], - resource == ?resource -); + resource +) +when { principal in resource.editors }; // Policy 4: Admins can perform any action on any resource // @id("admin-omnipotence") diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index c22ea50..d8dac4d 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -16,13 +16,14 @@ use itertools::Itertools; use lazy_static::lazy_static; -use std::{collections::HashMap, path::PathBuf, str::FromStr}; +use std::path::PathBuf; use tracing::{error, info, trace}; use cedar_policy::{ - Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicyId, PolicySet, - PolicySetError, Request, Schema, SchemaError, SlotId, ValidationMode, Validator, + Authorizer, Context, Decision, Diagnostics, EntityTypeName, ParseErrors, PolicySet, + PolicySetError, Request, Schema, SchemaError, ValidationMode, Validator, }; + use thiserror::Error; use tokio::sync::{ mpsc::{Receiver, Sender}, @@ -32,14 +33,21 @@ use tokio::sync::{ use crate::{ api::{ AddShare, CreateList, CreateTask, DeleteList, DeleteShare, DeleteTask, Empty, GetList, - GetLists, ShareRole, UpdateList, UpdateTask, + GetLists, UpdateList, UpdateTask, }, entitystore::{EntityDecodeError, EntityStore}, objects::List, policy_store, - util::{EntityUid, ListUid, Lists, UserOrTeamUid, TYPE_LIST}, + util::{EntityUid, ListUid, Lists, TYPE_LIST}, }; +#[cfg(feature = "use-templates")] +use std::{collections::HashMap, str::FromStr}; +#[cfg(feature = "use-templates")] +use cedar_policy::{PolicyId, SlotId}; +#[cfg(feature = "use-templates")] +use crate::{api::{ShareRole}, util::UserOrTeamUid}; + // There's almost certainly a nicer way to do this than having separate `sender` fields #[derive(Debug)] @@ -308,8 +316,7 @@ impl AppContext { let tx = send.clone(); tokio::spawn(async move { info!("Serving application server!"); - // FIXME: probably should pass in [schema] not [schema_path] - policy_store::spawn_watcher(policies_path, schema_path, tx).await; + policy_store::spawn_watcher(policies_path, tx).await; let c = Self { entities, authorizer, @@ -358,7 +365,6 @@ impl AppContext { fn update_policy_set(&mut self, policy_set: PolicySet) -> Result { let mut new_policies = rename_from_id_annotation(policy_set)?; let mut err = false; - let mut updated = false; // for each existing template-linked policy, // link against the new version of the template in the new policy set if present for p in self.policies.policies() { @@ -380,7 +386,6 @@ impl AppContext { None => error!("Error when reloading policies: Template with no matching links"), Some(vals) => { // link against new template, using the same policy ID as the old one - updated = true; new_policies.link(tid.clone(), p.id().clone(), vals)? } } @@ -389,27 +394,25 @@ impl AppContext { } } } - // no error during relinking + // no error during relinking; now validate policies if !err { - // check that re-linked policies validate properly - if updated { - let validator = Validator::new(self.schema.clone()); - let output = validator.validate(&new_policies, ValidationMode::default()); - if !output.validation_passed() { - for e in output.validation_errors() { - error!("Error validating linked policies: {e}") - } - } else { - self.policies = new_policies; - info!("Reloaded policy set") + let validator = Validator::new(self.schema.clone()); + let output = validator.validate(&new_policies, ValidationMode::default()); + if !output.validation_passed() { + for e in output.validation_errors() { + error!("Error validating linked policies: {e}") } + } else { + self.policies = new_policies; + info!("Reloaded policy set") } - }; + } Ok(AppResponse::Unit(())) } - // Computes the name of the template-linked policy. + // Computes the name of the template-linked policy; only relevant with "use-templates" feature enabled // This function is injective, ensuring that different share permissions will have different policy IDs + #[cfg(feature = "use-templates")] fn linked_policy_id( role: ShareRole, target: UserOrTeamUid, @@ -427,39 +430,59 @@ impl AppContext { fn add_share(&mut self, r: AddShare) -> Result { self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; - // Confirm that the identified list and sharer are known - let _list = self.entities.get_list(&r.list)?; - let _target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; - // Link a template to register the new permission - let tid = match r.role { - ShareRole::Reader => PolicyId::from_str("reader-template")?, - ShareRole::Editor => PolicyId::from_str("editor-template")?, - }; - // Construct template linking environment - let target_euid: &cedar_policy::EntityUid = r.share_with.as_ref(); - let list_euid: &cedar_policy::EntityUid = r.list.as_ref(); - let env: HashMap = [ - (SlotId::principal(), target_euid.clone()), - (SlotId::resource(), list_euid.clone()), - ] - .into_iter() - .collect(); - // Link it! - let pid = Self::linked_policy_id(r.role, r.share_with, r.list)?; - self.policies.link(tid, pid.clone(), env)?; - info!("Created policy {pid}"); + #[cfg(feature = "use-templates")] + { + // Confirm that the identified list and sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; + // Link a template to register the new permission + let tid = match r.role { + ShareRole::Reader => PolicyId::from_str("reader-template")?, + ShareRole::Editor => PolicyId::from_str("editor-template")?, + }; + // Construct template linking environment + let target_euid: &cedar_policy::EntityUid = r.share_with.as_ref(); + let list_euid: &cedar_policy::EntityUid = r.list.as_ref(); + let env: HashMap = [ + (SlotId::principal(), target_euid.clone()), + (SlotId::resource(), list_euid.clone()), + ] + .into_iter() + .collect(); + // Link it! + let pid = Self::linked_policy_id(r.role, r.share_with, r.list)?; + self.policies.link(tid, pid.clone(), env)?; + info!("Created policy {pid}"); + } + #[cfg(not(feature = "use-templates"))] + { + let list = self.entities.get_list(&r.list)?; + let team_uid = list.get_team(r.role).clone(); + let target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; + target_entity.insert_parent(team_uid); + } Ok(AppResponse::Unit(())) } fn delete_share(&mut self, r: DeleteShare) -> Result { self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; - // Confirm that the identified list and un-sharer are known - let _list = self.entities.get_list(&r.list)?; - let _target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; - // Unlink the policy that provided the permission - let pid = Self::linked_policy_id(r.role, r.unshare_with, r.list)?; - self.policies.unlink(pid.clone())?; - info!("Removed policy {pid}"); + #[cfg(feature = "use-templates")] + { + // Confirm that the identified list and un-sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; + // Unlink the policy that provided the permission + let pid = Self::linked_policy_id(r.role, r.unshare_with, r.list)?; + self.policies.unlink(pid.clone())?; + info!("Removed policy {pid}"); + } + #[cfg(not(feature = "use-templates"))] + { + let list = self.entities.get_list(&r.list)?; + let team_uid = list.get_team(r.role).clone(); + let target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; + target_entity.delete_parent(&team_uid); + } Ok(AppResponse::Unit(())) } @@ -515,7 +538,7 @@ impl AppContext { .entities .fresh_euid::(TYPE_LIST.clone()) .unwrap(); - let l = List::new(euid.clone(), r.uid, r.name); + let l = List::new(&mut self.entities, euid.clone(), r.uid, r.name); self.entities.insert_list(l); Ok(AppResponse::euid(euid)) diff --git a/tinytodo/src/entitystore.rs b/tinytodo/src/entitystore.rs index 616b1b1..3200bab 100644 --- a/tinytodo/src/entitystore.rs +++ b/tinytodo/src/entitystore.rs @@ -36,7 +36,6 @@ pub struct EntityStore { uid: usize, } -#[allow(dead_code)] impl EntityStore { pub fn euids(&self) -> impl Iterator { self.users diff --git a/tinytodo/src/main.rs b/tinytodo/src/main.rs index 96f1be6..d04f538 100644 --- a/tinytodo/src/main.rs +++ b/tinytodo/src/main.rs @@ -29,12 +29,15 @@ use tracing::Level; #[tokio::main] async fn main() { init_logger(); - let app = AppContext::spawn( - "./entities.json", - "./tinytodo.cedarschema.json", - "./policies.cedar", - ) - .unwrap(); + let (schema_path, policies_path) = if cfg!(feature = "use-templates") { + ( + "./tinytodo-templates.cedarschema.json", + "./policies-templates.cedar", + ) + } else { + ("./tinytodo.cedarschema.json", "./policies.cedar") + }; + let app = AppContext::spawn("./entities.json", schema_path, policies_path).unwrap(); let args = std::env::args().collect::>(); match get_port(&args) { diff --git a/tinytodo/src/objects.rs b/tinytodo/src/objects.rs index 7a457b0..0bf6a54 100644 --- a/tinytodo/src/objects.rs +++ b/tinytodo/src/objects.rs @@ -21,10 +21,13 @@ use serde::{Deserialize, Serialize}; use crate::{ context::APPLICATION_TINY_TODO, - entitystore::EntityDecodeError, + entitystore::{EntityDecodeError, EntityStore}, util::{EntityUid, ListUid, TeamUid, UserUid}, }; +#[cfg(not(feature = "use-templates"))] +use crate::{api::ShareRole, util::TYPE_TEAM}; + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Application { euid: EntityUid, @@ -141,10 +144,33 @@ pub struct List { owner: UserUid, name: String, tasks: Vec, // Invariant, `tasks` must be sorted + #[cfg(not(feature = "use-templates"))] + readers: TeamUid, + #[cfg(not(feature = "use-templates"))] + editors: TeamUid, } impl List { - pub fn new(uid: ListUid, owner: UserUid, name: String) -> Self { + #![allow(unused_variables)] + pub fn new(store: &mut EntityStore, uid: ListUid, owner: UserUid, name: String) -> Self { + #[cfg(not(feature = "use-templates"))] + { + let readers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); + let readers = Team::new(readers_uid.clone()); + let writers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); + let writers = Team::new(writers_uid.clone()); + store.insert_team(readers); + store.insert_team(writers); + Self { + uid, + owner, + name, + tasks: vec![], + readers: readers_uid, + editors: writers_uid, + } + } + #[cfg(feature = "use-templates")] Self { uid, owner, @@ -181,6 +207,14 @@ impl List { pub fn update_name(&mut self, name: String) { self.name = name; } + + #[cfg(not(feature = "use-templates"))] + pub fn get_team(&self, role: ShareRole) -> &TeamUid { + match role { + ShareRole::Reader => &self.readers, + ShareRole::Editor => &self.editors, + } + } } impl From for Entity { @@ -195,6 +229,16 @@ impl From for Entity { "tasks", RestrictedExpression::new_set(value.tasks.into_iter().map(|t| t.into())), ), + #[cfg(not(feature = "use-templates"))] + ( + "readers", + format!("{}", value.readers.as_ref()).parse().unwrap(), + ), + #[cfg(not(feature = "use-templates"))] + ( + "editors", + format!("{}", value.editors.as_ref()).parse().unwrap(), + ), ] .into_iter() .map(|(x, v)| (x.into(), v)) diff --git a/tinytodo/src/policy_store.rs b/tinytodo/src/policy_store.rs index e894b2e..c94f23e 100644 --- a/tinytodo/src/policy_store.rs +++ b/tinytodo/src/policy_store.rs @@ -15,12 +15,11 @@ */ use std::{ - fmt::Display, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; -use cedar_policy::{ParseErrors, PolicySet, Schema, SchemaError, ValidationError, Validator}; +use cedar_policy::{ParseErrors, PolicySet}; use thiserror::Error; use tokio::sync::mpsc::Sender; use tracing::{debug, error}; @@ -30,7 +29,6 @@ use crate::context::{AppQuery, AppQueryKind}; #[derive(Debug, Clone)] struct PolicySetWatcher { policy_set: PathBuf, - schema: PathBuf, tx: Sender, } @@ -42,42 +40,15 @@ enum Error { IO(#[from] std::io::Error), #[error("Errors parsing policy set: {0}")] ParsePolicies(#[from] ParseErrors), - #[error("Errors parsing schema: {0}")] - ParseSchema(#[from] SchemaError), - #[error("Errors validating policy set: {0}")] - Validation(String), #[error("Error sending to app processor: {0}")] McspChan(#[from] tokio::sync::mpsc::error::SendError), #[error("Error receiving response from oneshot channel: {0}")] OneShot(#[from] tokio::sync::oneshot::error::RecvError), } -#[derive(Debug)] -struct ValidationErrors<'a>(Vec<&'a ValidationError<'a>>); - -impl<'a> Display for ValidationErrors<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for err in self.0.iter() { - writeln!(f, "{}", err)?; - } - Ok(()) - } -} - -impl Error { - pub fn validation<'a>(v: impl Iterator>) -> Self { - Self::Validation(ValidationErrors(v.collect()).to_string()) - } -} - -pub async fn spawn_watcher( - policy_set: impl AsRef, - schema: impl AsRef, - tx: Sender, -) { +pub async fn spawn_watcher(policy_set: impl AsRef, tx: Sender) { let w = PolicySetWatcher { policy_set: PathBuf::from(policy_set.as_ref()), - schema: PathBuf::from(schema.as_ref()), tx, }; tokio::spawn(async move { watcher_supervisor(w).await }); @@ -127,14 +98,7 @@ async fn send_query(p: PolicySet, tx: &Sender) -> Result<()> { async fn attempt_policy_reload(w: &PolicySetWatcher) -> Result { let policies: PolicySet = tokio::fs::read_to_string(&w.policy_set).await?.parse()?; - let schema: Schema = tokio::fs::read_to_string(&w.schema).await?.parse()?; - let validator = Validator::new(schema); - let results = validator.validate(&policies, cedar_policy::ValidationMode::Strict); - if results.validation_passed() { - Ok(policies) - } else { - Err(Error::validation(results.validation_errors())) - } + Ok(policies) } async fn get_last_modified(path: &Path) -> std::io::Result { diff --git a/tinytodo/tinytodo-templates.cedarschema.json b/tinytodo/tinytodo-templates.cedarschema.json new file mode 100644 index 0000000..edc23c2 --- /dev/null +++ b/tinytodo/tinytodo-templates.cedarschema.json @@ -0,0 +1,145 @@ +{ + "": { + "entityTypes": { + "Application": {}, + "User": { + "memberOfTypes": [ + "Team", + "Application" + ] + }, + "Team": { + "memberOfTypes": [ + "Team", + "Application" + ] + }, + "List": { + "memberOfTypes": [ + "Application" + ], + "shape": { + "type": "Record", + "attributes": { + "owner": { + "type": "Entity", + "name": "User" + }, + "name": { + "type": "String" + }, + "tasks": { + "type": "Set", + "element": { + "type": "Record", + "attributes": { + "name": { + "type": "String" + }, + "id": { + "type": "Long" + }, + "state": { + "type": "String" + } + } + } + } + } + } + } + }, + "actions": { + "CreateList": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "Application" + ] + } + }, + "GetList": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "UpdateList": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "DeleteList": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "GetLists": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "Application" + ] + } + }, + "CreateTask": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "UpdateTask": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "DeleteTask": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + }, + "EditShare": { + "appliesTo": { + "principalTypes": [ + "User" + ], + "resourceTypes": [ + "List" + ] + } + } + } + } +} diff --git a/tinytodo/tinytodo.cedarschema.json b/tinytodo/tinytodo.cedarschema.json index edc23c2..5da4ec0 100644 --- a/tinytodo/tinytodo.cedarschema.json +++ b/tinytodo/tinytodo.cedarschema.json @@ -28,6 +28,14 @@ "name": { "type": "String" }, + "readers": { + "type": "Entity", + "name": "Team" + }, + "editors": { + "type": "Entity", + "name": "Team" + }, "tasks": { "type": "Set", "element": { From 0374278e825964e2a87244eeebfa0c00a676abde Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Thu, 21 Dec 2023 14:55:33 +0000 Subject: [PATCH 7/9] Fmt --- tinytodo/src/context.rs | 4 ++-- tinytodo/src/objects.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index d8dac4d..3306ae5 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -42,11 +42,11 @@ use crate::{ }; #[cfg(feature = "use-templates")] -use std::{collections::HashMap, str::FromStr}; +use crate::{api::ShareRole, util::UserOrTeamUid}; #[cfg(feature = "use-templates")] use cedar_policy::{PolicyId, SlotId}; #[cfg(feature = "use-templates")] -use crate::{api::{ShareRole}, util::UserOrTeamUid}; +use std::{collections::HashMap, str::FromStr}; // There's almost certainly a nicer way to do this than having separate `sender` fields diff --git a/tinytodo/src/objects.rs b/tinytodo/src/objects.rs index 0bf6a54..190ec21 100644 --- a/tinytodo/src/objects.rs +++ b/tinytodo/src/objects.rs @@ -168,7 +168,7 @@ impl List { tasks: vec![], readers: readers_uid, editors: writers_uid, - } + } } #[cfg(feature = "use-templates")] Self { From db07fa6553df3af2bffe207b9a6b1ad76798b649 Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Thu, 21 Dec 2023 10:10:34 -0500 Subject: [PATCH 8/9] extension to tutorial to use template-linked policies --- tinytodo/TUTORIAL.md | 2 + tinytodo/TUTORIAL_TEMPLATE.md | 232 ++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 tinytodo/TUTORIAL_TEMPLATE.md diff --git a/tinytodo/TUTORIAL.md b/tinytodo/TUTORIAL.md index cde3e82..7b85299 100644 --- a/tinytodo/TUTORIAL.md +++ b/tinytodo/TUTORIAL.md @@ -2,6 +2,8 @@ In this tutorial, we introduce Cedar and the Cedar SDK using an example application, TinyTodo, whose users and teams can organize, track, and share their todo lists. We show how to express TinyTodo permissions as Cedar policies and how TinyTodo uses the Cedar authorization engine to ensure that only intended users are granted access. We also suggest extensions to TinyTodo to help you further explore the Cedar SDK. +When you finish this tutorial, we invite you to consider a follow-on which shows [how to implement TinyTodo's policies using Cedar templates](TUTORIAL_TEMPLATE.md). + ## What is TinyTodo? TinyTodo allows individuals, called `User`s, and groups, called `Team`s, to organize, track, and share their todo lists. `User`s create `List`s which they can populate with tasks. As tasks are completed, they can be checked off the list. diff --git a/tinytodo/TUTORIAL_TEMPLATE.md b/tinytodo/TUTORIAL_TEMPLATE.md new file mode 100644 index 0000000..2a27922 --- /dev/null +++ b/tinytodo/TUTORIAL_TEMPLATE.md @@ -0,0 +1,232 @@ +# TinyTodo with Templates + +This is an extension to the tutorial for TinyTodo, an example application whose users and teams can organize, track, and share their todo lists. In the main tutorial we represented a list's allowed readers and editors as attributes of the list itself. In this extension, we represent them instead using Cedar's *template-linked policies*. This tutorial assumes you're familiar with the [main tutorial](TUTORIAL.md) already. + +## Building and running templated TinyTodo + +Recall the main tutorial's instructions for downloading and building TinyTodo: + +
>git clone https://github.com/cedar-policy/cedar-examples
+...downloading messages here
+> cd cedar-examples/tinytodo
+> pip3 install -r requirements.txt
+...installation messages here
+> cargo build --release 
+...build messages here
+
+ +To build the templated version, you should redo the last step: +

+> cargo build --release --features=use-templates
+
+This enables the `use-templates` feature flag which causes various TinyTodo `.rs` source files to be compiled differently. + +Running and interacting with the template-policy version of TinyTodo is the same as in the main tutorial. You should be able to carry out the same set of commands and get the same responses, with the only difference being that some `INFO` logging messages will be different. + +## What is a Cedar template-linked policy? + +A Cedar _template_ is a Cedar policy that contains _slots_, which can be filled in to make a complete policy that can be evaluated to make an authorization decision. Filling in a slot is called _linking_ and a fully-linked template is called a _template-linked policy_. By using different values for a template's slots, the same template can be linked into more than one policy, all stored in the same policy set. + +### Example + +Here is an example Cedar template: + +``` +// Example: Any principal in the [?principal] group may update photos tagged public +@id("comment-template") +permit( + principal in ?principal, + action == Action::"AddComment", + resource +) +when { resource.tags.contains("public") }; +``` + +This template has one slot, `?principal`. We can link this template against an entity UID to produce a template-linked policy. For example, if we linked the template against `UserGroup::"PaidSubscribers"` then we would get the following template-linked policy: + +``` +permit( + principal in User::"PaidSubscribers", + action == Action::"AddComment", + resource +) +when { resource.tags.contains("public") }; +``` + +In the Cedar SDK API, linking a template requires providing a policy set, the ID of the template in the policy set, and the values to link in. How do we know the template's ID? When defining the template we can use a [Cedar _policy annotation_](https://docs.cedarpolicy.com/policies/syntax-policy.html#term-parc-annotations) to specify it. The [Cedar command-line interface](https://github.com/cedar-policy/cedar/tree/main/cedar-policy-cli) uses the annotation `@id(...)` for this purpose, and we follow that convention above, giving the template above the ID `comment-template`. + +Cedar templates have a limited structure: They can only have one or two slots, `?principal` and/or `?resource`, and these slots can only appear on the righthand-side of the `in` or `==` that constrains the `principal` or `resource` elements of the policy scope. The Cedar policy syntax may expand in the future to support a wider variety of templates. + +### When to use templates? + +Templates are useful when you want to use policies to add or remove permissions in response to user activity with your application. For example, allowing a user to add a permission to an application object could be implemented by linking a template to create a policy that provides that permission. Doing this makes sense if there's no other data your application might already be storing that conveys the permission to be added. I.e., all you care about can be expressed in that policy. + +A template bears some resemblance to a [SQL prepared statement](https://www.w3schools.com/php/php_mysql_prepared_statements.asp), where template slots play the same role as a prepared statement's _parameters_ (also sometimes called _placeholders_). Cedar template-linked policies have the same security benefits as prepared statements: They can support on-the-fly construction of policies in a way that prevents code injection attacks. + +Templates also provide benefits to policy management. If your policy store has many policies linked to the same template, then updating that template can be made to automatically update all of its linked instances. Doing so is beneficial if the template represents a _kind_ of permission, where the conditions defining that permission may evolve over time. + +## Updating TinyTodo's policies to use templates + +In TinyTodo we can use templates for policies that correspond to reader and editor permissions. We can have TinyTodo link a template whenever a list's owner grants reader/editor permissions on it. Let's review how TinyTodo originally solved this problem, and then we'll show the template approach and contrast the two. + +### Old policies + +As a refresher, here are the policies we used in the original TinyTodo to grant permissions to a list's readers and editors. They can be found in the `policies.cedar` file. The first policy states that both readers and editors are allowed to see the contents of a list: + +``` +// Policy 2: A User can see a List and its tasks if they are a reader or editor +permit ( + principal, + action == Action::"GetList", + resource +) +when { + principal in resource.readers || principal in resource.editors +}; +``` + +The next policy states that a list's editors are allowed to modify the list in various ways: + +``` +// Policy 3: A User can update a List and its tasks if they are an editor +permit ( + principal, + action in [ + Action::"UpdateList", + Action::"CreateTask", + Action::"UpdateTask", + Action::"DeleteTask" + ], + resource +) +when { principal in resource.editors }; +``` + +In these policies, the `List` entity is assumed to have `readers` and `editors` attributes that reference corresponding entity groups with type `Team`. An example `List::"0"` entity illustrating this is shown in Figure 1. + +![Example List entity](images/list_entity.png) + +**Figure 1: Example List entity** + +Suppose that `User::"Andrew"`, the owner of `List::"0"`, wants to share this list with `Team::"interns"` as read-only. To make this happen, TinyTodo will update `Team::"1"` to have `Team::"interns"` as one of its parents. Because `List::"0".readers` references `Team::"1"`, the expression `User::"aaron" in List::"0".readers` will evaluate to true: `User::"aaron"` has parent (is a member of) `Team::"interns"`, which has parent `Team::"1"`, and thus `User::"aaron" in Team::"1"`. + + +### Template-linked policies + +Now let's see the two templates we will use to replace these policies. They can be found in the `policies-templates.cedar` file. + +``` +// Policy 2: Users who are members of [?principal] are readers of [?resource] +@id("reader-template") +permit ( + principal in ?principal, + action == Action::"GetList", + resource == ?resource +); + +// Policy 3: Users who are members of [?principal] are editors of [?resource] +@id("editor-template") +permit ( + principal in ?principal, + action in + [Action::"GetList", + Action::"UpdateList", + Action::"CreateTask", + Action::"UpdateTask", + Action::"DeleteTask"], + resource == ?resource +); +``` + +With these templates, each time a `List` owner shares the list with a `User` or `Team`, TinyTodo will link one of the above templates to the respective `User` or `Team` as the `?principal`, and the `List` as the `?resource`. The `reader-template` corresponds to the `readers` attribute we had on lists with the old policies: When previously we would have added _U_ to _L_`.readers`, now we link `reader-template` with _U_ as the `?principal` and _L_ as the `?resource`. The `editor-template` similarly corresponds to the old `List.editors` attribute. + +To see how this works, consider what happens when `User::"Andrew"` shares his list, `List::"0"`, with `Team::"interns"` as read-only. TinyTodo will link `reader-template` with `?principal` as `Team::"interns"` and `?resource` as `List::"0"` and thereby add the following template-linked policy in the policy store: +``` +permit ( + principal in Team::"interns", + action == Action::"GetList", + resource == List::"0" +); +``` +This newly-added policy will allow `User::"aaron"` to see the contents of `List::"0"` because he is a member of `Team::"interns"`. + +Conversely, each time a `List` owner wishes to _unshare_ a list with a particular `User` or `Team`, TinyTodo will _unlink_ the corresponding template, removing it from the policy store. + +`List` entities no longer need the `readers` or `editors` attributes. Those attributes' entire function was to track permissions, and that function is now handled using templates. Switching to templates has the useful benefit that it reduces the entities that need to be passed in with a Cedar authorization request -- what used to be captured as an entity is now (already) in the policy store. + +## TinyTodo authorization logic + +We'll finish out this tutorial by looking at the changes to the TinyTodo code needed to leverage templates. + +### Implementing `List` sharing + +Using the Python client, the running example we've been using so far is expressed by the command `share_list(0,interns,read_only=True)` (which follows other commands that created the list 0; see the original tutorial for more). This command will send an HTTP request to the TinyTodo server which induces the `add_share()` function in `context.rs` to be called. Here is its code; it's a lot to digest, so we'll work through it bit by bit. + +```rust +fn add_share(&mut self, r: AddShare) -> Result { + self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; + // Confirm that the identified list and sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.share_with)?; + // Link a template to register the new permission + let tid = match r.role { + ShareRole::Reader => PolicyId::from_str("reader-template")?, + ShareRole::Editor => PolicyId::from_str("editor-template")?, + }; + // Construct template linking environment + let target_euid: &cedar_policy::EntityUid = r.share_with.as_ref(); + let list_euid: &cedar_policy::EntityUid = r.list.as_ref(); + let env: HashMap = [ + (SlotId::principal(), target_euid.clone()), + (SlotId::resource(), list_euid.clone()), + ] + .into_iter() + .collect(); + // Link it! + let pid = Self::linked_policy_id(r.role, r.share_with, r.list)?; + self.policies.link(tid, pid.clone(), env)?; + info!("Created policy {pid}"); +} +``` +The first line tries to authorize the request; for our example, it checks that `User::"andrew"` can perform `Action::"EditShare"` on resource `List::"0"`. The `is_authorized` call returns `Ok()` because *policy 1* evaluates to `true`, since `List::"0".owner == User::"andrew"` (see Figure 3 in the original tutorial). The call to `get_list` then retrieves the native `List` object for `List::"0"` from `self.entities`, our `EntityStore`. The next call `get_user_or_team_mut` gets the `User` or `Team` to share the list with, which in this case is `Team::"interns"`. If either call fails then the target objects don't exist in the entity store and an error is sent back. + +Next, we construct the name of the template we will link: if `r.role` is `Reader`, as in our example, the template ID is `reader-template`. Now we construct the entity UIDs to link `?principal` and `?resource` against, and store them in `env`, a hashmap. Then we call the function `linked_policy_id` (not shown) to construct the policy ID from the sharing role, the target user/team, and the list. This function is injective, so we can be sure no two linked policies will have the same ID. In the case of our example, a policy ID is `reader[interns][0]`. Finally, we link the policy into the policy store. + +**Note**: The code in the actual `context.rs` file _conditionally compiles_ in code for using templates rather than entity attributes, using Rust macro `#[cfg(feature = "use-templates")]`. We do not show that code here in order to avoid clutter. + +### Unsharing a `List` + +Unsharing a list, via the command `unshare_list` in the Python client, finds the appropriate template-linked policy and unlinks it from the store. The logic here reverses what was done above. +```rust +fn delete_share(&mut self, r: DeleteShare) -> Result { + self.is_authorized(&r.uid, &*ACTION_EDIT_SHARE, &r.list)?; + // Confirm that the identified list and un-sharer are known + let _list = self.entities.get_list(&r.list)?; + let _target_entity = self.entities.get_user_or_team_mut(&r.unshare_with)?; + // Unlink the policy that provided the permission + let pid = Self::linked_policy_id(r.role, r.unshare_with, r.list)?; + self.policies.unlink(pid.clone())?; + info!("Removed policy {pid}"); + Ok(AppResponse::Unit(())) +} +``` +Once again, the first step is to authorize the action of editing the permissions, and then to confirm that the named list and target entity are present in the entity store. Then the code constructs the ID of the template-linked policy from the type of permission being unshared, and the IDs of the list and target entities, and then unlinks that policy from the store. + +### Logic for policy updates + +TinyTodo keeps watch on the `policies-templates.cedar` file which contains its initial policies and templates. If that file changes, it reloads the file and replaces the current policy set with the new set (assuming they all validate). In the old TinyTodo, that was all there is to it. But with this extension, we need to do some extra work to deal with the template-linked policies. In particular, the function `update_policy_set` in `context.rs` now iterates through the current policy set, finds all template-linked policies. For each such policy _p_, it finds _p_'s corresponding template _t_ in the new policies and links against that with the same links that were used to create _p_. If doing so fails, or any of the new template-linked policies fail to validate, the update aborts. + +### `List` entity type changes + +As mentioned earlier, changing TinyTodo to use templates means we no longer need the `readers` or `editors` attributes of `List` entities. Thus we change `objects.rs` to drop the corresponding fields from `struct List` and make some API changes that follow. We also change the TinyTodo schema, `tinytodo-templates.cedarschema.json`, to drop these fields from the definition of the `List` entity type. All other aspects of the schema stay the same. + +## Next steps + +This completes our refactoring of TinyTodo to use template-linked policies! We hope you've found it illuminating. As next steps, we invite you to consider the proposed extensions in the main tutorial, seeing how implementing them might differ when using templates instead. + +## Learn More + +* Cedar tutorial and playground: [https://www.cedarpolicy.com](https://www.cedarpolicy.com/) +* Other example apps: https://github.com/cedar-policy/cedar-examples +* Cedar SDK documentation: https://docs.cedarpolicy.com/ +* Amazon Verified Permissions, which builds an authorization service around Cedar: https://aws.amazon.com/verified-permissions/ From 6163e3dbeb724c9a5b91833394c64580b2b44c9d Mon Sep 17 00:00:00 2001 From: Mike Hicks Date: Fri, 22 Dec 2023 08:14:21 -0500 Subject: [PATCH 9/9] refactor so Cedar-internal error -> panic --- tinytodo/src/context.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index 3306ae5..755c2db 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -375,20 +375,16 @@ impl AppContext { match new_policies.template(tid) { None => { // template not in new policy set - let tidx = tid.clone(); - let pidx = p.id().clone(); + let pid = p.id(); err = true; - error!("Error when reloading policies: Could not find policy template {tidx} to link {pidx}") + error!("Error when reloading policies: Could not find policy template {tid} to link {pid}") } Some(_) => { - // found template in new policy set - match p.template_links() { - None => error!("Error when reloading policies: Template with no matching links"), - Some(vals) => { - // link against new template, using the same policy ID as the old one - new_policies.link(tid.clone(), p.id().clone(), vals)? - } - } + // found template in new policy set; link into new policy set + let vals = p.template_links().expect( + "Error when reloading policies: Template with no matching links", + ); + new_policies.link(tid.clone(), p.id().clone(), vals)? } } }