Skip to content

Commit

Permalink
H-4017: Implement types to define policies in Rust (#6355)
Browse files Browse the repository at this point in the history
  • Loading branch information
TimDiekmann authored Feb 19, 2025
1 parent c13bac2 commit 82fc0c8
Show file tree
Hide file tree
Showing 13 changed files with 2,176 additions and 15 deletions.
261 changes: 258 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
31 changes: 19 additions & 12 deletions libs/@local/graph/authorization/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions libs/@local/graph/authorization/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
extern crate alloc;

pub mod backend;
pub mod policies;
pub mod schema;
pub mod zanzibar;

Expand Down Expand Up @@ -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<T>(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<T>(
value: JsonValue,
error: impl AsRef<str>,
) -> Result<(), Box<dyn Error>>
where
T: fmt::Debug + Serialize + for<'de> Deserialize<'de>,
{
match serde_json::from_value::<T>(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(())
}
}
238 changes: 238 additions & 0 deletions libs/@local/graph/authorization/src/policies/action/mod.rs
Original file line number Diff line number Diff line change
@@ -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<ast::EntityType> {
static ENTITY_TYPE: LazyLock<Arc<ast::EntityType>> =
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<Self, Report<impl Error + Send + Sync + 'static>> {
Ok(serde_plain::from_str(eid.as_ref())?)
}
}

impl FromStr for ActionId {
type Err = Report<impl Error + Send + Sync + 'static>;

fn from_str(action: &str) -> Result<Self, Self::Err> {
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<ActionId>,
},
}

impl ActionConstraint {
pub(crate) fn try_from_cedar(
constraint: &ast::ActionConstraint,
) -> Result<Self, Report<InvalidActionConstraint>> {
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<str>,
) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
check_action(
&ActionConstraint::All {},
json!({
"type": "all",
}),
"action",
)?;

check_deserialization_error::<ActionConstraint>(
json!({
"type": "all",
"additional": "unexpected"
}),
"unknown field `additional`, there are no fields",
)?;

Ok(())
}

#[test]
fn constraint_one() -> Result<(), Box<dyn Error>> {
let action = ActionId::ViewProperties;
check_action(
&ActionConstraint::One { action },
json!({
"type": "one",
"action": action,
}),
format!(r#"action == HASH::Action::"{action}""#),
)?;

check_deserialization_error::<ActionConstraint>(
json!({
"type": "one",
}),
"missing field `action`",
)?;

check_deserialization_error::<ActionConstraint>(
json!({
"type": "one",
"action": action,
"additional": "unexpected",
}),
"unknown field `additional`, expected `action`",
)?;

Ok(())
}

#[test]
fn constraint_many() -> Result<(), Box<dyn Error>> {
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::<ActionConstraint>(
json!({
"type": "many",
}),
"missing field `actions`",
)?;

check_deserialization_error::<ActionConstraint>(
json!({
"type": "many",
"actions": actions,
"additional": "unexpected",
}),
"unknown field `additional`, expected `actions`",
)?;

Ok(())
}
}
Loading

0 comments on commit 82fc0c8

Please sign in to comment.