Security

Cedar policies

Amazon's Cedar policies: requests authorized against policies and entities from JSON, policies validated against a schema, why a decision was reached by policy name, and syntax mistakes placed by line and column.

Version 1.0.0 · 4 functions · 3 crates

#authorization#policy#access-control#aws

Functions

What use cedar.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use cedar_authorize, cedar_check from cedar (Quillo 0.6).

cedar_authorize(policies: str, entities: any, request: any, schema_value: any) → anyor an error

Decides a request. request is { principal, action, resource, context }, entities written User::"alice" or { type, id }. entities is Cedar's JSON, [{ uid, attrs, parents }], or text. schema is null, Cedar schema text or schema JSON: given, the request, the context and the entities are checked against it. Gives { allowed, decision, reasons, errors, why }: reasons are the policies that decided, by their @id annotation if they have one, errors the policies that failed to evaluate and so were skipped, why a sentence. A policy that fails to parse is an error with its line.

cedar_check(policies: str) → any

Parses policies without deciding anything: { ok, errors, policies }, each error with line and column, each policy with its id, effect and annotations.

cedar_validate(policies: str, schema_value: any) → anyor an error

Checks policies against a schema, Cedar schema text or JSON: a misspelled attribute, a type that cannot fit, an action applied to what it does not take. { ok, errors, warnings }, each with message and line, in strict mode.

cedar_matrix(policies: str, entities: any, principals: list, actions: list, resources: list) → listor an error

Who may do what, one line per pair of principals, actions and resources: a small access matrix for an admin page. Each cell is { principal, action, resource, allowed, why }.

Get it

$ quillo install cedar               # cedar.qwer into this folder$ quillo install cedar --example     # and its example, cedar.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/cedar.qwer$ curl -O https://quillolang.org/plugins/files/cedar.qwe

Then, in your program, all of it or only what you call:

main.qwe
use cedar.qweruse cedar_authorize, cedar_check from cedar      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }cedar-policy = "=4.14.0"miette = "7"

Example

