diff --git a/Cargo.lock b/Cargo.lock index 888946505d..164772f355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5953,12 +5953,15 @@ dependencies = [ name = "si-data-spicedb" version = "0.1.0" dependencies = [ + "futures", "indoc", + "rand 0.8.5", "remain", "serde", "si-std", "spicedb-client", "spicedb-grpc", + "strum 0.26.3", "telemetry", "thiserror", "tokio", diff --git a/component/spicedb/entrypoint.sh b/component/spicedb/entrypoint.sh index 3ea849f3b8..d354ff8b4e 100755 --- a/component/spicedb/entrypoint.sh +++ b/component/spicedb/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -spicedb serve >>/tmp/spicedb.log 2>&1 & +spicedb serve-testing >>/tmp/spicedb.log 2>&1 & tail -f /tmp/spicedb.log & sleep 3 diff --git a/lib/si-data-spicedb/BUCK b/lib/si-data-spicedb/BUCK index 8b2414e5e1..be69a04cd4 100644 --- a/lib/si-data-spicedb/BUCK +++ b/lib/si-data-spicedb/BUCK @@ -9,10 +9,12 @@ rust_library( deps = [ "//lib/si-std:si-std", "//lib/telemetry-rs:telemetry", + "//third-party/rust:futures", "//third-party/rust:remain", "//third-party/rust:serde", "//third-party/rust:spicedb-client", "//third-party/rust:spicedb-grpc", + "//third-party/rust:strum", "//third-party/rust:thiserror", "//third-party/rust:tokio", "//third-party/rust:url", @@ -27,6 +29,7 @@ rust_test( name = "test-integration", deps = [ "//third-party/rust:indoc", + "//third-party/rust:rand", "//third-party/rust:tokio", ":si-data-spicedb", ], diff --git a/lib/si-data-spicedb/Cargo.toml b/lib/si-data-spicedb/Cargo.toml index f8f012f390..a9dd074625 100644 --- a/lib/si-data-spicedb/Cargo.toml +++ b/lib/si-data-spicedb/Cargo.toml @@ -9,11 +9,13 @@ rust-version.workspace = true publish.workspace = true [dependencies] +futures = { workspace = true } remain = { workspace = true } serde = { workspace = true } si-std = { path = "../../lib/si-std" } spicedb-client = { workspace = true } spicedb-grpc = { workspace = true } +strum = { workspace = true } telemetry = { path = "../../lib/telemetry-rs" } thiserror = { workspace = true } tokio = { workspace = true } @@ -21,3 +23,4 @@ url = { workspace = true } [dev-dependencies] indoc = { workspace = true } +rand = { workspace = true } diff --git a/lib/si-data-spicedb/src/lib.rs b/lib/si-data-spicedb/src/lib.rs index 34fa00f5bf..a9038d4971 100644 --- a/lib/si-data-spicedb/src/lib.rs +++ b/lib/si-data-spicedb/src/lib.rs @@ -7,18 +7,21 @@ // )] // #![allow(clippy::missing_errors_doc)] +use futures::TryStreamExt; use std::{io, net::ToSocketAddrs, result, sync::Arc}; use serde::{Deserialize, Serialize}; use si_std::SensitiveString; -use spicedb_client::SpicedbClient; +use spicedb_client::{builder::WriteRelationshipsRequestBuilder, SpicedbClient}; +use spicedb_grpc::authzed::api::v1::{relationship_update::Operation, WriteRelationshipsRequest}; use telemetry::prelude::*; use thiserror::Error; +use types::Relationships; use url::Url; mod types; -pub use types::{ReadSchemaResponse, ZedToken}; +pub use types::{Permission, ReadSchemaResponse, Relationship, ZedToken}; #[remain::sorted] #[derive(Error, Debug)] @@ -29,6 +32,8 @@ pub enum Error { EndpointNoHost(Url), #[error("cannot determine spicedb endpoint port number: {0}")] EndpointUnknownPort(Url), + #[error("GRPC streaming error: {0}")] + GRPC(#[source] spicedb_client::result::Error), #[error("error resolving ip addr for spicedb endpoint hostname: {0}")] ResolveHostname(#[source] io::Error), #[error("resolved hostname returned no entries")] @@ -201,6 +206,150 @@ impl Client { span.record_ok(); Ok(resp) } + + #[instrument( + name = "spicedb_client.read_relationships", + level = "debug", + skip_all, + fields( + db.connection_string = %self.metadata.db_connection_string(), + db.system = %self.metadata.db_system(), + network.peer.address = self.metadata.network_peer_address(), + network.protocol.name = self.metadata.network_protocol_name(), + network.transport = self.metadata.network_transport(), + otel.kind = SpanKind::Client.as_str(), + otel.status_code = Empty, + otel.status_message = Empty, + server.address = self.metadata.server_address(), + server.port = self.metadata.server_port(), + ), + )] + pub async fn read_relationship(&mut self, relationship: Relationship) -> Result { + let span = current_span_for_instrument_at!("debug"); + let mut relationships = vec![]; + + let results: result::Result, _> = self + .inner + .read_relationships(relationship.into_request()) + .await? + .try_collect() + .await; + + for r in results.map_err(|e| span.record_err(Error::GRPC(e.into())))? { + if let Some(relationship) = r.relationship { + relationships.push(relationship.into()); + } + } + + span.record_ok(); + Ok(relationships) + } + + #[instrument( + name = "spicedb_client.create_relationships", + level = "debug", + skip_all, + fields( + db.connection_string = %self.metadata.db_connection_string(), + db.system = %self.metadata.db_system(), + network.peer.address = self.metadata.network_peer_address(), + network.protocol.name = self.metadata.network_protocol_name(), + network.transport = self.metadata.network_transport(), + otel.kind = SpanKind::Client.as_str(), + otel.status_code = Empty, + otel.status_message = Empty, + server.address = self.metadata.server_address(), + server.port = self.metadata.server_port(), + ), + )] + pub async fn create_relationships( + &mut self, + relationships: Relationships, + ) -> Result> { + self.update_relationships(relationships, Operation::Create) + .await + } + + #[instrument( + name = "spicedb_client.delete_relationships", + level = "debug", + skip_all, + fields( + db.connection_string = %self.metadata.db_connection_string(), + db.system = %self.metadata.db_system(), + network.peer.address = self.metadata.network_peer_address(), + network.protocol.name = self.metadata.network_protocol_name(), + network.transport = self.metadata.network_transport(), + otel.kind = SpanKind::Client.as_str(), + otel.status_code = Empty, + otel.status_message = Empty, + server.address = self.metadata.server_address(), + server.port = self.metadata.server_port(), + ), + )] + pub async fn delete_relationships( + &mut self, + relationships: Relationships, + ) -> Result> { + self.update_relationships(relationships, Operation::Delete) + .await + } + + async fn update_relationships( + &mut self, + relationships: Relationships, + operation: Operation, + ) -> Result> { + let span = current_span_for_instrument_at!("debug"); + + let request: WriteRelationshipsRequest = WriteRelationshipsRequest::new( + relationships + .into_iter() + .map(|r| r.into_relationship_update(operation)), + ); + + let resp = self + .inner + .write_relationships(request) + .await + .map_err(|err| span.record_err(Error::SpiceDb(err)))? + .written_at + .map(|value| value.into()); + + span.record_ok(); + Ok(resp) + } + + #[instrument( + name = "spicedb_client.check_permissions", + level = "debug", + skip_all, + fields( + db.connection_string = %self.metadata.db_connection_string(), + db.system = %self.metadata.db_system(), + network.peer.address = self.metadata.network_peer_address(), + network.protocol.name = self.metadata.network_protocol_name(), + network.transport = self.metadata.network_transport(), + otel.kind = SpanKind::Client.as_str(), + otel.status_code = Empty, + otel.status_message = Empty, + server.address = self.metadata.server_address(), + server.port = self.metadata.server_port(), + ), + )] + pub async fn check_permissions(&mut self, permission: Permission) -> Result { + let span = current_span_for_instrument_at!("debug"); + + let resp = self + .inner + .check_permission(permission.into_request()) + .await + .map_err(|err| span.record_err(Error::SpiceDb(err)))? + .permissionship; + + span.record_ok(); + Ok(Permission::has_permission(resp)) + } } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/lib/si-data-spicedb/src/types.rs b/lib/si-data-spicedb/src/types.rs index 5f10620d1f..568b3e9065 100644 --- a/lib/si-data-spicedb/src/types.rs +++ b/lib/si-data-spicedb/src/types.rs @@ -1,6 +1,7 @@ use std::ops; -use spicedb_grpc::authzed::api::v1; +use spicedb_client::builder::{RelationshipBuilder, RelationshipFilterBuilder}; +use spicedb_grpc::authzed::api::v1::{self, ObjectReference, SubjectReference}; /// ZedToken is used to provide causality metadata between Write and Check requests. /// @@ -49,3 +50,109 @@ impl From for ReadSchemaResponse { } } } + +pub type Relationships = Vec; +#[derive(Clone, Debug)] +pub struct Relationship(pub(crate) v1::Relationship); + +impl Relationship { + pub fn new( + object_type: impl ToString, + object_id: impl ToString, + relation: impl ToString, + subject_type: impl ToString, + subject_id: impl ToString, + ) -> Self { + Self(::new( + object_type, + object_id, + relation, + subject_type, + subject_id, + )) + } + + pub fn into_request(self) -> v1::ReadRelationshipsRequest { + let inner = self.0; + let mut builder = ::new(); + + if let Some(resource) = inner.resource { + builder.resource_type(resource.object_type); + } + + builder.relation(inner.relation); + + builder + } + + pub(crate) fn inner(self) -> v1::Relationship { + self.0 + } + + pub(crate) fn into_relationship_update( + self, + operation: v1::relationship_update::Operation, + ) -> v1::RelationshipUpdate { + spicedb_grpc::authzed::api::v1::RelationshipUpdate { + operation: operation.into(), + relationship: Some(self.inner()), + } + } +} + +impl From for Relationship { + fn from(value: v1::Relationship) -> Self { + Relationship(value) + } +} + +#[derive(Clone, Debug)] +pub struct Permission { + resource_type: String, + resource_id: String, + permission: String, + subject_type: String, + subject_id: String, +} + +impl Permission { + pub fn new( + resource_type: impl ToString, + resource_id: impl ToString, + permission: impl ToString, + subject_type: impl ToString, + subject_id: impl ToString, + ) -> Self { + Self { + resource_type: resource_type.to_string(), + resource_id: resource_id.to_string(), + permission: permission.to_string(), + subject_type: subject_type.to_string(), + subject_id: subject_id.to_string(), + } + } + + pub(crate) fn into_request(self) -> v1::CheckPermissionRequest { + v1::CheckPermissionRequest { + consistency: None, + resource: Some(ObjectReference { + object_type: self.resource_type, + object_id: self.resource_id, + }), + permission: self.permission, + subject: Some(SubjectReference { + object: Some(ObjectReference { + object_type: self.subject_type, + object_id: self.subject_id, + }), + optional_relation: "".to_string(), + }), + context: None, + with_tracing: false, + } + } + + pub(crate) fn has_permission(permissionship: i32) -> bool { + i32::from(v1::check_permission_response::Permissionship::HasPermission) == permissionship + } +} diff --git a/lib/si-data-spicedb/tests/integration_test/mod.rs b/lib/si-data-spicedb/tests/integration_test/mod.rs index a019baafbb..eff31ae2b1 100644 --- a/lib/si-data-spicedb/tests/integration_test/mod.rs +++ b/lib/si-data-spicedb/tests/integration_test/mod.rs @@ -1,7 +1,9 @@ use std::env; use indoc::indoc; -use si_data_spicedb::{Client, SpiceDbConfig}; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use si_data_spicedb::{Client, Permission, Relationship, SpiceDbConfig}; const ENV_VAR_SPICEDB_URL: &str = "SI_TEST_SPICEDB_URL"; @@ -11,6 +13,10 @@ fn spicedb_config() -> SpiceDbConfig { if let Ok(value) = env::var(ENV_VAR_SPICEDB_URL) { config.endpoint = value.parse().expect("failed to parse spicedb url"); } + + let mut rng = thread_rng(); + let random_string: String = (0..12).map(|_| rng.sample(Alphanumeric) as char).collect(); + config.preshared_key = random_string.into(); config } @@ -50,3 +56,103 @@ async fn write_and_read_schema() { .lines() .any(|line| line == "definition plan {}")); } + +#[tokio::test] +async fn write_and_read_relationship() { + let config = spicedb_config(); + + let mut client = Client::new(&config) + .await + .expect("failed to connect to spicedb"); + + let schema = indoc! {" + // Plan comment + definition plan {} + + definition user {} + + definition workspace { + relation approver: user + permission approve = approver + } + "}; + + client + .write_schema(schema) + .await + .expect("failed to write schema"); + + let scott_aprover_workspace = + Relationship::new("workspace", "456", "approver", "user", "scott"); + + client + .create_relationships(vec![scott_aprover_workspace.clone()]) + .await + .expect("failed to create a relation"); + + let resp = client + .read_relationship(scott_aprover_workspace.clone()) + .await + .expect("failed to read relation"); + + assert!(resp.len() == 1); + + client + .delete_relationships(vec![scott_aprover_workspace.clone()]) + .await + .expect("failed to delete relation"); + + let resp = client + .read_relationship(scott_aprover_workspace.clone()) + .await + .expect("failed to read relation"); + + assert!(resp.is_empty()); +} + +#[tokio::test] +async fn check_permissions() { + let config = spicedb_config(); + + let mut client = Client::new(&config) + .await + .expect("failed to connect to spicedb"); + + let schema = indoc! {" + // Plan comment + definition plan {} + + definition user {} + + definition workspace { + relation approver: user + permission approve = approver + } + "}; + + client + .write_schema(schema) + .await + .expect("failed to write schema"); + + let scott_aprover_workspace = + Relationship::new("workspace", "789", "approver", "user", "scott"); + + client + .create_relationships(vec![scott_aprover_workspace.clone()]) + .await + .expect("failed to create a relation"); + + let perms = Permission::new("workspace", "789", "approver", "user", "scott"); + let bad_perms = Permission::new("workspace", "789", "approver", "user", "fletcher"); + + assert!(client + .check_permissions(perms) + .await + .expect("failed to check permissions")); + + assert!(!client + .check_permissions(bad_perms) + .await + .expect("failed to check permissions")); +}