diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 71fb76a..c93039e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,6 +5,10 @@ on: branches: - main +permissions: + contents: read + packages: read + jobs: test: runs-on: ubuntu-latest @@ -29,9 +33,83 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy + - name: Install protobuf compiler + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - name: Run format check run: cargo fmt --check - name: Run clippy - run: cargo clippy -- -D warnings + run: cargo clippy --all-features -- -D warnings - name: Run tests - run: cargo test + run: cargo test --all-features + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start latest Anvil for backend tests + shell: bash + env: + ANVIL_IMAGE: ghcr.io/worka-ai/anvil:latest + ANVIL_SECRET_ENCRYPTION_KEY: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + JWT_SECRET: zanzibar-ci-jwt-secret + run: | + set -euo pipefail + docker pull "$ANVIL_IMAGE" + docker volume create zanzibar-anvil-e2e-data + + admin() { + docker run --rm \ + -e ANVIL_SECRET_ENCRYPTION_KEY="$ANVIL_SECRET_ENCRYPTION_KEY" \ + -e STORAGE_PATH=/var/lib/anvil \ + -v zanzibar-anvil-e2e-data:/var/lib/anvil \ + "$ANVIL_IMAGE" admin "$@" + } + + admin region create zanzibar-ci + admin tenant create zanzibar-ci + app_output=$(admin app create --tenant-name zanzibar-ci --app-name zanzibar-ci-app) + printf '%s\n' "$app_output" + client_id=$(printf '%s\n' "$app_output" | awk -F': ' '/Client ID:/ { print $2 }') + client_secret=$(printf '%s\n' "$app_output" | awk -F': ' '/Client Secret:/ { print $2 }') + test -n "$client_id" + test -n "$client_secret" + admin policy grant --app-name zanzibar-ci-app --action '*' --resource '*' + + docker run -d --name zanzibar-anvil-e2e \ + -p 50051:50051 \ + -e JWT_SECRET="$JWT_SECRET" \ + -e ANVIL_SECRET_ENCRYPTION_KEY="$ANVIL_SECRET_ENCRYPTION_KEY" \ + -e CLUSTER_SECRET=zanzibar-ci-cluster-secret \ + -e PUBLIC_API_ADDR=http://127.0.0.1:50051 \ + -e API_LISTEN_ADDR=0.0.0.0:50051 \ + -e CLUSTER_LISTEN_ADDR=/ip4/0.0.0.0/udp/0/quic-v1 \ + -e REGION=zanzibar-ci \ + -e INIT_CLUSTER=true \ + -e ENABLE_MDNS=false \ + -e STORAGE_PATH=/var/lib/anvil \ + -v zanzibar-anvil-e2e-data:/var/lib/anvil \ + "$ANVIL_IMAGE" + + for _ in {1..120}; do + if curl -fsS http://127.0.0.1:50051/ready >/dev/null; then + break + fi + sleep 1 + done + curl -fsS http://127.0.0.1:50051/ready >/dev/null + + { + echo 'ANVIL_E2E_GRPC=http://127.0.0.1:50051' + echo "ANVIL_E2E_CLIENT_ID=$client_id" + echo "ANVIL_E2E_CLIENT_SECRET=$client_secret" + } >> "$GITHUB_ENV" + + - name: Run Anvil backend tests + run: cargo test --features anvil --test anvil_backend -- --ignored --nocapture --test-threads=1 + + - name: Dump Anvil logs + if: always() + run: docker logs zanzibar-anvil-e2e || true diff --git a/Cargo.toml b/Cargo.toml index ddfcac1..9fb72f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/worka-ai/zanzibar" [dependencies] anyhow = "1.0" +anvil-storage = { git = "https://github.com/worka-ai/anvil.git", branch = "main", optional = true } async-trait = "0.1.89" moka = { version = "0.12.14", features = ["future"] } serde = { version = "1.0", features = ["derive"] } @@ -21,8 +22,13 @@ serde_json = "1.0" sqlx = { version = "0.7.4", features = ["postgres", "runtime-tokio-rustls", "json"] } thiserror = "1.0" tokio = { version = "1.49.0", features = ["full"] } +tonic = { version = "0.14", optional = true } tracing = "0.1" +[features] +default = [] +anvil = ["dep:anvil-storage", "dep:tonic"] + [dev-dependencies] proptest = "1.6.0" criterion = { version = "0.5.1", features = ["async_tokio"] } @@ -32,4 +38,4 @@ futures = "0.3" [[bench]] name = "postgres_scale" -harness = false \ No newline at end of file +harness = false diff --git a/src/anvil.rs b/src/anvil.rs new file mode 100644 index 0000000..7eb034a --- /dev/null +++ b/src/anvil.rs @@ -0,0 +1,896 @@ +use crate::{ + CheckRequest, NamespaceConfig, Object, RebacEngine, RebacError, RelationRule, Schema, Subject, + Tuple, TupleUpdate, +}; +use anvil_storage::{AnvilClient, proto}; +use async_trait::async_trait; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tonic::Streaming; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnvilConsistencyToken { + pub revision: u64, + pub zookie: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum AnvilConsistency { + #[default] + Latest, + Exact(String), + AtLeast(String), +} + +impl AnvilConsistency { + fn as_request_parts(&self) -> (&'static str, String) { + match self { + Self::Latest => ("latest", String::new()), + Self::Exact(zookie) => ("exact", zookie.clone()), + Self::AtLeast(zookie) => ("at_least", zookie.clone()), + } + } +} + +#[derive(Clone)] +pub struct AnvilRebacEngine { + client: AnvilClient, + schemas: Arc>>, +} + +impl AnvilRebacEngine { + pub fn new(client: AnvilClient) -> Self { + Self { + client, + schemas: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn write_tuples_with_zookie( + &self, + updates: Vec, + ) -> Result, RebacError> { + if updates.is_empty() { + return Ok(None); + } + let mutations = updates + .into_iter() + .map(tuple_update_to_mutation) + .collect::, _>>()?; + let response = self + .client + .auth() + .write_authz_tuples(proto::WriteAuthzTuplesRequest { mutations }) + .await + .map_err(anvil_status)? + .into_inner(); + Ok(Some(AnvilConsistencyToken { + revision: response.revision, + zookie: response.zookie, + })) + } + + pub async fn read_tuples_with_consistency( + &self, + object: Option, + relation: Option, + subject: Option, + consistency: AnvilConsistency, + ) -> Result<(Vec, AnvilConsistencyToken), RebacError> { + let (subject_kind, subject_id) = match subject.as_ref() { + Some(subject) => { + let encoded = encode_subject(subject)?; + (encoded.subject_kind, encoded.subject_id) + } + None => (String::new(), String::new()), + }; + let (consistency, zookie) = consistency.as_request_parts(); + let mut client = self.client.auth(); + let mut page_token = String::new(); + let mut tuples = Vec::new(); + let token = loop { + let response = client + .read_authz_tuples(proto::ReadAuthzTuplesRequest { + namespace: object + .as_ref() + .map(|object| object.namespace.clone()) + .unwrap_or_default(), + object_id: object + .as_ref() + .map(|object| object.id.clone()) + .unwrap_or_default(), + relation: relation.clone().unwrap_or_default(), + subject_kind: subject_kind.clone(), + subject_id: subject_id.clone(), + caveat_hash: String::new(), + consistency: consistency.to_string(), + zookie: zookie.clone(), + page_size: 1000, + page_token, + }) + .await + .map_err(anvil_status)? + .into_inner(); + let response_token = AnvilConsistencyToken { + revision: response.revision, + zookie: response.zookie.clone(), + }; + for tuple in response.tuples { + tuples.push(tuple_from_proto(tuple)?); + } + if response.next_page_token.is_empty() { + break response_token; + } + page_token = response.next_page_token; + }; + Ok((tuples, token)) + } + + pub async fn check_with_consistency( + &self, + tenant_id: i64, + subject: &Subject, + relation: &str, + object: &Object, + consistency: AnvilConsistency, + ) -> Result<(bool, AnvilConsistencyToken), RebacError> { + let schema = self.schema_for_tenant(tenant_id).await?; + let (tuples, token) = self + .read_tuples_with_consistency(None, None, None, consistency) + .await?; + let view = TupleView::new(tuples); + Ok((view.check(&schema, object, relation, subject), token)) + } + + pub async fn watch_tuple_log( + &self, + after_revision: u64, + namespace: impl Into, + ) -> Result, RebacError> { + self.client + .auth() + .watch_authz_tuple_log(proto::WatchAuthzTupleLogRequest { + after_revision, + namespace: namespace.into(), + }) + .await + .map(|response| response.into_inner()) + .map_err(anvil_status) + } +} + +#[async_trait] +impl RebacEngine for AnvilRebacEngine { + async fn apply_schema(&self, tenant_id: i64, schema: Schema) -> Result<(), RebacError> { + validate_schema(&schema)?; + let namespaces = schema_to_proto_namespaces(&schema)?; + self.client + .auth() + .apply_authz_schema(proto::ApplyAuthzSchemaRequest { + namespaces, + reason: "zanzibar schema apply".to_string(), + }) + .await + .map_err(anvil_status)?; + self.schemas.write().await.insert(tenant_id, schema); + Ok(()) + } + + async fn write_tuples( + &self, + _tenant_id: i64, + updates: Vec, + ) -> Result<(), RebacError> { + self.write_tuples_with_zookie(updates).await?; + Ok(()) + } + + async fn read_tuples( + &self, + _tenant_id: i64, + object: Option, + relation: Option, + subject: Option, + ) -> Result, RebacError> { + self.read_tuples_with_consistency(object, relation, subject, AnvilConsistency::Latest) + .await + .map(|(tuples, _)| tuples) + } + + async fn check( + &self, + tenant_id: i64, + subject: &Subject, + relation: &str, + object: &Object, + ) -> Result { + self.check_with_consistency( + tenant_id, + subject, + relation, + object, + AnvilConsistency::Latest, + ) + .await + .map(|(allowed, _)| allowed) + } + + async fn check_many( + &self, + tenant_id: i64, + requests: Vec, + ) -> Result, RebacError> { + let schema = self.schema_for_tenant(tenant_id).await?; + let tuples = self.read_tuples(tenant_id, None, None, None).await?; + let view = TupleView::new(tuples); + Ok(requests + .into_iter() + .map(|request| { + view.check( + &schema, + &request.object, + &request.relation, + &request.subject, + ) + }) + .collect()) + } + + async fn list_objects( + &self, + tenant_id: i64, + subject: &Subject, + relation: &str, + object_namespace: &str, + ) -> Result, RebacError> { + let schema = self.schema_for_tenant(tenant_id).await?; + let tuples = self.read_tuples(tenant_id, None, None, None).await?; + let view = TupleView::new(tuples); + Ok(view.list_objects(&schema, subject, relation, object_namespace)) + } + + async fn list_subjects( + &self, + tenant_id: i64, + object: &Object, + relation: &str, + subject_namespace: &str, + ) -> Result, RebacError> { + let schema = self.schema_for_tenant(tenant_id).await?; + let tuples = self.read_tuples(tenant_id, None, None, None).await?; + let view = TupleView::new(tuples); + Ok(view.list_subjects(&schema, object, relation, subject_namespace)) + } +} + +impl AnvilRebacEngine { + async fn schema_for_tenant(&self, tenant_id: i64) -> Result { + if let Some(schema) = self.schemas.read().await.get(&tenant_id).cloned() { + return Ok(schema); + } + let response = self + .client + .auth() + .get_authz_schema(proto::GetAuthzSchemaRequest { + namespace: String::new(), + }) + .await + .map_err(anvil_status)? + .into_inner(); + let schema = schema_from_proto_namespaces(response.namespaces)?; + self.schemas.write().await.insert(tenant_id, schema.clone()); + Ok(schema) + } +} + +#[derive(Debug)] +struct EncodedSubject { + subject_kind: String, + subject_id: String, +} + +fn tuple_update_to_mutation(update: TupleUpdate) -> Result { + let (tuple, operation) = match update { + TupleUpdate::Write(tuple) => (tuple, "add"), + TupleUpdate::Delete(tuple) => (tuple, "remove"), + }; + let subject = encode_subject(&tuple.subject)?; + Ok(proto::AuthzTupleMutation { + namespace: tuple.object.namespace, + object_id: tuple.object.id, + relation: tuple.relation, + subject_kind: subject.subject_kind, + subject_id: subject.subject_id, + caveat_hash: String::new(), + operation: operation.to_string(), + reason: "zanzibar tuple update".to_string(), + }) +} + +fn encode_subject(subject: &Subject) -> Result { + Ok(match subject { + Subject::Entity(object) => EncodedSubject { + subject_kind: object.namespace.clone(), + subject_id: object.id.clone(), + }, + Subject::Userset { object, relation } => EncodedSubject { + subject_kind: "userset".to_string(), + subject_id: encode_userset_subject(object, relation), + }, + }) +} + +fn tuple_from_proto(tuple: proto::AuthzTuple) -> Result { + Ok(Tuple { + object: Object { + namespace: tuple.namespace, + id: tuple.object_id, + }, + relation: tuple.relation, + subject: decode_subject(&tuple.subject_kind, &tuple.subject_id)?, + }) +} + +fn decode_subject(subject_kind: &str, subject_id: &str) -> Result { + if subject_kind == "userset" { + let (object, relation) = decode_userset_subject(subject_id)?; + Ok(Subject::Userset { object, relation }) + } else { + Ok(Subject::Entity(Object { + namespace: subject_kind.to_string(), + id: subject_id.to_string(), + })) + } +} + +fn encode_userset_subject(object: &Object, relation: &str) -> String { + format!("{}/{}#{}", object.namespace, object.id, relation) +} + +fn decode_userset_subject(value: &str) -> Result<(Object, String), RebacError> { + let Some((namespace, rest)) = value.split_once('/') else { + return Err(RebacError::Internal("invalid userset subject".to_string())); + }; + let Some((object_id, relation)) = rest.rsplit_once('#') else { + return Err(RebacError::Internal("invalid userset subject".to_string())); + }; + Ok(( + Object { + namespace: namespace.to_string(), + id: object_id.to_string(), + }, + relation.to_string(), + )) +} + +fn schema_to_proto_namespaces( + schema: &Schema, +) -> Result, RebacError> { + let schema_json = serde_json::to_string(schema) + .map_err(|err| RebacError::Internal(format!("encode schema: {err}")))?; + let mut namespaces = schema + .namespaces + .iter() + .map(|(namespace, config)| proto::AuthzNamespaceSchema { + namespace: namespace.clone(), + relations: config + .rules + .iter() + .map(|(relation, rules)| proto::AuthzRelationSchema { + relation: relation.clone(), + rules: rules.iter().map(relation_rule_to_proto).collect(), + }) + .collect(), + schema_json: schema_json.clone(), + schema_hash: String::new(), + schema_version: 0, + authz_revision: 0, + applied_at: String::new(), + }) + .collect::>(); + namespaces.sort_by(|left, right| left.namespace.cmp(&right.namespace)); + Ok(namespaces) +} + +fn relation_rule_to_proto(rule: &RelationRule) -> proto::AuthzRelationRule { + match rule { + RelationRule::Inherit(relation) => proto::AuthzRelationRule { + kind: "inherit".to_string(), + relation: relation.clone(), + tuple_relation: String::new(), + target_relation: String::new(), + }, + RelationRule::Computed { + tuple_relation, + target_relation, + } => proto::AuthzRelationRule { + kind: "computed".to_string(), + relation: String::new(), + tuple_relation: tuple_relation.clone(), + target_relation: target_relation.clone(), + }, + RelationRule::TupleToUserset { + tuple_relation, + target_relation, + } => proto::AuthzRelationRule { + kind: "tuple_to_userset".to_string(), + relation: String::new(), + tuple_relation: tuple_relation.clone(), + target_relation: target_relation.clone(), + }, + } +} + +fn schema_from_proto_namespaces( + namespaces: Vec, +) -> Result { + let mut schema = Schema::default(); + for namespace in namespaces { + if !namespace.schema_json.is_empty() { + let stored_schema: Schema = serde_json::from_str(&namespace.schema_json) + .map_err(|err| RebacError::Internal(format!("decode schema: {err}")))?; + if let Some(config) = stored_schema.namespaces.get(&namespace.namespace) { + schema + .namespaces + .insert(namespace.namespace.clone(), config.clone()); + continue; + } + } + schema.namespaces.insert( + namespace.namespace, + NamespaceConfig { + rules: namespace + .relations + .into_iter() + .map(|relation| { + Ok(( + relation.relation, + relation + .rules + .into_iter() + .map(relation_rule_from_proto) + .collect::, _>>()?, + )) + }) + .collect::, RebacError>>()?, + }, + ); + } + Ok(schema) +} + +fn relation_rule_from_proto(rule: proto::AuthzRelationRule) -> Result { + match rule.kind.as_str() { + "inherit" => Ok(RelationRule::Inherit(rule.relation)), + "computed" => Ok(RelationRule::Computed { + tuple_relation: rule.tuple_relation, + target_relation: rule.target_relation, + }), + "tuple_to_userset" => Ok(RelationRule::TupleToUserset { + tuple_relation: rule.tuple_relation, + target_relation: rule.target_relation, + }), + other => Err(RebacError::Internal(format!( + "unsupported Anvil authz schema rule kind: {other}" + ))), + } +} + +fn validate_schema(schema: &Schema) -> Result<(), RebacError> { + for (namespace, config) in &schema.namespaces { + validate_component(namespace, "namespace")?; + for (relation, rules) in &config.rules { + validate_component(relation, "relation")?; + for rule in rules { + match rule { + RelationRule::Inherit(relation) => validate_component(relation, "relation")?, + RelationRule::Computed { + tuple_relation, + target_relation, + } + | RelationRule::TupleToUserset { + tuple_relation, + target_relation, + } => { + validate_component(tuple_relation, "tuple relation")?; + validate_component(target_relation, "target relation")?; + } + } + } + } + } + Ok(()) +} + +fn validate_component(value: &str, name: &str) -> Result<(), RebacError> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.chars().any(char::is_control) + { + Err(RebacError::Internal(format!( + "invalid Anvil Zanzibar {name}: {value:?}" + ))) + } else { + Ok(()) + } +} + +fn anvil_status(status: tonic::Status) -> RebacError { + RebacError::Internal(format!("Anvil request failed: {status}")) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct NodeKey { + namespace: String, + object_id: String, + relation: String, +} + +impl NodeKey { + fn new(object: &Object, relation: &str) -> Self { + Self { + namespace: object.namespace.clone(), + object_id: object.id.clone(), + relation: relation.to_string(), + } + } + + fn object(&self) -> Object { + Object { + namespace: self.namespace.clone(), + id: self.object_id.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct TupleView { + tuples: Vec, +} + +impl TupleView { + fn new(tuples: Vec) -> Self { + Self { tuples } + } + + fn check(&self, schema: &Schema, object: &Object, relation: &str, subject: &Subject) -> bool { + self.check_node( + schema, + &NodeKey::new(object, relation), + subject, + &mut HashSet::new(), + ) + } + + fn list_objects( + &self, + schema: &Schema, + subject: &Subject, + relation: &str, + object_namespace: &str, + ) -> Vec { + self.tuples + .iter() + .filter(|tuple| tuple.object.namespace == object_namespace) + .map(|tuple| tuple.object.id.clone()) + .collect::>() + .into_iter() + .filter(|object_id| { + self.check( + schema, + &Object { + namespace: object_namespace.to_string(), + id: object_id.clone(), + }, + relation, + subject, + ) + }) + .collect() + } + + fn list_subjects( + &self, + schema: &Schema, + object: &Object, + relation: &str, + subject_namespace: &str, + ) -> Vec { + self.tuples + .iter() + .filter_map(|tuple| match &tuple.subject { + Subject::Entity(subject) if subject.namespace == subject_namespace => { + Some(subject.id.clone()) + } + _ => None, + }) + .collect::>() + .into_iter() + .filter(|subject_id| { + self.check( + schema, + object, + relation, + &Subject::Entity(Object { + namespace: subject_namespace.to_string(), + id: subject_id.clone(), + }), + ) + }) + .collect() + } + + fn check_node( + &self, + schema: &Schema, + node: &NodeKey, + subject: &Subject, + visited: &mut HashSet, + ) -> bool { + if !visited.insert(node.clone()) { + return false; + } + + if subject_matches_node(subject, node) { + visited.remove(node); + return true; + } + + for tuple in self.tuples_for_node(node) { + if subject_matches(&tuple.subject, subject) + || match &tuple.subject { + Subject::Userset { object, relation } => { + self.check_node(schema, &NodeKey::new(object, relation), subject, visited) + } + Subject::Entity(_) => false, + } + { + visited.remove(node); + return true; + } + } + + if let Some(namespace) = schema.namespaces.get(&node.namespace) + && let Some(rules) = namespace.rules.get(&node.relation) + { + let object = node.object(); + for rule in rules { + match rule { + RelationRule::Inherit(inherited_relation) => { + if self.check_node( + schema, + &NodeKey::new(&object, inherited_relation), + subject, + visited, + ) { + visited.remove(node); + return true; + } + } + RelationRule::Computed { + tuple_relation, + target_relation, + } + | RelationRule::TupleToUserset { + tuple_relation, + target_relation, + } => { + let source_node = NodeKey::new(&object, tuple_relation); + for tuple in self.tuples_for_node(&source_node) { + let target_object = match &tuple.subject { + Subject::Entity(object) | Subject::Userset { object, .. } => object, + }; + if self.check_node( + schema, + &NodeKey::new(target_object, target_relation), + subject, + visited, + ) { + visited.remove(node); + return true; + } + } + } + } + } + } + + visited.remove(node); + false + } + + fn tuples_for_node<'a>(&'a self, node: &'a NodeKey) -> impl Iterator + 'a { + self.tuples.iter().filter(move |tuple| { + tuple.object.namespace == node.namespace + && tuple.object.id == node.object_id + && tuple.relation == node.relation + }) + } +} + +fn subject_matches(left: &Subject, right: &Subject) -> bool { + match (left, right) { + (Subject::Entity(left), Subject::Entity(right)) => object_matches(left, right), + ( + Subject::Userset { + object: left_object, + relation: left_relation, + }, + Subject::Userset { + object: right_object, + relation: right_relation, + }, + ) => object_matches(left_object, right_object) && left_relation == right_relation, + _ => false, + } +} + +fn subject_matches_node(subject: &Subject, node: &NodeKey) -> bool { + match subject { + Subject::Userset { object, relation } => { + object_matches( + object, + &Object { + namespace: node.namespace.clone(), + id: node.object_id.clone(), + }, + ) && relation == &node.relation + } + Subject::Entity(_) => false, + } +} + +fn object_matches(left: &Object, right: &Object) -> bool { + (left.namespace == right.namespace || left.namespace == "*" || right.namespace == "*") + && (left.id == right.id || left.id == "*" || right.id == "*") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SchemaBuilder; + + fn user(id: &str) -> Subject { + Subject::Entity(Object { + namespace: "user".to_string(), + id: id.to_string(), + }) + } + + fn object(namespace: &str, id: &str) -> Object { + Object { + namespace: namespace.to_string(), + id: id.to_string(), + } + } + + #[test] + fn local_evaluator_handles_inherit_and_nested_usersets() { + let schema = SchemaBuilder::new() + .namespace( + "document", + NamespaceConfig { + rules: HashMap::from([( + "viewer".to_string(), + vec![RelationRule::Inherit("editor".to_string())], + )]), + }, + ) + .build(); + let view = TupleView::new(vec![ + Tuple { + object: object("document", "alpha"), + relation: "editor".to_string(), + subject: Subject::Userset { + object: object("group", "eng"), + relation: "member".to_string(), + }, + }, + Tuple { + object: object("group", "eng"), + relation: "member".to_string(), + subject: user("alice"), + }, + ]); + + assert!(view.check( + &schema, + &object("document", "alpha"), + "viewer", + &user("alice") + )); + } + + #[test] + fn local_evaluator_handles_computed_usersets_from_entity_subjects() { + let schema = SchemaBuilder::new() + .namespace( + "document", + NamespaceConfig { + rules: HashMap::from([( + "viewer".to_string(), + vec![RelationRule::Computed { + tuple_relation: "parent_folder".to_string(), + target_relation: "viewer".to_string(), + }], + )]), + }, + ) + .namespace( + "folder", + NamespaceConfig { + rules: HashMap::new(), + }, + ) + .build(); + let view = TupleView::new(vec![ + Tuple { + object: object("document", "alpha"), + relation: "parent_folder".to_string(), + subject: Subject::Entity(object("folder", "platform")), + }, + Tuple { + object: object("folder", "platform"), + relation: "viewer".to_string(), + subject: user("alice"), + }, + ]); + + assert!(view.check( + &schema, + &object("document", "alpha"), + "viewer", + &user("alice") + )); + assert_eq!( + view.list_objects(&schema, &user("alice"), "viewer", "document"), + vec!["alpha"] + ); + } + + #[test] + fn schema_round_trips_through_anvil_proto_shape() { + let schema = SchemaBuilder::new() + .namespace( + "document", + NamespaceConfig { + rules: HashMap::from([( + "viewer".to_string(), + vec![ + RelationRule::Inherit("editor".to_string()), + RelationRule::TupleToUserset { + tuple_relation: "shared_with".to_string(), + target_relation: "member".to_string(), + }, + ], + )]), + }, + ) + .build(); + let proto = schema_to_proto_namespaces(&schema).unwrap(); + let decoded = schema_from_proto_namespaces(proto).unwrap(); + assert_eq!(decoded.namespaces.len(), schema.namespaces.len()); + assert_eq!( + decoded.namespaces["document"].rules["viewer"], + schema.namespaces["document"].rules["viewer"] + ); + } + + #[test] + fn userset_subject_round_trips_through_anvil_tuple_shape() { + let subject = Subject::Userset { + object: object("group", "eng"), + relation: "member".to_string(), + }; + let encoded = encode_subject(&subject).unwrap(); + assert_eq!(encoded.subject_kind, "userset"); + assert_eq!(encoded.subject_id, "group/eng#member"); + assert_eq!( + decode_subject(&encoded.subject_kind, &encoded.subject_id).unwrap(), + subject + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 70869da..b295ab8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "anvil")] +pub mod anvil; + pub mod postgres; pub const POSTGRES_SCHEMA: &str = include_str!("../schema.sql"); @@ -130,6 +133,12 @@ impl SchemaBuilder { } } +impl Default for SchemaBuilder { + fn default() -> Self { + Self::new() + } +} + #[derive(Debug, thiserror::Error)] pub enum RebacError { #[error("Database error: {0}")] diff --git a/src/postgres.rs b/src/postgres.rs index 8ccae72..d734373 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -5,7 +5,6 @@ use crate::{ use anyhow::Result; use async_trait::async_trait; use sqlx::{PgPool, Row}; -use tracing::{debug, error}; #[derive(Clone)] pub struct PostgresRebacEngine { diff --git a/tests/anvil_backend.rs b/tests/anvil_backend.rs new file mode 100644 index 0000000..c5035f9 --- /dev/null +++ b/tests/anvil_backend.rs @@ -0,0 +1,257 @@ +#![cfg(feature = "anvil")] + +use anvil_storage::{AnvilClient, proto}; +use std::collections::HashMap; +use std::time::Duration; +use zanzibar::anvil::AnvilRebacEngine; +use zanzibar::{ + NamespaceConfig, Object, RebacEngine, RelationRule, SchemaBuilder, Subject, Tuple, TupleUpdate, +}; + +fn object(namespace: &str, id: &str) -> Object { + Object { + namespace: namespace.to_string(), + id: id.to_string(), + } +} + +fn user(id: &str) -> Subject { + Subject::Entity(object("user", id)) +} + +fn tuple(namespace: &str, object_id: &str, relation: &str, subject: Subject) -> TupleUpdate { + TupleUpdate::Write(Tuple { + object: object(namespace, object_id), + relation: relation.to_string(), + subject, + }) +} + +async fn engine() -> AnvilRebacEngine { + let endpoint = std::env::var("ANVIL_E2E_GRPC") + .expect("ANVIL_E2E_GRPC must point to an Anvil gRPC endpoint"); + let token = match std::env::var("ANVIL_E2E_TOKEN") { + Ok(token) => token, + Err(_) => fetch_e2e_token(&endpoint).await, + }; + let client = AnvilClient::connect_with_bearer(endpoint, token) + .await + .expect("connect to Anvil e2e endpoint"); + AnvilRebacEngine::new(client) +} + +async fn fetch_e2e_token(endpoint: &str) -> String { + let client_id = std::env::var("ANVIL_E2E_CLIENT_ID") + .expect("ANVIL_E2E_CLIENT_ID must be set when ANVIL_E2E_TOKEN is absent"); + let client_secret = std::env::var("ANVIL_E2E_CLIENT_SECRET") + .expect("ANVIL_E2E_CLIENT_SECRET must be set when ANVIL_E2E_TOKEN is absent"); + let client = AnvilClient::connect(endpoint.to_string()) + .await + .expect("connect to Anvil token endpoint"); + client + .auth() + .get_access_token(proto::GetAccessTokenRequest { + client_id, + client_secret, + scopes: vec!["*".to_string()], + }) + .await + .expect("obtain Anvil e2e access token") + .into_inner() + .access_token +} + +#[tokio::test] +#[ignore = "requires ANVIL_E2E_GRPC plus ANVIL_E2E_TOKEN or ANVIL_E2E_CLIENT_ID/ANVIL_E2E_CLIENT_SECRET"] +async fn anvil_backend_checks_direct_computed_tuple_to_userset_and_nested_usersets() { + let engine = engine().await; + let schema = SchemaBuilder::new() + .namespace( + "document", + NamespaceConfig { + rules: HashMap::from([ + ( + "viewer".to_string(), + vec![ + RelationRule::Inherit("editor".to_string()), + RelationRule::Computed { + tuple_relation: "parent_folder".to_string(), + target_relation: "viewer".to_string(), + }, + RelationRule::TupleToUserset { + tuple_relation: "shared_with".to_string(), + target_relation: "member".to_string(), + }, + ], + ), + ("editor".to_string(), vec![]), + ("parent_folder".to_string(), vec![]), + ("shared_with".to_string(), vec![]), + ]), + }, + ) + .namespace( + "folder", + NamespaceConfig { + rules: HashMap::new(), + }, + ) + .namespace( + "group", + NamespaceConfig { + rules: HashMap::new(), + }, + ) + .build(); + engine.apply_schema(1, schema).await.unwrap(); + + let first = engine + .write_tuples_with_zookie(vec![ + tuple("document", "direct", "editor", user("alice")), + tuple( + "document", + "computed", + "parent_folder", + Subject::Entity(object("folder", "platform")), + ), + tuple("folder", "platform", "viewer", user("bob")), + tuple( + "document", + "tuple-to-userset", + "shared_with", + Subject::Entity(object("group", "engineering")), + ), + tuple("group", "engineering", "member", user("carol")), + tuple( + "document", + "nested", + "viewer", + Subject::Userset { + object: object("group", "platform"), + relation: "member".to_string(), + }, + ), + tuple( + "group", + "platform", + "member", + Subject::Userset { + object: object("group", "engineering"), + relation: "member".to_string(), + }, + ), + ]) + .await + .unwrap() + .expect("tuple write returns a zookie"); + + assert!( + engine + .check(1, &user("alice"), "viewer", &object("document", "direct")) + .await + .unwrap() + ); + assert!( + engine + .check(1, &user("bob"), "viewer", &object("document", "computed")) + .await + .unwrap() + ); + assert!( + engine + .check( + 1, + &user("carol"), + "viewer", + &object("document", "tuple-to-userset"), + ) + .await + .unwrap() + ); + assert!( + engine + .check(1, &user("carol"), "viewer", &object("document", "nested")) + .await + .unwrap() + ); + + let (allowed, token) = engine + .check_with_consistency( + 1, + &user("carol"), + "viewer", + &object("document", "nested"), + zanzibar::anvil::AnvilConsistency::Exact(first.zookie.clone()), + ) + .await + .unwrap(); + assert!(allowed); + assert_eq!(token.zookie, first.zookie); + + let objects = engine + .list_objects(1, &user("carol"), "viewer", "document") + .await + .unwrap(); + assert_eq!(objects, vec!["nested", "tuple-to-userset"]); + + let subjects = engine + .list_subjects(1, &object("document", "nested"), "viewer", "user") + .await + .unwrap(); + assert_eq!(subjects, vec!["carol"]); +} + +#[tokio::test] +#[ignore = "requires ANVIL_E2E_GRPC plus ANVIL_E2E_TOKEN or ANVIL_E2E_CLIENT_ID/ANVIL_E2E_CLIENT_SECRET"] +async fn anvil_backend_batch_write_is_atomic_and_watch_replays_from_revision() { + let engine = engine().await; + engine + .apply_schema( + 1, + SchemaBuilder::new() + .namespace( + "document", + NamespaceConfig { + rules: HashMap::new(), + }, + ) + .build(), + ) + .await + .unwrap(); + + let err = engine + .write_tuples( + 1, + vec![ + tuple("document", "alpha", "viewer", user("alice")), + tuple("bad/slash", "beta", "viewer", user("bob")), + ], + ) + .await + .expect_err("invalid tuple in a batch must fail the whole write"); + assert!(err.to_string().contains("Anvil request failed")); + assert!( + !engine + .check(1, &user("alice"), "viewer", &object("document", "alpha")) + .await + .unwrap() + ); + + let token = engine + .write_tuples_with_zookie(vec![tuple("document", "alpha", "viewer", user("alice"))]) + .await + .unwrap() + .unwrap(); + let mut stream = engine.watch_tuple_log(0, "document").await.unwrap(); + let event = tokio::time::timeout(Duration::from_secs(5), stream.message()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(event.revision, token.revision); + assert_eq!(event.namespace, "document"); + assert_eq!(event.object_id, "alpha"); + assert_eq!(event.subject_kind, "user"); + assert_eq!(event.subject_id, "alice"); +}