diff --git a/Cargo.lock b/Cargo.lock index 2147fd66d01..c41bce6fe07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -1034,6 +1043,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +[[package]] +name = "borsh" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5430e3be710b68d984d1391c854eb431a9d548640711faa54eecb1df93db91cc" +dependencies = [ + "cfg_aliases", +] + [[package]] name = "bs58" version = "0.5.1" @@ -1166,6 +1184,30 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy-core" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ceab22d0143092919782797f5d58bf165682d38b464be42f186d443cd5d24aa" +dependencies = [ + "educe 0.6.0", + "either", + "itertools 0.14.0", + "lalrpop", + "lalrpop-util", + "lazy_static", + "miette", + "nonempty", + "ref-cast", + "rustc_lexer", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.11", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -2022,6 +2064,18 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "effect-dns-hickory" version = "0.0.0" @@ -2046,6 +2100,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -2274,6 +2337,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.0.35" @@ -2945,12 +3014,17 @@ dependencies = [ name = "hash-graph-authorization" version = "0.0.0" dependencies = [ + "cedar-policy-core", "derive-where", + "derive_more 2.0.1", "error-stack", "futures", "futures-core", "hash-codec", "hash-graph-types", + "indoc", + "postgres-types", + "pretty_assertions", "reqwest", "serde", "serde_json", @@ -3423,6 +3497,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "hostname" version = "0.3.1" @@ -3901,6 +3984,12 @@ dependencies = [ "serde", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inferno" version = "0.12.1" @@ -4130,6 +4219,47 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7e253b574775d0ebd7975c471fc18f72f0775a4d42b563b5fbc3c4068aa1075" +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7047a26de42016abf8f181b46b398aef0b77ad46711df41847f6ed869a2a1d5b" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph 0.7.1", + "pico-args", + "regex", + "regex-syntax 0.8.5", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d05b3fe34b8bd562c338db725dfa9beb9451a48f65f129ccb9538b48d2c93b" +dependencies = [ + "regex-automata 0.4.9", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4750,6 +4880,30 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "miette" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a955165f87b37fd1862df2a59547ac542c77ef6d17c666f619d1ad22dd89484" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "thiserror 1.0.69", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf45bf44ab49be92fd1227a3be6fc6f617f1a337c06af54981048574d8783147" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "mimalloc" version = "0.1.43" @@ -5046,6 +5200,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "303e8749c804ccd6ca3b428de7fe0d86cb86bc7606bc15291f100fd487960bb8" + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5530,10 +5690,20 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap 1.9.3", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.7.1", +] + [[package]] name = "phf" version = "0.11.3" @@ -5552,6 +5722,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.9" @@ -5746,6 +5922,12 @@ dependencies = [ "zerocopy 0.7.35", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -5911,7 +6093,7 @@ dependencies = [ "log", "multimap", "once_cell", - "petgraph", + "petgraph 0.6.3", "prettyplease", "prost", "prost-types", @@ -5988,6 +6170,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "qoi" version = "0.4.1" @@ -6555,6 +6746,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -7098,6 +7298,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -7193,6 +7403,16 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +[[package]] +name = "smol_str" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "snow" version = "0.9.6" @@ -7242,6 +7462,19 @@ dependencies = [ "der", ] +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -7254,6 +7487,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +[[package]] +name = "string_cache" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938d512196766101d333398efde81bc1f37b00cb42c2f8350e5df639f040bbbe" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -7547,6 +7792,16 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "term" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3bb6001afcea98122260987f8b7b5da969ecad46dbf0b5453702f776b491a41" +dependencies = [ + "home", + "windows-sys 0.52.0", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -7859,7 +8114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf600e7036b17782571dd44fa0a5cea3c82f60db5137f774a325a76a0d6852b" dependencies = [ "bytes", - "educe", + "educe 0.5.11", "futures-core", "futures-sink", "pin-project", diff --git a/Cargo.toml b/Cargo.toml index b252a5f8747..dc4806ab608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,7 @@ aws-config = { version = "=1.5.16" } aws-sdk-s3 = { version = "=1.76.0", default-features = false } bitvec = { version = "=1.0.1", default-features = false } bytes-utils = { version = "=0.1.4", default-features = false } +cedar-policy-core = { version = "=4.3.1", default-features = false } clap = { version = "=4.5.30", features = ["color", "error-context", "help", "std", "suggestions", "usage"] } clap_complete = { version = "=4.5.45", default-features = false } convert_case = { version = "=0.7.1", default-features = false } @@ -177,6 +178,7 @@ hifijson = { version = "=0.2.2", default-features = false } humansize = { version = "=2.1.3", default-features = false } hyper = { version = "=1.6.0", default-features = false } include_dir = { version = "=0.7.4", default-features = false } +indoc = { version = "=2.0.5", default-features = false } insta = { version = "=1.42.1", default-features = false } itertools = { version = "=0.14.0", default-features = false } jsonschema = { version = "=0.29.0", default-features = false } diff --git a/libs/@local/graph/authorization/Cargo.toml b/libs/@local/graph/authorization/Cargo.toml index dd36e29e4a6..b45d1873ca5 100644 --- a/libs/@local/graph/authorization/Cargo.toml +++ b/libs/@local/graph/authorization/Cargo.toml @@ -20,22 +20,29 @@ hash-codec = { workspace = true } type-system = { workspace = true } # Private third-party dependencies -derive-where = { workspace = true } -futures = { workspace = true } -serde = { workspace = true, features = ["derive", "unstable"] } -serde_json = { workspace = true } -serde_plain = { workspace = true } -tokio = { workspace = true } -tokio-util = { workspace = true, features = ["io"] } -tracing = { workspace = true, features = ["attributes"] } -utoipa = { workspace = true, optional = true } -uuid = { workspace = true } +cedar-policy-core = { workspace = true } +derive-where = { workspace = true } +derive_more = { workspace = true, features = ["display", "error", "from"] } +futures = { workspace = true } +postgres-types = { workspace = true, features = ["derive", "with-uuid-1"], optional = true } +serde = { workspace = true, features = ["derive", "unstable"] } +serde_json = { workspace = true } +serde_plain = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true, features = ["io"] } +tracing = { workspace = true, features = ["attributes"] } +utoipa = { workspace = true, optional = true } +uuid = { workspace = true } [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +indoc = { workspace = true } +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +uuid = { workspace = true, features = ["v4"] } [features] -utoipa = ["dep:utoipa"] +utoipa = ["dep:utoipa"] +postgres = ["dep:postgres-types"] [lints] workspace = true diff --git a/libs/@local/graph/authorization/src/lib.rs b/libs/@local/graph/authorization/src/lib.rs index 3ba383c6a65..7558dc6a6e0 100644 --- a/libs/@local/graph/authorization/src/lib.rs +++ b/libs/@local/graph/authorization/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; pub mod backend; +pub mod policies; pub mod schema; pub mod zanzibar; @@ -343,3 +344,43 @@ where Ok(self.clone()) } } + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")] +mod test_utils { + use core::{error::Error, fmt}; + + use pretty_assertions::assert_eq; + use serde::{Deserialize, Serialize}; + use serde_json::Value as JsonValue; + + #[track_caller] + pub(crate) fn check_serialization(constraint: &T, value: JsonValue) + where + T: fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>, + { + let serialized = serde_json::to_value(constraint).expect("should be JSON representable"); + assert_eq!(serialized, value); + let deserialized: T = + serde_json::from_value(value).expect("should be a valid resource constraint"); + assert_eq!(*constraint, deserialized); + } + + #[track_caller] + pub(crate) fn check_deserialization_error( + value: JsonValue, + error: impl AsRef, + ) -> Result<(), Box> + where + T: fmt::Debug + Serialize + for<'de> Deserialize<'de>, + { + match serde_json::from_value::(value) { + Ok(value) => panic!( + "should not be a valid resource constraint: {:#}", + serde_json::to_value(&value)? + ), + Err(actual_error) => assert_eq!(actual_error.to_string(), error.as_ref()), + } + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/action/mod.rs b/libs/@local/graph/authorization/src/policies/action/mod.rs new file mode 100644 index 00000000000..8298fe4cc2f --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/action/mod.rs @@ -0,0 +1,238 @@ +#![expect( + clippy::empty_enum, + reason = "serde::Deserialize does not use the never-type" +)] + +use alloc::sync::Arc; +use core::{error::Error, fmt, str::FromStr}; +use std::sync::LazyLock; + +use cedar_policy_core::ast; +use error_stack::{Report, ResultExt as _, TryReportIteratorExt as _}; +use serde::Serialize as _; + +use crate::policies::cedar::CedarEntityId; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ActionId { + View, + ViewProperties, + ViewMetadata, +} + +impl CedarEntityId for ActionId { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Action"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(serde_plain::from_str(eid.as_ref())?) + } +} + +impl FromStr for ActionId { + type Err = Report; + + fn from_str(action: &str) -> Result { + Ok(serde_plain::from_str(action)?) + } +} + +impl fmt::Display for ActionId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + self.serialize(fmt) + } +} + +impl ActionId { + #[must_use] + pub const fn parents(self) -> &'static [Self] { + match self { + Self::View => &[], + Self::ViewProperties | Self::ViewMetadata => &[Self::View], + } + } +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub(crate) enum InvalidActionConstraint { + #[display("Invalid action in constraint")] + InvalidAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ActionConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + All {}, + One { + action: ActionId, + }, + Many { + actions: Vec, + }, +} + +impl ActionConstraint { + pub(crate) fn try_from_cedar( + constraint: &ast::ActionConstraint, + ) -> Result> { + Ok(match constraint { + ast::ActionConstraint::Any => Self::All {}, + ast::ActionConstraint::Eq(action) => Self::One { + action: ActionId::from_euid(action) + .change_context(InvalidActionConstraint::InvalidAction)?, + }, + ast::ActionConstraint::In(actions) => Self::Many { + actions: actions + .iter() + .map(|action| ActionId::from_euid(action)) + .try_collect_reports() + .change_context(InvalidActionConstraint::InvalidAction)?, + }, + }) + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::ActionConstraint { + match self { + Self::All {} => ast::ActionConstraint::any(), + Self::One { action } => ast::ActionConstraint::is_eq(action.to_euid()), + Self::Many { actions } => { + ast::ActionConstraint::is_in(actions.iter().map(ActionId::to_euid)) + } + } + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")] +mod tests { + use core::error::Error; + + use pretty_assertions::assert_eq; + use serde_json::{Value as JsonValue, json}; + + use super::ActionConstraint; + use crate::{ + policies::ActionId, + test_utils::{check_deserialization_error, check_serialization}, + }; + + #[track_caller] + pub(crate) fn check_action( + constraint: &ActionConstraint, + value: JsonValue, + cedar_string: impl AsRef, + ) -> Result<(), Box> { + check_serialization(constraint, value); + + let cedar_constraint = constraint.to_cedar(); + assert_eq!(cedar_constraint.to_string(), cedar_string.as_ref()); + ActionConstraint::try_from_cedar(&cedar_constraint)?; + Ok(()) + } + + #[test] + fn constraint_all() -> Result<(), Box> { + check_action( + &ActionConstraint::All {}, + json!({ + "type": "all", + }), + "action", + )?; + + check_deserialization_error::( + json!({ + "type": "all", + "additional": "unexpected" + }), + "unknown field `additional`, there are no fields", + )?; + + Ok(()) + } + + #[test] + fn constraint_one() -> Result<(), Box> { + let action = ActionId::ViewProperties; + check_action( + &ActionConstraint::One { action }, + json!({ + "type": "one", + "action": action, + }), + format!(r#"action == HASH::Action::"{action}""#), + )?; + + check_deserialization_error::( + json!({ + "type": "one", + }), + "missing field `action`", + )?; + + check_deserialization_error::( + json!({ + "type": "one", + "action": action, + "additional": "unexpected", + }), + "unknown field `additional`, expected `action`", + )?; + + Ok(()) + } + + #[test] + fn constraint_many() -> Result<(), Box> { + let actions = [ActionId::ViewProperties, ActionId::ViewMetadata]; + check_action( + &ActionConstraint::Many { + actions: actions.to_vec(), + }, + json!({ + "type": "many", + "actions": actions, + }), + format!( + r#"action in [HASH::Action::"{}",HASH::Action::"{}"]"#, + actions[0], actions[1] + ), + )?; + + check_deserialization_error::( + json!({ + "type": "many", + }), + "missing field `actions`", + )?; + + check_deserialization_error::( + json!({ + "type": "many", + "actions": actions, + "additional": "unexpected", + }), + "unknown field `additional`, expected `actions`", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/cedar.rs b/libs/@local/graph/authorization/src/policies/cedar.rs new file mode 100644 index 00000000000..3db2fffde02 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/cedar.rs @@ -0,0 +1,54 @@ +use alloc::sync::Arc; +use core::{error::Error, iter}; + +use cedar_policy_core::ast; +use error_stack::{Report, ResultExt as _, ensure}; + +use crate::policies::error::FromCedarRefernceError; + +pub(crate) trait CedarEntityId: Sized + 'static { + fn entity_type() -> &'static Arc; + + fn to_eid(&self) -> ast::Eid; + + fn to_euid(&self) -> ast::EntityUID { + ast::EntityUID::from_components( + ast::EntityType::clone(Self::entity_type()), + self.to_eid(), + None, + ) + } + + fn from_eid(eid: &ast::Eid) -> Result>; + + fn from_euid(euid: &ast::EntityUID) -> Result> { + let entity_type = Self::entity_type(); + ensure!( + *euid.entity_type() == **entity_type, + FromCedarRefernceError::UnexpectedEntityType { + expected: ast::EntityType::clone(entity_type), + actual: euid.entity_type().clone(), + } + ); + Self::from_eid(euid.eid()).change_context(FromCedarRefernceError::FromCedarIdError) + } +} + +pub(crate) fn cedar_resource_type( + names: [&'static str; N], +) -> Arc { + let [namespaces @ .., name] = names.as_slice() else { + panic!("names should not be empty") + }; + + Arc::new(ast::EntityType::from( + ast::Name::try_from(ast::InternalName::new( + name.parse().expect("name should be valid"), + iter::once(&"HASH") + .chain(namespaces) + .map(|namespace| namespace.parse().expect("namespace should be valid")), + None, + )) + .expect("name should be valid"), + )) +} diff --git a/libs/@local/graph/authorization/src/policies/error.rs b/libs/@local/graph/authorization/src/policies/error.rs new file mode 100644 index 00000000000..5805eaa38fb --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/error.rs @@ -0,0 +1,13 @@ +use cedar_policy_core::ast; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("Could not convert from Cedar Entity Reference")] +pub(crate) enum FromCedarRefernceError { + #[display("Wrong entity type, expected {expected}, got {actual}")] + UnexpectedEntityType { + actual: ast::EntityType, + expected: ast::EntityType, + }, + #[display("Could not convert from Cedar Entity ID")] + FromCedarIdError, +} diff --git a/libs/@local/graph/authorization/src/policies/mod.rs b/libs/@local/graph/authorization/src/policies/mod.rs new file mode 100644 index 00000000000..30d17fcb6a4 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/mod.rs @@ -0,0 +1,322 @@ +pub mod error; + +pub(crate) use self::cedar::cedar_resource_type; +pub use self::{ + action::{ActionConstraint, ActionId}, + principal::{ + OrganizationId, OrganizationPrincipalConstraint, OrganizationRoleId, PrincipalConstraint, + UserId, UserPrincipalConstraint, + }, + resource::{EntityResourceConstraint, ResourceConstraint}, +}; +mod action; +mod cedar; +mod principal; +mod resource; + +use alloc::{collections::BTreeMap, sync::Arc}; +use core::{error::Error, fmt, str::FromStr as _}; +use std::collections::HashMap; + +use cedar::CedarEntityId as _; +use cedar_policy_core::{ + ast, entities::Entities, evaluator::Evaluator, extensions::Extensions, + parser::parse_policy_or_template_to_est_and_ast, +}; +use error_stack::{Report, ResultExt as _}; +use hash_graph_types::knowledge::entity::EntityUuid; +use uuid::Uuid; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Effect { + Permit, + Forbid, +} + +#[derive( + Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr( + feature = "postgres", + derive(postgres_types::FromSql, postgres_types::ToSql), + postgres(transparent) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[repr(transparent)] +pub struct PolicyId(Uuid); + +impl PolicyId { + #[must_use] + pub const fn new(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } + + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for PolicyId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Policy { + pub id: PolicyId, + pub effect: Effect, + pub principal: PrincipalConstraint, + pub action: ActionConstraint, + pub resource: ResourceConstraint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub constraints: Option<()>, +} + +pub struct User { + pub id: UserId, +} + +#[non_exhaustive] +pub struct RequestContext; + +impl RequestContext { + #[expect( + clippy::unused_self, + reason = "More fields will be added to the context" + )] + pub(crate) fn to_cedar(&self) -> ast::Context { + ast::Context::Value(Arc::new(BTreeMap::new())) + } +} + +pub struct Request { + user: User, + action: ActionId, + resource: EntityUuid, + context: RequestContext, +} + +impl Request { + pub(crate) fn to_cedar(&self) -> Result> { + Ok(ast::Request::new( + (self.user.id.to_euid(), None), + (self.action.to_euid(), None), + (self.resource.to_euid(), None), + self.context.to_cedar(), + None::<&ast::RequestSchemaAllPass>, + Extensions::none(), + )?) + } +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub enum InvalidPolicy { + #[display("Invalid policy id")] + InvalidId, + #[display("Invalid principal constraint")] + InvalidPrincipalConstraint, + #[display("Invalid action constraint")] + InvalidActionConstraint, + #[display("Invalid resource constraint")] + InvalidResourceConstraint, + #[display("Invalid policy syntax")] + InvalidSyntax, +} + +impl Policy { + pub(crate) fn try_from_cedar_template( + policy: &ast::Template, + ) -> Result> { + Ok(Self { + id: PolicyId::new( + Uuid::from_str(policy.id().as_ref()).change_context(InvalidPolicy::InvalidId)?, + ), + effect: match policy.effect() { + ast::Effect::Permit => Effect::Permit, + ast::Effect::Forbid => Effect::Forbid, + }, + principal: PrincipalConstraint::try_from_cedar(policy.principal_constraint()) + .change_context(InvalidPolicy::InvalidPrincipalConstraint)?, + action: ActionConstraint::try_from_cedar(policy.action_constraint()) + .change_context(InvalidPolicy::InvalidActionConstraint)?, + resource: ResourceConstraint::try_from_cedar(policy.resource_constraint()) + .change_context(InvalidPolicy::InvalidResourceConstraint)?, + constraints: None, + }) + } + + pub(crate) fn to_cedar(&self) -> ast::Template { + ast::Template::new( + ast::PolicyID::from_string(self.id.to_string()), + None, + ast::Annotations::new(), + match self.effect { + Effect::Permit => ast::Effect::Permit, + Effect::Forbid => ast::Effect::Forbid, + }, + self.principal.to_cedar(), + self.action.to_cedar(), + self.resource.to_cedar(), + ast::Expr::val(true), + ) + } + + /// Parses a policy from a string. + /// + /// If `policy_id` is not provided, a new [`PolicyId`] will be generated. + /// + /// # Errors + /// + /// - [`InvalidPolicy::InvalidSyntax`] if the Cedar policy is invalid. + /// - [`InvalidPolicy`] if the Cedar policy cannot be converted to a [`Policy`]. + pub fn parse_cedar_policy( + text: &str, + policy_id: Option, + ) -> Result> { + let (_, template) = parse_policy_or_template_to_est_and_ast( + Some(ast::PolicyID::from_string( + policy_id + .unwrap_or_else(|| PolicyId::new(Uuid::new_v4())) + .to_string(), + )), + text, + ) + .change_context(InvalidPolicy::InvalidSyntax)?; + Self::try_from_cedar_template(&template) + } + + /// Evaluates the policy for the given request. + /// + /// # Errors + /// + /// - [`Error`] if the policy is invalid. + // TODO: Use `Report` instead of `Box` + pub fn evaluate(&self, request: &Request) -> Result> { + let cedar_policy = Arc::new(self.to_cedar()); + let policy_id = cedar_policy.id().clone(); + let policy = ast::Template::link(cedar_policy, policy_id, HashMap::new())?; + let entities = Entities::new(); + let evaluator = Evaluator::new(request.to_cedar()?, &entities, Extensions::none()); + Ok(evaluator.evaluate(&policy)?) + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")] +mod tests { + use core::error::Error; + + use indoc::formatdoc; + use pretty_assertions::assert_eq; + use serde_json::{Value as JsonValue, json}; + use uuid::Uuid; + + use super::Policy; + use crate::test_utils::check_serialization; + + #[track_caller] + pub(crate) fn check_policy( + policy: &Policy, + value: JsonValue, + cedar_string: impl AsRef, + ) -> Result<(), Box> { + check_serialization(policy, value); + + let cedar_policy = policy.to_cedar(); + assert_eq!(cedar_policy.to_string(), cedar_string.as_ref()); + if !policy.principal.has_slot() && !policy.resource.has_slot() { + Policy::try_from_cedar_template(&cedar_policy)?; + } + Ok(()) + } + + mod serialization { + use core::error::Error; + + use hash_graph_types::knowledge::entity::EntityUuid; + + use super::*; + use crate::policies::{ + ActionConstraint, ActionId, Effect, EntityResourceConstraint, PolicyId, + PrincipalConstraint, Request, RequestContext, ResourceConstraint, User, UserId, + UserPrincipalConstraint, + }; + + #[test] + fn user_can_view_entity_uuid() -> Result<(), Box> { + let policy_id = PolicyId::new(Uuid::new_v4()); + let user_id = UserId::new(Uuid::new_v4()); + let entity_uuid = EntityUuid::new(Uuid::new_v4()); + + let policy = Policy { + id: policy_id, + effect: Effect::Permit, + principal: PrincipalConstraint::User(UserPrincipalConstraint::Exact { + user_id: Some(user_id), + }), + action: ActionConstraint::Many { + actions: vec![ActionId::View], + }, + resource: ResourceConstraint::Entity(EntityResourceConstraint::Exact { + entity_uuid: Some(entity_uuid), + }), + constraints: None, + }; + + check_policy( + &policy, + json!({ + "id": policy_id, + "effect": "permit", + "principal": { + "type": "user", + "userId": user_id, + }, + "action": { + "type": "many", + "actions": ["view"], + }, + "resource": { + "type": "entity", + "entityUuid": entity_uuid, + }, + }), + formatdoc!( + r#" + permit( + principal == HASH::User::"{user_id}", + action in [HASH::Action::"view"], + resource == HASH::Entity::"{entity_uuid}" + ) when {{ + true + }};"# + ), + )?; + + assert!(policy.evaluate(&Request { + user: User { id: user_id }, + action: ActionId::View, + resource: entity_uuid, + context: RequestContext, + })?); + + assert!(!policy.evaluate(&Request { + user: User { id: user_id }, + action: ActionId::ViewMetadata, + resource: entity_uuid, + context: RequestContext, + })?); + + Ok(()) + } + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/mod.rs b/libs/@local/graph/authorization/src/policies/principal/mod.rs new file mode 100644 index 00000000000..8d55c10df95 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/mod.rs @@ -0,0 +1,221 @@ +#![expect( + clippy::empty_enum, + reason = "serde::Deseiriealize does not use the never-type" +)] + +use cedar_policy_core::ast; +use error_stack::{Report, ResultExt as _, bail}; + +pub use self::{ + organization::{OrganizationId, OrganizationPrincipalConstraint, OrganizationRoleId}, + user::{UserId, UserPrincipalConstraint}, +}; +use super::cedar::CedarEntityId as _; + +mod organization; +mod user; + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum PrincipalConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + Public {}, + User(UserPrincipalConstraint), + Organization(OrganizationPrincipalConstraint), +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub(crate) enum InvalidPrincipalConstraint { + #[display("Cannot convert constraints containing slots")] + AmbiguousSlot, + #[error(ignore)] + #[display("Unexpected entity type: {_0}")] + UnexpectedEntityType(ast::EntityType), + #[display("Invalid principal ID")] + InvalidPrincipalId, +} + +impl PrincipalConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::Public {} => false, + Self::User(user) => user.has_slot(), + Self::Organization(organization) => organization.has_slot(), + } + } + + pub(crate) fn try_from_cedar( + constraint: &ast::PrincipalConstraint, + ) -> Result> { + Ok(match constraint.as_inner() { + ast::PrincipalOrResourceConstraint::Any => Self::Public {}, + + ast::PrincipalOrResourceConstraint::Is(principal_type) + if **principal_type == **UserId::entity_type() => + { + Self::User(UserPrincipalConstraint::Any {}) + } + ast::PrincipalOrResourceConstraint::Is(principal_type) => { + bail!(InvalidPrincipalConstraint::UnexpectedEntityType( + ast::EntityType::clone(principal_type) + )) + } + + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::EUID(principal)) + if *principal.entity_type() == **UserId::entity_type() => + { + Self::User(UserPrincipalConstraint::Exact { + user_id: Some( + UserId::from_eid(principal.eid()) + .change_context(InvalidPrincipalConstraint::InvalidPrincipalId)?, + ), + }) + } + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::EUID(principal)) => { + bail!(InvalidPrincipalConstraint::UnexpectedEntityType( + ast::EntityType::clone(principal.entity_type()) + )) + } + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::Slot(_)) => { + bail!(InvalidPrincipalConstraint::AmbiguousSlot) + } + + ast::PrincipalOrResourceConstraint::IsIn( + principal_type, + ast::EntityReference::EUID(principal), + ) if **principal_type == **UserId::entity_type() => { + if *principal.entity_type() == **OrganizationId::entity_type() { + Self::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InOrganization { + organization_id: Some( + OrganizationId::from_eid(principal.eid()).change_context( + InvalidPrincipalConstraint::InvalidPrincipalId, + )?, + ), + }, + )) + } else if *principal.entity_type() == **OrganizationRoleId::entity_type() { + Self::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InRole { + organization_role_id: Some( + OrganizationRoleId::from_eid(principal.eid()).change_context( + InvalidPrincipalConstraint::InvalidPrincipalId, + )?, + ), + }, + )) + } else { + bail!(InvalidPrincipalConstraint::UnexpectedEntityType( + ast::EntityType::clone(principal.entity_type()) + )) + } + } + ast::PrincipalOrResourceConstraint::IsIn( + principal_type, + ast::EntityReference::EUID(_), + ) => bail!(InvalidPrincipalConstraint::UnexpectedEntityType( + ast::EntityType::clone(principal_type) + )), + ast::PrincipalOrResourceConstraint::IsIn(_, ast::EntityReference::Slot(_)) => { + bail!(InvalidPrincipalConstraint::AmbiguousSlot) + } + + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::EUID(principal)) + if *principal.entity_type() == **OrganizationId::entity_type() => + { + Self::Organization(OrganizationPrincipalConstraint::InOrganization { + organization_id: Some( + OrganizationId::from_eid(principal.eid()) + .change_context(InvalidPrincipalConstraint::InvalidPrincipalId)?, + ), + }) + } + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::EUID(principal)) + if *principal.entity_type() == **OrganizationRoleId::entity_type() => + { + // Organization from cedar (Some(principal)) + Self::Organization(OrganizationPrincipalConstraint::InRole { + organization_role_id: Some( + OrganizationRoleId::from_eid(principal.eid()) + .change_context(InvalidPrincipalConstraint::InvalidPrincipalId)?, + ), + }) + } + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::EUID(principal)) => { + bail!(InvalidPrincipalConstraint::UnexpectedEntityType( + ast::EntityType::clone(principal.entity_type()) + )) + } + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::Slot(_)) => { + bail!(InvalidPrincipalConstraint::AmbiguousSlot) + } + }) + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::PrincipalConstraint { + match self { + Self::Public {} => ast::PrincipalConstraint::any(), + Self::User(user) => user.to_cedar(), + Self::Organization(organization) => organization.to_cedar(), + } + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")] +mod tests { + use core::error::Error; + + use pretty_assertions::assert_eq; + use serde_json::{Value as JsonValue, json}; + + use super::PrincipalConstraint; + use crate::test_utils::{check_deserialization_error, check_serialization}; + + #[track_caller] + pub(crate) fn check_principal( + constraint: &PrincipalConstraint, + value: JsonValue, + cedar_string: impl AsRef, + ) -> Result<(), Box> { + check_serialization(constraint, value); + + let cedar_constraint = constraint.to_cedar(); + assert_eq!(cedar_constraint.to_string(), cedar_string.as_ref()); + if !constraint.has_slot() { + PrincipalConstraint::try_from_cedar(&cedar_constraint)?; + } + Ok(()) + } + + #[test] + fn constraint_public() -> Result<(), Box> { + check_principal( + &PrincipalConstraint::Public {}, + json!({ + "type": "public", + }), + "principal", + )?; + + check_deserialization_error::( + json!({ + "type": "public", + "additional": "unexpected" + }), + "unknown field `additional`, there are no fields", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/organization.rs b/libs/@local/graph/authorization/src/policies/principal/organization.rs new file mode 100644 index 00000000000..0bbfb7ae3e1 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/organization.rs @@ -0,0 +1,288 @@ +use alloc::sync::Arc; +use core::{error::Error, fmt, str::FromStr as _}; +use std::sync::LazyLock; + +use cedar_policy_core::ast; +use error_stack::Report; +use uuid::Uuid; + +use crate::policies::cedar::CedarEntityId; + +#[derive( + Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr( + feature = "postgres", + derive(postgres_types::FromSql, postgres_types::ToSql), + postgres(transparent) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[repr(transparent)] +pub struct OrganizationId(Uuid); + +impl OrganizationId { + #[must_use] + pub const fn new(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } + + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for OrganizationId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +impl CedarEntityId for OrganizationId { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Organization"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.0.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive( + Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr( + feature = "postgres", + derive(postgres_types::FromSql, postgres_types::ToSql), + postgres(transparent) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[repr(transparent)] +pub struct OrganizationRoleId(Uuid); + +impl OrganizationRoleId { + #[must_use] + pub const fn new(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } + + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for OrganizationRoleId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +impl CedarEntityId for OrganizationRoleId { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Organization", "Role"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.0.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(untagged, rename_all_fields = "camelCase", deny_unknown_fields)] +pub enum OrganizationPrincipalConstraint { + InOrganization { + #[serde(deserialize_with = "Option::deserialize")] + organization_id: Option, + }, + InRole { + #[serde(deserialize_with = "Option::deserialize")] + organization_role_id: Option, + }, +} + +impl OrganizationPrincipalConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::InOrganization { + organization_id: Some(_), + } + | Self::InRole { + organization_role_id: Some(_), + } => false, + Self::InOrganization { + organization_id: None, + } + | Self::InRole { + organization_role_id: None, + } => true, + } + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::PrincipalConstraint { + match self { + Self::InOrganization { organization_id } => organization_id.map_or_else( + ast::PrincipalConstraint::is_in_slot, + |organization_id| { + ast::PrincipalConstraint::is_in(Arc::new(organization_id.to_euid())) + }, + ), + Self::InRole { + organization_role_id, + } => organization_role_id.map_or_else( + ast::PrincipalConstraint::is_in_slot, + |organization_role_id| { + ast::PrincipalConstraint::is_in(Arc::new(organization_role_id.to_euid())) + }, + ), + } + } + + #[must_use] + pub(crate) fn to_cedar_in_type(&self) -> ast::PrincipalConstraint { + match self { + Self::InOrganization { organization_id } => organization_id.map_or_else( + || ast::PrincipalConstraint::is_entity_type_in_slot(Arc::clone(C::entity_type())), + |organization_id| { + ast::PrincipalConstraint::is_entity_type_in( + Arc::clone(C::entity_type()), + Arc::new(organization_id.to_euid()), + ) + }, + ), + Self::InRole { + organization_role_id, + } => organization_role_id.map_or_else( + || ast::PrincipalConstraint::is_entity_type_in_slot(Arc::clone(C::entity_type())), + |organization_role_id| { + ast::PrincipalConstraint::is_entity_type_in( + Arc::clone(C::entity_type()), + Arc::new(organization_role_id.to_euid()), + ) + }, + ), + } + } +} + +#[cfg(test)] +mod tests { + use core::error::Error; + + use serde_json::json; + use uuid::Uuid; + + use super::OrganizationPrincipalConstraint; + use crate::{ + policies::{ + OrganizationId, OrganizationRoleId, PrincipalConstraint, + principal::tests::check_principal, + }, + test_utils::check_deserialization_error, + }; + + #[test] + fn in_organization() -> Result<(), Box> { + let organization_id = OrganizationId::new(Uuid::new_v4()); + check_principal( + &PrincipalConstraint::Organization(OrganizationPrincipalConstraint::InOrganization { + organization_id: Some(organization_id), + }), + json!({ + "type": "organization", + "organizationId": organization_id, + }), + format!(r#"principal in HASH::Organization::"{organization_id}""#), + )?; + + check_principal( + &PrincipalConstraint::Organization(OrganizationPrincipalConstraint::InOrganization { + organization_id: None, + }), + json!({ + "type": "organization", + "organizationId": null, + }), + "principal in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "organization", + }), + "data did not match any variant of untagged enum OrganizationPrincipalConstraint", + )?; + + check_deserialization_error::( + json!({ + "type": "organization", + "organizationId": organization_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum OrganizationPrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn in_role() -> Result<(), Box> { + let role_id = OrganizationRoleId::new(Uuid::new_v4()); + check_principal( + &PrincipalConstraint::Organization(OrganizationPrincipalConstraint::InRole { + organization_role_id: Some(role_id), + }), + json!({ + "type": "organization", + "organizationRoleId": role_id, + }), + format!(r#"principal in HASH::Organization::Role::"{role_id}""#), + )?; + + check_principal( + &PrincipalConstraint::Organization(OrganizationPrincipalConstraint::InRole { + organization_role_id: None, + }), + json!({ + "type": "organization", + "organizationRoleId": null, + }), + "principal in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "organization", + "roleId": role_id, + "organizationRoleId": Uuid::new_v4(), + }), + "data did not match any variant of untagged enum OrganizationPrincipalConstraint", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/user.rs b/libs/@local/graph/authorization/src/policies/principal/user.rs new file mode 100644 index 00000000000..1d938998ea2 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/user.rs @@ -0,0 +1,264 @@ +#![expect( + clippy::empty_enum, + reason = "serde::Deseiriealize does not use the never-type" +)] + +use alloc::sync::Arc; +use core::{error::Error, fmt, str::FromStr as _}; +use std::sync::LazyLock; + +use cedar_policy_core::ast; +use error_stack::Report; +use uuid::Uuid; + +use crate::policies::{ + cedar::CedarEntityId, principal::organization::OrganizationPrincipalConstraint, +}; + +#[derive( + Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr( + feature = "postgres", + derive(postgres_types::FromSql, postgres_types::ToSql), + postgres(transparent) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[repr(transparent)] +pub struct UserId(Uuid); + +impl UserId { + #[must_use] + pub const fn new(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } + + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for UserId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +impl CedarEntityId for UserId { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["User"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.0.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(untagged, rename_all_fields = "camelCase", deny_unknown_fields)] +pub enum UserPrincipalConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + Any {}, + Exact { + #[serde(deserialize_with = "Option::deserialize")] + user_id: Option, + }, + Organization(OrganizationPrincipalConstraint), +} + +impl UserPrincipalConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::Any {} | Self::Exact { user_id: Some(_) } => false, + Self::Exact { user_id: None } => true, + Self::Organization(organization) => organization.has_slot(), + } + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::PrincipalConstraint { + match self { + Self::Any {} => { + ast::PrincipalConstraint::is_entity_type(Arc::clone(UserId::entity_type())) + } + Self::Exact { user_id } => user_id + .map_or_else(ast::PrincipalConstraint::is_eq_slot, |user_id| { + ast::PrincipalConstraint::is_eq(Arc::new(user_id.to_euid())) + }), + Self::Organization(organization) => organization.to_cedar_in_type::(), + } + } +} + +#[cfg(test)] +mod tests { + use core::error::Error; + + use serde_json::json; + use uuid::Uuid; + + use super::OrganizationPrincipalConstraint; + use crate::{ + policies::{ + OrganizationId, OrganizationRoleId, PrincipalConstraint, UserId, + principal::{UserPrincipalConstraint, tests::check_principal}, + }, + test_utils::check_deserialization_error, + }; + + #[test] + fn any() -> Result<(), Box> { + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Any {}), + json!({ + "type": "user", + }), + "principal is HASH::User", + )?; + + check_deserialization_error::( + json!({ + "type": "user", + "additional": "unexpected", + }), + "data did not match any variant of untagged enum UserPrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn exact() -> Result<(), Box> { + let user_id = UserId::new(Uuid::new_v4()); + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Exact { + user_id: Some(user_id), + }), + json!({ + "type": "user", + "userId": user_id, + }), + format!(r#"principal == HASH::User::"{user_id}""#), + )?; + + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Exact { user_id: None }), + json!({ + "type": "user", + "userId": null, + }), + "principal == ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "user", + "userId": user_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum UserPrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn organization() -> Result<(), Box> { + let organization_id = OrganizationId::new(Uuid::new_v4()); + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InOrganization { + organization_id: Some(organization_id), + }, + )), + json!({ + "type": "user", + "organizationId": organization_id, + }), + format!(r#"principal is HASH::User in HASH::Organization::"{organization_id}""#), + )?; + + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InOrganization { + organization_id: None, + }, + )), + json!({ + "type": "user", + "organizationId": null, + }), + "principal is HASH::User in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "user", + "organizationId": organization_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum UserPrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn organization_role() -> Result<(), Box> { + let organization_role_id = OrganizationRoleId::new(Uuid::new_v4()); + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InRole { + organization_role_id: Some(organization_role_id), + }, + )), + json!({ + "type": "user", + "organizationRoleId": organization_role_id, + }), + format!( + r#"principal is HASH::User in HASH::Organization::Role::"{organization_role_id}""# + ), + )?; + + check_principal( + &PrincipalConstraint::User(UserPrincipalConstraint::Organization( + OrganizationPrincipalConstraint::InRole { + organization_role_id: None, + }, + )), + json!({ + "type": "user", + "organizationRoleId": null, + }), + "principal is HASH::User in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "user", + "organizationRoleId": organization_role_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum UserPrincipalConstraint", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/resource/entity.rs b/libs/@local/graph/authorization/src/policies/resource/entity.rs new file mode 100644 index 00000000000..17ba5d5381e --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/resource/entity.rs @@ -0,0 +1,190 @@ +use alloc::sync::Arc; +use core::{error::Error, str::FromStr as _}; +use std::sync::LazyLock; + +use cedar_policy_core::ast; +use error_stack::Report; +use hash_graph_types::{knowledge::entity::EntityUuid, owned_by_id::OwnedById}; +use uuid::Uuid; + +use crate::policies::cedar::CedarEntityId; + +impl CedarEntityId for EntityUuid { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Entity"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(untagged, rename_all_fields = "camelCase", deny_unknown_fields)] +pub enum EntityResourceConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + Any {}, + Exact { + #[serde(deserialize_with = "Option::deserialize")] + entity_uuid: Option, + }, + Web { + #[serde(deserialize_with = "Option::deserialize")] + web_id: Option, + }, +} + +impl EntityResourceConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::Any {} + | Self::Exact { + entity_uuid: Some(_), + } + | Self::Web { web_id: Some(_) } => false, + Self::Exact { entity_uuid: None } | Self::Web { web_id: None } => true, + } + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::ResourceConstraint { + match self { + Self::Any {} => { + ast::ResourceConstraint::is_entity_type(Arc::clone(EntityUuid::entity_type())) + } + Self::Exact { entity_uuid } => entity_uuid + .map_or_else(ast::ResourceConstraint::is_eq_slot, |entity_uuid| { + ast::ResourceConstraint::is_eq(Arc::new(entity_uuid.to_euid())) + }), + Self::Web { web_id } => web_id.map_or_else( + || { + ast::ResourceConstraint::is_entity_type_in_slot(Arc::clone( + EntityUuid::entity_type(), + )) + }, + |web_id| { + ast::ResourceConstraint::is_entity_type_in( + Arc::clone(EntityUuid::entity_type()), + Arc::new(web_id.to_euid()), + ) + }, + ), + } + } +} + +#[cfg(test)] +mod tests { + use core::error::Error; + + use hash_graph_types::{knowledge::entity::EntityUuid, owned_by_id::OwnedById}; + use serde_json::json; + use uuid::Uuid; + + use super::EntityResourceConstraint; + use crate::{ + policies::{ResourceConstraint, resource::tests::check_resource}, + test_utils::check_deserialization_error, + }; + + #[test] + fn constraint_any() -> Result<(), Box> { + check_resource( + &ResourceConstraint::Entity(EntityResourceConstraint::Any {}), + json!({ + "type": "entity" + }), + "resource is HASH::Entity", + )?; + + check_deserialization_error::( + json!({ + "type": "entity", + "additional": "unexpected" + }), + "data did not match any variant of untagged enum EntityResourceConstraint", + )?; + + Ok(()) + } + + #[test] + fn constraint_exact() -> Result<(), Box> { + let entity_uuid = EntityUuid::new(Uuid::new_v4()); + check_resource( + &ResourceConstraint::Entity(EntityResourceConstraint::Exact { + entity_uuid: Some(entity_uuid), + }), + json!({ + "type": "entity", + "entityUuid": entity_uuid, + }), + format!(r#"resource == HASH::Entity::"{entity_uuid}""#), + )?; + + check_resource( + &ResourceConstraint::Entity(EntityResourceConstraint::Exact { entity_uuid: None }), + json!({ + "type": "entity", + "entityUuid": null, + }), + "resource == ?resource", + )?; + + check_deserialization_error::( + json!({ + "type": "entity", + "webId": OwnedById::new(Uuid::new_v4()), + "entityUuid": entity_uuid, + }), + "data did not match any variant of untagged enum EntityResourceConstraint", + )?; + + Ok(()) + } + + #[test] + fn constraint_in_web() -> Result<(), Box> { + let web_id = OwnedById::new(Uuid::new_v4()); + check_resource( + &ResourceConstraint::Entity(EntityResourceConstraint::Web { + web_id: Some(web_id), + }), + json!({ + "type": "entity", + "webId": web_id, + }), + format!(r#"resource is HASH::Entity in HASH::Web::"{web_id}""#), + )?; + + check_resource( + &ResourceConstraint::Entity(EntityResourceConstraint::Web { web_id: None }), + json!({ + "type": "entity", + "webId": null, + }), + "resource is HASH::Entity in ?resource", + )?; + + check_deserialization_error::( + json!({ + "type": "entity", + "webId": OwnedById::new(Uuid::new_v4()), + "entityUuid": EntityUuid::new(Uuid::new_v4()), + }), + "data did not match any variant of untagged enum EntityResourceConstraint", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/resource/mod.rs b/libs/@local/graph/authorization/src/policies/resource/mod.rs new file mode 100644 index 00000000000..8ecd835491e --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/resource/mod.rs @@ -0,0 +1,266 @@ +#![expect( + clippy::empty_enum, + reason = "serde::Deseiriealize does not use the never-type" +)] + +use alloc::sync::Arc; +use core::{error::Error, str::FromStr as _}; +use std::sync::LazyLock; + +use cedar_policy_core::ast; +use error_stack::{Report, ResultExt as _, bail}; +use uuid::Uuid; + +pub use self::entity::EntityResourceConstraint; +use crate::policies::cedar::CedarEntityId; +mod entity; + +use hash_graph_types::{knowledge::entity::EntityUuid, owned_by_id::OwnedById}; + +impl CedarEntityId for OwnedById { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Web"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ResourceConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + Global {}, + Web { + #[serde(deserialize_with = "Option::deserialize")] + web_id: Option, + }, + Entity(EntityResourceConstraint), +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub(crate) enum InvalidResourceConstraint { + #[display("Cannot convert constraints containing slots")] + AmbiguousSlot, + #[error(ignore)] + #[display("Unexpected entity type: {_0}")] + UnexpectedEntityType(ast::EntityType), + #[display("Invalid resource ID")] + InvalidPrincipalId, +} + +impl ResourceConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::Global {} | Self::Web { web_id: Some(_) } => false, + Self::Web { web_id: None } => true, + Self::Entity(entity) => entity.has_slot(), + } + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::ResourceConstraint { + match self { + Self::Global {} => ast::ResourceConstraint::any(), + Self::Web { web_id } => web_id + .map_or_else(ast::ResourceConstraint::is_in_slot, |web_id| { + ast::ResourceConstraint::is_in(Arc::new(web_id.to_euid())) + }), + Self::Entity(entity) => entity.to_cedar(), + } + } + + pub(crate) fn try_from_cedar( + constraint: &ast::ResourceConstraint, + ) -> Result> { + Ok(match constraint.as_inner() { + ast::PrincipalOrResourceConstraint::Any => Self::Global {}, + + ast::PrincipalOrResourceConstraint::Is(resource_type) + if **resource_type == **EntityUuid::entity_type() => + { + Self::Entity(EntityResourceConstraint::Any {}) + } + ast::PrincipalOrResourceConstraint::Is(resource_type) => { + bail!(InvalidResourceConstraint::UnexpectedEntityType( + ast::EntityType::clone(resource_type) + )) + } + + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::EUID(resource)) + if *resource.entity_type() == **EntityUuid::entity_type() => + { + Self::Entity(EntityResourceConstraint::Exact { + entity_uuid: Some(EntityUuid::new( + Uuid::from_str(resource.eid().as_ref()) + .change_context(InvalidResourceConstraint::InvalidPrincipalId)?, + )), + }) + } + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::EUID(principal)) => { + bail!(InvalidResourceConstraint::UnexpectedEntityType( + principal.entity_type().clone() + )) + } + ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::Slot(_)) => { + bail!(InvalidResourceConstraint::AmbiguousSlot) + } + + ast::PrincipalOrResourceConstraint::IsIn( + resource_type, + ast::EntityReference::EUID(resource), + ) if **resource_type == **EntityUuid::entity_type() => { + if *resource.entity_type() == **OwnedById::entity_type() { + Self::Entity(EntityResourceConstraint::Web { + web_id: Some(OwnedById::new( + Uuid::from_str(resource.eid().as_ref()) + .change_context(InvalidResourceConstraint::InvalidPrincipalId)?, + )), + }) + } else { + bail!(InvalidResourceConstraint::UnexpectedEntityType( + resource.entity_type().clone() + )) + } + } + ast::PrincipalOrResourceConstraint::IsIn( + resource_type, + ast::EntityReference::EUID(_), + ) => bail!(InvalidResourceConstraint::UnexpectedEntityType( + ast::EntityType::clone(resource_type) + )), + ast::PrincipalOrResourceConstraint::IsIn(_, ast::EntityReference::Slot(_)) => { + bail!(InvalidResourceConstraint::AmbiguousSlot) + } + + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::EUID(resource)) + if *resource.entity_type() == **OwnedById::entity_type() => + { + Self::Web { + web_id: Some(OwnedById::new( + Uuid::from_str(resource.eid().as_ref()) + .change_context(InvalidResourceConstraint::InvalidPrincipalId)?, + )), + } + } + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::EUID(resource)) => { + bail!(InvalidResourceConstraint::UnexpectedEntityType( + resource.entity_type().clone() + )) + } + ast::PrincipalOrResourceConstraint::In(ast::EntityReference::Slot(_)) => { + bail!(InvalidResourceConstraint::AmbiguousSlot) + } + }) + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")] +mod tests { + use core::error::Error; + + use hash_graph_types::owned_by_id::OwnedById; + use pretty_assertions::assert_eq; + use serde_json::{Value as JsonValue, json}; + use uuid::Uuid; + + use super::ResourceConstraint; + use crate::test_utils::{check_deserialization_error, check_serialization}; + + #[track_caller] + pub(crate) fn check_resource( + constraint: &ResourceConstraint, + value: JsonValue, + cedar_string: impl AsRef, + ) -> Result<(), Box> { + check_serialization(constraint, value); + + let cedar_constraint = constraint.to_cedar(); + assert_eq!(cedar_constraint.to_string(), cedar_string.as_ref()); + if !constraint.has_slot() { + ResourceConstraint::try_from_cedar(&cedar_constraint)?; + } + + Ok(()) + } + + #[test] + fn constraint_any() -> Result<(), Box> { + check_resource( + &ResourceConstraint::Global {}, + json!({ + "type": "global", + }), + "resource", + )?; + + check_deserialization_error::( + json!({ + "type": "global", + "additional": "unexpected" + }), + "unknown field `additional`, there are no fields", + )?; + + Ok(()) + } + + #[test] + fn constraint_in_web() -> Result<(), Box> { + let web_id = OwnedById::new(Uuid::new_v4()); + check_resource( + &ResourceConstraint::Web { + web_id: Some(web_id), + }, + json!({ + "type": "web", + "webId": web_id, + }), + format!(r#"resource in HASH::Web::"{web_id}""#), + )?; + + check_resource( + &ResourceConstraint::Web { web_id: None }, + json!({ + "type": "web", + "webId": null, + }), + "resource in ?resource", + )?; + + check_deserialization_error::( + json!({ + "type": "web", + }), + "missing field `webId`", + )?; + + check_deserialization_error::( + json!({ + "type": "web", + "webId": web_id, + "additional": "unexpected", + }), + "unknown field `additional`, expected `webId`", + )?; + + Ok(()) + } +}