cedar.qwe
// Who may see what in a photo-sharing app, decided by Cedar policies: an allow, a forbid that wins,// a request no policy permits, each with its reason, and policies checked against a schema.////   quillo run cedar.qwe      then open http://localhost:8080/allowed, /denied, /matrix, /validate and /brokenuse "cedar.qwer"let POLICIES = `@id("viewers-see-albums")permit (principal in Group::"viewers", action == Action::"view", resource in Album::"holiday");@id("owners-do-anything")permit (principal, action, resource)when { resource has owner && resource.owner == principal };@id("no-private-for-contractors")forbid (principal, action == Action::"view", resource)when { resource.private && principal.contractor };@id("office-hours")permit (principal == User::"tom", action == Action::"edit", resource)when { context.hour >= 9 && context.hour < 17 };`let ENTITIES = [    { uid: { type: "User", id: "alice" }, attrs: { contractor: false }, parents: [{ type: "Group", id: "viewers" }] },    { uid: { type: "User", id: "carl" }, attrs: { contractor: true }, parents: [{ type: "Group", id: "viewers" }] },    { uid: { type: "User", id: "tom" }, attrs: { contractor: false }, parents: [] },    { uid: { type: "Group", id: "viewers" }, attrs: {}, parents: [] },    { uid: { type: "Album", id: "holiday" }, attrs: {}, parents: [] },    { uid: { type: "Photo", id: "beach.jpg" }, attrs: { private: false, owner: { __entity: { type: "User", id: "tom" } } }, parents: [{ type: "Album", id: "holiday" }] },    { uid: { type: "Photo", id: "tax.pdf" }, attrs: { private: true, owner: { __entity: { type: "User", id: "tom" } } }, parents: [{ type: "Album", id: "holiday" }] },]fn ask(who, what, res, ctx) = cedar_authorize(POLICIES, ENTITIES, { principal: who, action: what, resource: res, context: ctx }, null)route GET /allowed = {    viewer: ask("User::\"alice\"", "Action::\"view\"", "Photo::\"beach.jpg\"", {})?,    owner: ask({ type: "User", id: "tom" }, "Action::\"delete\"", "Photo::\"tax.pdf\"", {})?.why,    in_hours: ask("User::\"tom\"", "Action::\"edit\"", "Photo::\"nothing\"", { hour: 10 })?.why,}route GET /denied = {    forbidden: ask("User::\"carl\"", "Action::\"view\"", "Photo::\"tax.pdf\"", {})?,    nothing: ask("User::\"alice\"", "Action::\"delete\"", "Photo::\"beach.jpg\"", {})?.why,    late: ask("User::\"tom\"", "Action::\"edit\"", "Photo::\"nothing\"", { hour: 22 })?.why,    // office-hours cannot see `hour`: that policy is skipped and named among the errors.    errors: ask("User::\"tom\"", "Action::\"edit\"", "Photo::\"nothing\"", {})?.errors,}route GET /matrix = cedar_matrix(POLICIES, ENTITIES, ["User::\"alice\"", "User::\"carl\""], ["Action::\"view\""], ["Photo::\"beach.jpg\"", "Photo::\"tax.pdf\""])? | map((c) => [c.principal, c.resource, c.allowed])let SCHEMA = `entity Group;entity Album;entity User in [Group] { contractor: Bool };entity Photo in [Album] { private: Bool, owner: User };action view, edit, delete appliesTo { principal: User, resource: [Photo, Album], context: { hour?: Long } };`route GET /validate = {    good: cedar_validate("permit (principal, action == Action::\"view\", resource) when { resource.private == false };", "entity User; entity Photo { private: Bool }; action view appliesTo { principal: User, resource: Photo };")?,    typo: cedar_validate("permit (principal, action == Action::\"view\", resource)\nwhen { resource.privat };", "entity User; entity Photo { private: Bool }; action view appliesTo { principal: User, resource: Photo };")?,    with_schema: cedar_authorize("permit (principal, action, resource);", ENTITIES, { principal: "User::\"alice\"", action: "Action::\"view\"", resource: "Photo::\"beach.jpg\"" }, SCHEMA)?.allowed,    wrong_entity: cedar_authorize("permit (principal, action, resource);", [{ uid: { type: "User", id: "x" }, attrs: { contractor: "no" }, parents: [] }], { principal: "User::\"x\"", action: "Action::\"view\"", resource: "Album::\"a\"" }, SCHEMA).error,    wrong_action: cedar_authorize("permit (principal, action, resource);", [], { principal: "User::\"x\"", action: "Action::\"fly\"", resource: "Album::\"a\"" }, SCHEMA).error,}route GET /broken = {    check: cedar_check("permit (principal, action, resource);\n\nforbid (principal action, resource);"),    list: cedar_check(POLICIES).policies | map((p) => `${p.effect} ${p.id}`),    refused: cedar_authorize("permit (", [], { principal: "User::\"a\"", action: "Action::\"b\"", resource: "C::\"d\"" }, null).error,    bad_uid: cedar_authorize("permit (principal, action, resource);", [], { principal: "alice", action: "Action::\"b\"", resource: "C::\"d\"" }, null).error,}
$ quillo run cedar.qwe$ curl 'localhost:8080/allowed'$ curl 'localhost:8080/denied'$ curl 'localhost:8080/matrix'$ curl 'localhost:8080/validate'$ curl 'localhost:8080/broken'

Source

cedar.qwer
//! Amazon's Cedar policies: requests authorized against policies and entities from JSON, policies validated against a schema, and why a decision was reached, each problem placed by line.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! cedar-policy = "=4.14.0"//! miette = "7"use cedar_policy::{Authorizer, Context, Decision, Entities, EntityUid, PolicyId, PolicySet, Request, Schema, ValidationMode, Validator};use miette::Diagnostic;use serde_json::{json, Value};use std::str::FromStr;/// Line and column, from 1, of a byte in a text.fn place(text: &str, at: usize) -> (usize, usize) {    let before = &text[..text.floor_char_boundary(at.min(text.len()))];    (before.matches('\n').count() + 1, before.rsplit('\n').next().unwrap_or("").chars().count() + 1)}/// A problem with where it is: `{ message, line, column, help }`.fn described(d: &dyn Diagnostic, text: &str) -> Value {    let mut message = d.to_string();    let mut src = std::error::Error::source(d);    while let Some(e) = src {        let s = e.to_string();        if !message.contains(&s) {            message = format!("{}: {}", message, s);        }        src = e.source();    }    // Only a label in a text we hold says anything.    let at = if text.is_empty() { None } else { d.labels().and_then(|mut l| l.next()).map(|l| l.offset()) };    let (line, column) = match at {        Some(a) => { let (l, c) = place(text, a); (json!(l), json!(c)) }        None => (Value::Null, Value::Null),    };    json!({ "message": message, "line": line, "column": column, "help": d.help().map(|h| h.to_string()) })}fn first_line(v: &Value) -> String {    match v["line"].as_u64() {        Some(l) => format!("line {}, column {}: {}", l, v["column"], v["message"].as_str().unwrap_or("")),        None => v["message"].as_str().unwrap_or("").to_string(),    }}fn parse(policies: &str) -> Result<PolicySet, String> {    PolicySet::from_str(policies).map_err(|e| first_line(&described(&e, policies)))}fn schema(s: &Value) -> Result<Option<Schema>, String> {    match s {        Value::Null => Ok(None),        Value::String(t) => Schema::from_cedarschema_str(t).map(|x| Some(x.0)).map_err(|e| format!("schema: {}", first_line(&described(&e, t)))),        o => Schema::from_json_value(o.clone()).map(Some).map_err(|e| format!("schema: {}", first_line(&described(&e, "")))),    }}fn uid(v: &Value, what: &str) -> Result<EntityUid, String> {    match v {        Value::String(s) => EntityUid::from_str(s).map_err(|e| format!("{} `{}` is not an entity like User::\"alice\": {}", what, s, e)),        Value::Object(_) => {            let t = v["type"].as_str().ok_or(format!("{}: no type", what))?;            let id = v["id"].as_str().ok_or(format!("{}: no id", what))?;            Ok(EntityUid::from_type_name_and_id(t.parse().map_err(|e: cedar_policy::ParseErrors| format!("{}: {}", what, e))?, id.parse().map_err(|e| format!("{}: {:?}", what, e))?))        }        _ => Err(format!("{} is needed", what)),    }}fn label(set: &PolicySet, id: &PolicyId) -> Value {    let Some(p) = set.policy(id) else { return json!({ "id": id.to_string() }) };    let notes: serde_json::Map<String, Value> = p.annotations().map(|(k, v)| (k.to_string(), json!(v))).collect();    let name = notes.get("id").cloned().unwrap_or_else(|| json!(id.to_string()));    let effect = format!("{:?}", p.effect()).to_lowercase();    json!({ "id": name, "effect": effect, "annotations": notes, "text": p.to_string().trim() })}/// Decides a request. `request` is `{ principal, action, resource, context }`, entities written `User::"alice"` or `{ type, id }`. `entities` is Cedar's JSON, `[{ uid, attrs, parents }]`, or text. `schema` is null, Cedar schema text or schema JSON: given, the request, the context and the entities are checked against it. Gives `{ allowed, decision, reasons, errors, why }`: `reasons` are the policies that decided, by their `@id` annotation if they have one, `errors` the policies that failed to evaluate and so were skipped, `why` a sentence. A policy that fails to parse is an error with its line.pub fn cedar_authorize(policies: &str, entities: serde_json::Value, request: serde_json::Value, schema_value: serde_json::Value) -> Result<Value, String> {    let set = parse(policies)?;    let sch = schema(&schema_value)?;    let principal = uid(&request["principal"], "principal")?;    let action = uid(&request["action"], "action")?;    let resource = uid(&request["resource"], "resource")?;    let context = match &request["context"] {        Value::Null => Context::empty(),        c => Context::from_json_value(c.clone(), sch.as_ref().map(|s| (s, &action))).map_err(|e| format!("context: {}", first_line(&described(&e, ""))))?,    };    let list = match entities {        Value::String(t) => serde_json::from_str(&t).map_err(|e| format!("entities: not JSON: {}", e))?,        Value::Null => json!([]),        o => o,    };    let ents = Entities::from_json_value(list, sch.as_ref()).map_err(|e| format!("entities: {}", first_line(&described(&e, ""))))?;    let q = Request::new(principal.clone(), action.clone(), resource.clone(), context, sch.as_ref()).map_err(|e| format!("the request: {}", first_line(&described(&e, ""))))?;    let r = Authorizer::new().is_authorized(&q, &set, &ents);    let allowed = r.decision() == Decision::Allow;    let reasons: Vec<Value> = r.diagnostics().reason().map(|id| label(&set, id)).collect();    let errors: Vec<String> = r.diagnostics().errors().map(|e| { let v = described(e, ""); v["message"].as_str().unwrap_or("").to_string() }).collect();    let names: Vec<String> = reasons.iter().map(|x| x["id"].as_str().unwrap_or("").to_string()).collect();    let why = if allowed {        format!("allowed by {}", names.join(", "))    } else if !names.is_empty() {        format!("denied: forbidden by {}", names.join(", "))    } else if !errors.is_empty() {        format!("denied: no policy permits it, and {} failed to evaluate", errors.len())    } else {        "denied: no policy permits it".to_string()    };    Ok(json!({ "allowed": allowed, "decision": if allowed { "Allow" } else { "Deny" }, "reasons": reasons, "errors": errors, "why": why }))}/// Parses policies without deciding anything: `{ ok, errors, policies }`, each error with `line` and `column`, each policy with its `id`, `effect` and annotations.pub fn cedar_check(policies: &str) -> Value {    match PolicySet::from_str(policies) {        Err(e) => {            let errors: Vec<Value> = e.iter().map(|d| described(d, policies)).collect();            json!({ "ok": false, "errors": if errors.is_empty() { vec![described(&e, policies)] } else { errors }, "policies": [] })        }        Ok(set) => {            let list: Vec<Value> = set.policies().map(|p| { let mut v = label(&set, p.id()); v.as_object_mut().unwrap().remove("text"); v }).collect();            json!({ "ok": true, "errors": [], "policies": list })        }    }}/// Checks policies against a schema, Cedar schema text or JSON: a misspelled attribute, a type that cannot fit, an action applied to what it does not take. `{ ok, errors, warnings }`, each with `message` and `line`, in strict mode.pub fn cedar_validate(policies: &str, schema_value: serde_json::Value) -> Result<Value, String> {    let set = parse(policies)?;    let sch = schema(&schema_value)?.ok_or("no schema given")?;    let r = Validator::new(sch).validate(&set, ValidationMode::Strict);    let errors: Vec<Value> = r.validation_errors().map(|e| described(e, policies)).collect();    let warnings: Vec<Value> = r.validation_warnings().map(|e| described(e, policies)).collect();    Ok(json!({ "ok": errors.is_empty(), "errors": errors, "warnings": warnings }))}/// Who may do what, one line per pair of `principals`, `actions` and `resources`: a small access matrix for an admin page. Each cell is `{ principal, action, resource, allowed, why }`.pub fn cedar_matrix(policies: &str, entities: serde_json::Value, principals: Vec<String>, actions: Vec<String>, resources: Vec<String>) -> Result<Vec<Value>, String> {    let mut out = vec![];    for p in &principals {        for a in &actions {            for r in &resources {                let d = cedar_authorize(policies, entities.clone(), json!({ "principal": p, "action": a, "resource": r }), Value::Null)?;                out.push(json!({ "principal": p, "action": a, "resource": r, "allowed": d["allowed"], "why": d["why"] }));            }        }    }    Ok(out)}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

On Cedar policies: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.