From 365d8571c32987dbf0948dae5ae3bcb0761c62f3 Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Thu, 23 May 2024 11:56:43 +0200 Subject: [PATCH] ARC-1699: Spicegen gRPC client binding (#2) * ARC-1699: Added SpiceDB binding and example * ARC-1699: Update CODEOWNERS * ARC-1699: Better README * ARC-1699: Removed copy paste leftovers. --- CODEOWNERS | 1 + README.md | 4 + .../com/oviva/spicegen/api/ObjectRef.java | 14 +- .../oviva/spicegen/api/PermissionService.java | 15 ++ .../com/oviva/spicegen/api/UpdateResult.java | 5 + .../exceptions/AuthenticationException.java | 11 + .../exceptions/AuthorizationException.java | 11 + .../api/exceptions/ClientException.java | 11 + .../api/exceptions/ConflictException.java | 11 + .../api/exceptions/ValidationException.java | 11 + .../api/internal/SimpleObjectRef.java | 27 --- example/README.md | 69 +++++++ example/pom.xml | 120 +++++++++++ .../oviva/spicegen/example/ExampleTest.java | 156 ++++++++++++++ example/src/test/resources/files.zed | 16 ++ generator/out/pom.xml | 45 ---- .../spicegen/parser/AstPreProcessor.java | 1 - pom.xml | 43 +++- spicedb-binding/pom.xml | 130 ++++++++++++ .../src/main/docker/compose.dev.yaml | 43 ++++ .../SpiceDbPermissionServiceBuilder.java | 25 +++ ...dateToSpiceDBUpdateRelationshipMapper.java | 32 +++ .../internal/GrpcExceptionMapper.java | 17 ++ .../internal/ObjectReferenceMapper.java | 14 ++ .../internal/PreconditionMapper.java | 58 ++++++ .../SpiceDbPermissionServiceImpl.java | 44 ++++ .../internal/SubjectReferenceMapper.java | 16 ++ .../internal/UpdateResultImpl.java | 5 + .../SpiceDbPermissionServiceBuilderTest.java | 33 +++ ...ToSpiceDBUpdateRelationshipMapperTest.java | 53 +++++ .../internal/GrpcExceptionMapperTest.java | 63 ++++++ .../internal/SpiceDbContractTest.java | 192 ++++++++++++++++++ .../SpiceDbContractTestContextProvider.java | 156 ++++++++++++++ .../spicedbbinding/internal/SpiceDbUtils.java | 104 ++++++++++ .../spicedbbinding/test/Fixtures.java | 24 +++ .../test/GenericTypedParameterResolver.java | 30 +++ .../spicedb_contract_test_schema.zed | 17 ++ 37 files changed, 1545 insertions(+), 82 deletions(-) create mode 100644 CODEOWNERS create mode 100644 api/src/main/java/com/oviva/spicegen/api/PermissionService.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/UpdateResult.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/exceptions/AuthenticationException.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/exceptions/AuthorizationException.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/exceptions/ClientException.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/exceptions/ConflictException.java create mode 100644 api/src/main/java/com/oviva/spicegen/api/exceptions/ValidationException.java delete mode 100644 api/src/main/java/com/oviva/spicegen/api/internal/SimpleObjectRef.java create mode 100644 example/README.md create mode 100644 example/pom.xml create mode 100644 example/src/test/java/com/oviva/spicegen/example/ExampleTest.java create mode 100644 example/src/test/resources/files.zed delete mode 100644 generator/out/pom.xml create mode 100644 spicedb-binding/pom.xml create mode 100644 spicedb-binding/src/main/docker/compose.dev.yaml create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilder.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapper.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/ObjectReferenceMapper.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/PreconditionMapper.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbPermissionServiceImpl.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SubjectReferenceMapper.java create mode 100644 spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/UpdateResultImpl.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilderTest.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapperTest.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapperTest.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTest.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTestContextProvider.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbUtils.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/Fixtures.java create mode 100644 spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/GenericTypedParameterResolver.java create mode 100644 spicedb-binding/src/test/resources/spicedb_contract_test_schema.zed diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..7aa3c68 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* thomas.richner@oviva.com anna.durrer@oviva.com shivan.taher@oviva.com diff --git a/README.md b/README.md index 61cd4fc..1f33ed0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ The generator work in multiple stages that could be re-used for other generators To make this easy to use, all the above is bundled in the [maven plugin](./generator-maven-plugin). +## Useful Links + +- [SpiceDB API Docs](https://buf.build/authzed/api/docs/main/authzed.api.v1) + ## Wishlist - type-safe IDs, needs additional metadata in the schema diff --git a/api/src/main/java/com/oviva/spicegen/api/ObjectRef.java b/api/src/main/java/com/oviva/spicegen/api/ObjectRef.java index 59e8ec5..d6f1fcc 100644 --- a/api/src/main/java/com/oviva/spicegen/api/ObjectRef.java +++ b/api/src/main/java/com/oviva/spicegen/api/ObjectRef.java @@ -1,13 +1,21 @@ package com.oviva.spicegen.api; -import com.oviva.spicegen.api.internal.SimpleObjectRef; - public interface ObjectRef { String kind(); String id(); static ObjectRef of(String kind, String id) { - return SimpleObjectRef.of(kind, id); + return new ObjectRef() { + @Override + public String kind() { + return kind; + } + + @Override + public String id() { + return id; + } + }; } } diff --git a/api/src/main/java/com/oviva/spicegen/api/PermissionService.java b/api/src/main/java/com/oviva/spicegen/api/PermissionService.java new file mode 100644 index 0000000..703f354 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/PermissionService.java @@ -0,0 +1,15 @@ +package com.oviva.spicegen.api; + +public interface PermissionService { + + /** + * Updates relationships, optionally with preconditions. The returned consistencyToken should be + * stored alongside the created resource such that the authorization can be done at the given + * consistency. This vastly improves the performance and allows the system to function even when + * it is partitioned. + * + * @param updateRelationships the request + * @return the result, containing the consistencyToken + */ + UpdateResult updateRelationships(UpdateRelationships updateRelationships); +} diff --git a/api/src/main/java/com/oviva/spicegen/api/UpdateResult.java b/api/src/main/java/com/oviva/spicegen/api/UpdateResult.java new file mode 100644 index 0000000..b0ff1a9 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/UpdateResult.java @@ -0,0 +1,5 @@ +package com.oviva.spicegen.api; + +public interface UpdateResult { + String consistencyToken(); +} diff --git a/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthenticationException.java b/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthenticationException.java new file mode 100644 index 0000000..14f0f93 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthenticationException.java @@ -0,0 +1,11 @@ +package com.oviva.spicegen.api.exceptions; + +public class AuthenticationException extends ClientException { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthorizationException.java b/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthorizationException.java new file mode 100644 index 0000000..075d206 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/exceptions/AuthorizationException.java @@ -0,0 +1,11 @@ +package com.oviva.spicegen.api.exceptions; + +public class AuthorizationException extends ClientException { + public AuthorizationException(String message) { + super(message); + } + + public AuthorizationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/com/oviva/spicegen/api/exceptions/ClientException.java b/api/src/main/java/com/oviva/spicegen/api/exceptions/ClientException.java new file mode 100644 index 0000000..8fb81d7 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/exceptions/ClientException.java @@ -0,0 +1,11 @@ +package com.oviva.spicegen.api.exceptions; + +public class ClientException extends RuntimeException { + public ClientException(String message) { + super(message); + } + + public ClientException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/com/oviva/spicegen/api/exceptions/ConflictException.java b/api/src/main/java/com/oviva/spicegen/api/exceptions/ConflictException.java new file mode 100644 index 0000000..4ad7dc3 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/exceptions/ConflictException.java @@ -0,0 +1,11 @@ +package com.oviva.spicegen.api.exceptions; + +public class ConflictException extends ClientException { + public ConflictException(String message) { + super(message); + } + + public ConflictException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/com/oviva/spicegen/api/exceptions/ValidationException.java b/api/src/main/java/com/oviva/spicegen/api/exceptions/ValidationException.java new file mode 100644 index 0000000..9633641 --- /dev/null +++ b/api/src/main/java/com/oviva/spicegen/api/exceptions/ValidationException.java @@ -0,0 +1,11 @@ +package com.oviva.spicegen.api.exceptions; + +public class ValidationException extends ClientException { + public ValidationException(String message) { + super(message); + } + + public ValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/com/oviva/spicegen/api/internal/SimpleObjectRef.java b/api/src/main/java/com/oviva/spicegen/api/internal/SimpleObjectRef.java deleted file mode 100644 index 79464d1..0000000 --- a/api/src/main/java/com/oviva/spicegen/api/internal/SimpleObjectRef.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.oviva.spicegen.api.internal; - -import com.oviva.spicegen.api.ObjectRef; - -public final class SimpleObjectRef implements ObjectRef { - private final String kind; - private final String id; - - public static ObjectRef of(String kind, String id) { - return new SimpleObjectRef(kind, id); - } - - private SimpleObjectRef(String kind, String id) { - this.kind = kind; - this.id = id; - } - - @Override - public String kind() { - return kind; - } - - @Override - public String id() { - return id; - } -} diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..4f12bf3 --- /dev/null +++ b/example/README.md @@ -0,0 +1,69 @@ +# Spicegen Example + +## Code +See [ExampleTest](./src/test/java/com/oviva/spicegen/example/ExampleTest.java). + + +## Maven Setup +Example [pom.xml](./pom.xml) +```xml + + + + + + + + spicegen + GitHub Oviva Spicegen + https://maven.pkg.github.com/oviva-ag/spicegen + + + + + spicegen + GitHub Oviva Spicegen Plugin + https://maven.pkg.github.com/oviva-ag/spicegen + + + + + + + + com.oviva.spicegen + api + ... + + + com.oviva.spicegen + spicedb-binding + ... + + + + + + + + + com.oviva.spicegen + spicegen-maven-plugin + ${project.version} + + + + ${project.basedir}/src/test/resources/files.zed + ${project.groupId}.permissions + ${project.basedir}/target/generated-sources/src/main/java + + + spicegen + + + + + + + +``` \ No newline at end of file diff --git a/example/pom.xml b/example/pom.xml new file mode 100644 index 0000000..9b283f9 --- /dev/null +++ b/example/pom.xml @@ -0,0 +1,120 @@ + + + 4.0.0 + + + com.oviva.spicegen + spicegen-parent + 1.0.0-SNAPSHOT + + + Permissions Generator Example + example + + + + com.oviva.spicegen + api + + + com.oviva.spicegen + spicedb-binding + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.slf4j + slf4j-simple + test + + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + + + + + maven-enforcer-plugin + + + org.codehaus.mojo + versions-maven-plugin + + + com.diffplug.spotless + spotless-maven-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + ${argLine} + + + + + integration-tests + integration-test + + test + + + !integration + integration + + + + + + com.oviva.spicegen + spicegen-maven-plugin + ${project.version} + + + + ${project.basedir}/src/test/resources/files.zed + ${project.groupId}.permissions + ${project.basedir}/target/generated-sources/src/main/java + + + spicegen + + + + + + + + diff --git a/example/src/test/java/com/oviva/spicegen/example/ExampleTest.java b/example/src/test/java/com/oviva/spicegen/example/ExampleTest.java new file mode 100644 index 0000000..8fc84ea --- /dev/null +++ b/example/src/test/java/com/oviva/spicegen/example/ExampleTest.java @@ -0,0 +1,156 @@ +package com.oviva.spicegen.example; + +import static com.authzed.api.v1.PermissionService.CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import com.authzed.api.v1.Core; +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.authzed.api.v1.SchemaServiceGrpc; +import com.authzed.api.v1.SchemaServiceOuterClass; +import com.authzed.grpcutil.BearerToken; +import com.oviva.spicegen.api.ObjectRef; +import com.oviva.spicegen.api.PermissionService; +import com.oviva.spicegen.api.SubjectRef; +import com.oviva.spicegen.api.UpdateRelationships; +import com.oviva.spicegen.permissions.SchemaConstants; +import com.oviva.spicegen.permissions.refs.DocumentRef; +import com.oviva.spicegen.permissions.refs.FolderRef; +import com.oviva.spicegen.permissions.refs.UserRef; +import com.oviva.spicegen.spicedbbinding.SpiceDbPermissionServiceBuilder; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +public class ExampleTest { + + private static final int GRPC_PORT = 50051; + private static final String TOKEN = "t0ken"; + + private static final Logger logger = LoggerFactory.getLogger(ExampleTest.class); + + @Container + private GenericContainer spicedb = + new GenericContainer<>(DockerImageName.parse("quay.io/authzed/spicedb:v1.32.0")) + .withCommand("serve", "--grpc-preshared-key", TOKEN) + .waitingFor(new LogMessageWaitStrategy().withRegEx(".*\"grpc server started serving\".*")) + .withLogConsumer(f -> logger.info("spicedb: {}", f.getUtf8String())) + .withExposedPorts( + GRPC_PORT, // grpc + 8080, // dashboard + 9090 // metrics + ); + + private PermissionService permissionService; + private PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionServiceStub; + private ManagedChannel channel; + + @BeforeEach + void before() { + + var host = spicedb.getHost(); + var port = spicedb.getMappedPort(GRPC_PORT); + + var bearerToken = new BearerToken(TOKEN); + channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + + updateSchema(); + + // setup the GRPC stub + permissionServiceStub = + PermissionsServiceGrpc.newBlockingStub(channel).withCallCredentials(bearerToken); + + // create the permissions service + permissionService = + SpiceDbPermissionServiceBuilder.newBuilder() + .permissionsBlockingStub(permissionServiceStub) + .build(); + } + + private void updateSchema() { + var schemaService = + SchemaServiceGrpc.newBlockingStub(channel).withCallCredentials(new BearerToken(TOKEN)); + + schemaService.writeSchema( + SchemaServiceOuterClass.WriteSchemaRequest.newBuilder().setSchema(loadSchema()).build()); + } + + @Test + void example() { + var userId = 7; + + // typesafe object references! + var user = UserRef.ofLong(userId); + var folder = FolderRef.of("home"); + var document = DocumentRef.ofLong(48); + + // EXAMPLE: updating relationships + var updateResult = + permissionService.updateRelationships( + UpdateRelationships.newBuilder() + // note the generated factory methods! + .update(folder.createReaderUser(user)) + .update(document.createParentFolderFolder(folder)) + .build()); + + var consistencyToken = updateResult.consistencyToken(); + + // EXAMPLE: checking permission + var res = + checkPermission( + document, + // note the generated constants! + SchemaConstants.PERMISSION_DOCUMENT_READ, + SubjectRef.ofObject(user), + consistencyToken); + + assertEquals(PERMISSIONSHIP_HAS_PERMISSION, res.getPermissionship()); + } + + private com.authzed.api.v1.PermissionService.CheckPermissionResponse checkPermission( + ObjectRef object, String permission, SubjectRef subject, String consistencyToken) { + + return permissionServiceStub.checkPermission( + com.authzed.api.v1.PermissionService.CheckPermissionRequest.newBuilder() + .setPermission(permission) + .setResource( + Core.ObjectReference.newBuilder() + .setObjectType(object.kind()) + .setObjectId(object.id()) + .build()) + .setSubject( + Core.SubjectReference.newBuilder() + .setObject( + Core.ObjectReference.newBuilder() + .setObjectType(subject.kind()) + .setObjectId(subject.id()) + .build()) + .build()) + .setConsistency( + com.authzed.api.v1.PermissionService.Consistency.newBuilder() + .setAtLeastAsFresh( + Core.ZedToken.newBuilder().setToken(consistencyToken).build()) + .build()) + .build()); + } + + private String loadSchema() { + try (var is = this.getClass().getResourceAsStream("/files.zed")) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + fail(e); + } + return ""; + } +} diff --git a/example/src/test/resources/files.zed b/example/src/test/resources/files.zed new file mode 100644 index 0000000..2b0d01f --- /dev/null +++ b/example/src/test/resources/files.zed @@ -0,0 +1,16 @@ +definition user {} + +definition folder { + relation reader: user + permission read = reader +} + +definition document { + relation parent_folder: folder + relation reader: user + + /** + * read defines whether a user can read the document + */ + permission read = reader + parent_folder->read +} \ No newline at end of file diff --git a/generator/out/pom.xml b/generator/out/pom.xml deleted file mode 100644 index 8c4cf2f..0000000 --- a/generator/out/pom.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - 4.0.0 - - client - com.oviva.spicedbclient - 0.0.1-SNAPSHOT - - - 21 - ${maven.compiler.release} - ${maven.compiler.release} - - UTF-8 - UTF-8 - - 3.3.2 - 3.13.0 - 2.10 - 3.1.1 - 3.4.1 - 3.2.5 - 3.1.1 - 3.6.1 - 3.4.0 - 3.6.3 - 3.12.0 - 3.3.1 - 3.12.1 - 3.3.1 - 3.2.5 - 2.16.2 - - - - - com.oviva.spicegen - api - 1.0.0-SNAPSHOT - - - - diff --git a/model/src/main/java/com/oviva/spicegen/parser/AstPreProcessor.java b/model/src/main/java/com/oviva/spicegen/parser/AstPreProcessor.java index bf3b05e..d94256e 100644 --- a/model/src/main/java/com/oviva/spicegen/parser/AstPreProcessor.java +++ b/model/src/main/java/com/oviva/spicegen/parser/AstPreProcessor.java @@ -7,7 +7,6 @@ import java.nio.file.attribute.PosixFilePermissions; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/pom.xml b/pom.xml index 937ecf3..dbce9bb 100644 --- a/pom.xml +++ b/pom.xml @@ -33,9 +33,11 @@ api + example generator-maven-plugin generator model + spicedb-binding @@ -63,14 +65,18 @@ 3.2.5 2.16.2 + 1.63.0 + + 1.7.36 + 2.2 2.17.0 5.10.2 ${mockito.version} 5.11.0 - - 1.7.36 - 4.8.4 + 1.19.7 + 0.7.0 + 2.43.0 @@ -92,6 +98,13 @@ import pom + + io.grpc + grpc-bom + ${grpc.version} + pom + import + org.slf4j slf4j-api @@ -119,12 +132,18 @@ model ${project.version} + + com.oviva.spicegen + spicedb-binding + ${project.version} + - com.github.spotbugs - spotbugs-annotations - ${spotbugs-annotations.version} + com.authzed.api + authzed + ${authzed.version} + org.junit.jupiter junit-jupiter @@ -149,6 +168,13 @@ ${mockito.jupiter.version} test + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + @@ -250,6 +276,11 @@ + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + com.diffplug.spotless spotless-maven-plugin diff --git a/spicedb-binding/pom.xml b/spicedb-binding/pom.xml new file mode 100644 index 0000000..d9e808c --- /dev/null +++ b/spicedb-binding/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + + com.oviva.spicegen + spicegen-parent + 1.0.0-SNAPSHOT + + + Permissions SpiceDB Implementation + spicedb-binding + + + + com.oviva.spicegen + api + + + org.slf4j + slf4j-api + + + com.authzed.api + authzed + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + io.grpc + grpc-netty-shaded + + + + org.junit.jupiter + junit-jupiter + test + + + org.hamcrest + hamcrest + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.slf4j + slf4j-simple + test + + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + + + + + maven-enforcer-plugin + + + org.codehaus.mojo + versions-maven-plugin + + + com.diffplug.spotless + spotless-maven-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + ${argLine} + + + + + integration-tests + integration-test + + test + + + !integration + integration + + + + + + + + diff --git a/spicedb-binding/src/main/docker/compose.dev.yaml b/spicedb-binding/src/main/docker/compose.dev.yaml new file mode 100644 index 0000000..511ba25 --- /dev/null +++ b/spicedb-binding/src/main/docker/compose.dev.yaml @@ -0,0 +1,43 @@ +version: "3.9" +services: + spicedb: + image: quay.io/authzed/spicedb + ports: + - "127.0.0.1::8080" + - "127.0.0.1::50051" + - "127.0.0.1::9090" + command: + - 'serve' + - '--grpc-preshared-key=t0ken' + - '--datastore-engine=postgres' + - '--datastore-conn-uri=postgres://spicedb-pg:5432/spicedb?sslmode=disable&user=postgres&password=root' + depends_on: + spicedb-pg: + condition: service_healthy + spicedb-migrator: + condition: service_completed_successfully + spicedb-migrator: + image: quay.io/authzed/spicedb + command: + - 'migrate' + - 'head' + - '--datastore-engine' + - 'postgres' + - '--datastore-conn-uri' + - 'postgres://spicedb-pg:5432/spicedb?sslmode=disable&user=postgres&password=root' + depends_on: + spicedb-pg: + condition: service_healthy + spicedb-pg: + image: postgres:14 + ports: + - "127.0.0.1::5432" + environment: + POSTGRES_DB: 'spicedb' + POSTGRES_PASSWORD: 'root' + healthcheck: + test: "psql 'postgres://spicedb-pg:5432/spicedb?sslmode=disable&user=postgres&password=root' --quiet --output=/dev/null -c 'SELECT 1;'" + interval: 1s + timeout: 1s + retries: 3 + start_period: 10s \ No newline at end of file diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilder.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilder.java new file mode 100644 index 0000000..d23d7c2 --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilder.java @@ -0,0 +1,25 @@ +package com.oviva.spicegen.spicedbbinding; + +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.oviva.spicegen.api.PermissionService; +import com.oviva.spicegen.spicedbbinding.internal.SpiceDbPermissionServiceImpl; + +public class SpiceDbPermissionServiceBuilder { + private PermissionsServiceGrpc.PermissionsServiceBlockingStub stub; + + public static SpiceDbPermissionServiceBuilder newBuilder() { + return new SpiceDbPermissionServiceBuilder(); + } + + private SpiceDbPermissionServiceBuilder() {} + + public SpiceDbPermissionServiceBuilder permissionsBlockingStub( + PermissionsServiceGrpc.PermissionsServiceBlockingStub stub) { + this.stub = stub; + return this; + } + + public PermissionService build() { + return new SpiceDbPermissionServiceImpl(stub); + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper.java new file mode 100644 index 0000000..3cf34ec --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper.java @@ -0,0 +1,32 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.Core; +import com.oviva.spicegen.api.UpdateRelationship; + +public class CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper { + + private final ObjectReferenceMapper objectReferenceMapper = new ObjectReferenceMapper(); + private final SubjectReferenceMapper subjectReferenceMapper = new SubjectReferenceMapper(); + + public Core.RelationshipUpdate map(UpdateRelationship updateRelationship) { + + var subjectRef = subjectReferenceMapper.map(updateRelationship.subject()); + var resourceRef = objectReferenceMapper.map(updateRelationship.resource()); + + return Core.RelationshipUpdate.newBuilder() + .setOperation(mapOperation(updateRelationship.operation())) + .setRelationship( + Core.Relationship.newBuilder() + .setRelation(updateRelationship.relation()) + .setSubject(subjectRef) + .setResource(resourceRef)) + .build(); + } + + private Core.RelationshipUpdate.Operation mapOperation(UpdateRelationship.Operation operation) { + return switch (operation) { + case UPDATE -> Core.RelationshipUpdate.Operation.OPERATION_TOUCH; + case DELETE -> Core.RelationshipUpdate.Operation.OPERATION_DELETE; + }; + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapper.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapper.java new file mode 100644 index 0000000..6e3f6c8 --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapper.java @@ -0,0 +1,17 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.oviva.spicegen.api.exceptions.*; +import io.grpc.StatusRuntimeException; + +public class GrpcExceptionMapper { + public ClientException map(StatusRuntimeException e) { + return switch (e.getStatus().getCode()) { + case PERMISSION_DENIED -> new AuthorizationException("permission denied", e); + case UNAUTHENTICATED -> new AuthenticationException("unauthenticated", e); + case ALREADY_EXISTS -> new ConflictException("already exists", e); + case INVALID_ARGUMENT -> new ValidationException("invalid argument", e); + case FAILED_PRECONDITION -> new ValidationException("failed precondition", e); + default -> new ClientException("unexpected status: " + e.getStatus(), e); + }; + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/ObjectReferenceMapper.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/ObjectReferenceMapper.java new file mode 100644 index 0000000..ec111d9 --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/ObjectReferenceMapper.java @@ -0,0 +1,14 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.Core; +import com.oviva.spicegen.api.ObjectRef; + +public class ObjectReferenceMapper { + + public Core.ObjectReference map(ObjectRef ref) { + return Core.ObjectReference.newBuilder() + .setObjectType(ref.kind()) + .setObjectId(ref.id()) + .build(); + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/PreconditionMapper.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/PreconditionMapper.java new file mode 100644 index 0000000..ac8e862 --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/PreconditionMapper.java @@ -0,0 +1,58 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.oviva.spicegen.api.Precondition; +import com.oviva.spicegen.api.RelationshipFilter; + +public class PreconditionMapper { + + public com.authzed.api.v1.PermissionService.Precondition map(Precondition precondition) { + + var builder = com.authzed.api.v1.PermissionService.Precondition.newBuilder(); + + builder.setOperation(mapOperation(precondition.condition())); + builder.setFilter(mapFilter(precondition.filter())); + + return builder.build(); + } + + private com.authzed.api.v1.PermissionService.RelationshipFilter mapFilter( + RelationshipFilter filter) { + var builder = com.authzed.api.v1.PermissionService.RelationshipFilter.newBuilder(); + + builder.setResourceType(filter.resourceKind()); + filter.resourceId().ifPresent(builder::setOptionalResourceId); + filter.relation().ifPresent(builder::setOptionalRelation); + + filter.subjectFilter().map(this::mapSubjectFilter).ifPresent(builder::setOptionalSubjectFilter); + + return builder.build(); + } + + private com.authzed.api.v1.PermissionService.SubjectFilter mapSubjectFilter( + RelationshipFilter.SubjectFilter subjectFilter) { + var subjectFilterBuilder = + com.authzed.api.v1.PermissionService.SubjectFilter.newBuilder() + .setSubjectType(subjectFilter.subjectKind()); + + subjectFilter.subjectId().ifPresent(subjectFilterBuilder::setOptionalSubjectId); + subjectFilter + .relation() + .map( + r -> + com.authzed.api.v1.PermissionService.SubjectFilter.RelationFilter.newBuilder() + .setRelation(r) + .build()) + .ifPresent(subjectFilterBuilder::setOptionalRelation); + return subjectFilterBuilder.build(); + } + + private com.authzed.api.v1.PermissionService.Precondition.Operation mapOperation( + Precondition.Condition condition) { + return switch (condition) { + case MUST_MATCH -> + com.authzed.api.v1.PermissionService.Precondition.Operation.OPERATION_MUST_MATCH; + case MUST_NOT_MATCH -> + com.authzed.api.v1.PermissionService.Precondition.Operation.OPERATION_MUST_NOT_MATCH; + }; + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbPermissionServiceImpl.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbPermissionServiceImpl.java new file mode 100644 index 0000000..fa52576 --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbPermissionServiceImpl.java @@ -0,0 +1,44 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.oviva.spicegen.api.PermissionService; +import com.oviva.spicegen.api.UpdateRelationships; +import com.oviva.spicegen.api.UpdateResult; +import io.grpc.StatusRuntimeException; + +public class SpiceDbPermissionServiceImpl implements PermissionService { + private final CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper updateRelationshipMapper = + new CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper(); + private final PreconditionMapper preconditionMapper = new PreconditionMapper(); + + private final PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService; + + private final GrpcExceptionMapper exceptionMapper = new GrpcExceptionMapper(); + + public SpiceDbPermissionServiceImpl( + PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService) { + this.permissionsService = permissionsService; + } + + @Override + public UpdateResult updateRelationships(UpdateRelationships updates) { + + var mappedUpdates = updates.updates().stream().map(updateRelationshipMapper::map).toList(); + var mappedPreconditions = + updates.preconditions().stream().map(preconditionMapper::map).toList(); + + var req = + com.authzed.api.v1.PermissionService.WriteRelationshipsRequest.newBuilder() + .addAllOptionalPreconditions(mappedPreconditions) + .addAllUpdates(mappedUpdates) + .build(); + + try { + var res = permissionsService.writeRelationships(req); + var zedToken = res.getWrittenAt().getToken(); + return new UpdateResultImpl(zedToken); + } catch (StatusRuntimeException e) { + throw exceptionMapper.map(e); + } + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SubjectReferenceMapper.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SubjectReferenceMapper.java new file mode 100644 index 0000000..2ea470a --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/SubjectReferenceMapper.java @@ -0,0 +1,16 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.Core; +import com.oviva.spicegen.api.SubjectRef; + +public class SubjectReferenceMapper { + + public Core.SubjectReference map(SubjectRef subjectRef) { + var ref = + Core.ObjectReference.newBuilder() + .setObjectType(subjectRef.kind()) + .setObjectId(subjectRef.id()) + .build(); + return Core.SubjectReference.newBuilder().setObject(ref).build(); + } +} diff --git a/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/UpdateResultImpl.java b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/UpdateResultImpl.java new file mode 100644 index 0000000..ac25b0a --- /dev/null +++ b/spicedb-binding/src/main/java/com/oviva/spicegen/spicedbbinding/internal/UpdateResultImpl.java @@ -0,0 +1,5 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.oviva.spicegen.api.UpdateResult; + +public record UpdateResultImpl(String consistencyToken) implements UpdateResult {} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilderTest.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilderTest.java new file mode 100644 index 0000000..8c8624d --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/SpiceDbPermissionServiceBuilderTest.java @@ -0,0 +1,33 @@ +package com.oviva.spicegen.spicedbbinding; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; + +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.authzed.grpcutil.BearerToken; +import com.oviva.spicegen.api.PermissionService; +import io.grpc.ManagedChannelBuilder; +import org.junit.Test; + +public class SpiceDbPermissionServiceBuilderTest { + + @Test + public void test_spiceDbPermissionServiceBuilder() { + + var token = "t0ken"; + var host = "127.0.0.1"; + var port = 50051; + + var bearerToken = new BearerToken(token); + var channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + var permissionsService = + PermissionsServiceGrpc.newBlockingStub(channel).withCallCredentials(bearerToken); + + var svc = + SpiceDbPermissionServiceBuilder.newBuilder() + .permissionsBlockingStub(permissionsService) + .build(); + + assertThat(svc, instanceOf(PermissionService.class)); + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapperTest.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapperTest.java new file mode 100644 index 0000000..2c7787f --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapperTest.java @@ -0,0 +1,53 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; + +import com.authzed.api.v1.Core; +import com.oviva.spicegen.api.ObjectRef; +import com.oviva.spicegen.api.UpdateRelationship; +import org.junit.jupiter.api.Test; + +public class CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapperTest { + + private static final String TENANT = "tenant"; + private static final String USER = "user"; + private static final String ADMINISTRATOR = "administrator"; + private static final String ID = "9392"; + + private final CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper mapper = + new CreateRelationshipUpdateToSpiceDBUpdateRelationshipMapper(); + + @Test + public void test_mapper_withUpdateOperation() { + + var resource = ObjectRef.of(TENANT, ID); + var subject = ObjectRef.of(USER, ID); + + var updateRelationship = UpdateRelationship.ofUpdate(resource, ADMINISTRATOR, subject); + var map = mapper.map(updateRelationship); + + assertThat(map.getOperation(), equalTo(Core.RelationshipUpdate.Operation.OPERATION_TOUCH)); + assertThat(map.getRelationship(), notNullValue()); + assertThat(map.getRelationship().getRelation(), equalTo(ADMINISTRATOR)); + assertThat(map.getRelationship().getResource().getObjectId(), equalTo(ID)); + assertThat(map.getRelationship().getResource().getObjectType(), equalTo(TENANT)); + } + + @Test + public void test_mapper_withDeleteOperation() { + + var resource = ObjectRef.of(TENANT, ID); + var subject = ObjectRef.of(USER, ID); + + var updateRelationship = UpdateRelationship.ofDelete(resource, ADMINISTRATOR, subject); + var map = mapper.map(updateRelationship); + + assertThat(map.getOperation(), equalTo(Core.RelationshipUpdate.Operation.OPERATION_DELETE)); + assertThat(map.getRelationship(), notNullValue()); + assertThat(map.getRelationship().getRelation(), equalTo(ADMINISTRATOR)); + assertThat(map.getRelationship().getResource().getObjectId(), equalTo(ID)); + assertThat(map.getRelationship().getResource().getObjectType(), equalTo(TENANT)); + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapperTest.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapperTest.java new file mode 100644 index 0000000..3c4496f --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/GrpcExceptionMapperTest.java @@ -0,0 +1,63 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import com.oviva.spicegen.api.exceptions.*; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.Test; + +public class GrpcExceptionMapperTest { + + private final GrpcExceptionMapper grpcExceptionMapper = new GrpcExceptionMapper(); + + @Test + public void test_map_permissionDenied() { + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.PERMISSION_DENIED)); + + assertThat(exception, instanceOf(AuthorizationException.class)); + assertThat(exception.getMessage(), equalTo("permission denied")); + } + + @Test + public void test_map_unauthenticated() { + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.UNAUTHENTICATED)); + + assertThat(exception, instanceOf(AuthenticationException.class)); + assertThat(exception.getMessage(), equalTo("unauthenticated")); + } + + @Test + public void test_map_alreadyExists() { + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.ALREADY_EXISTS)); + + assertThat(exception, instanceOf(ConflictException.class)); + assertThat(exception.getMessage(), equalTo("already exists")); + } + + @Test + public void test_map_invalidArgument() { + + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.INVALID_ARGUMENT)); + + assertThat(exception, instanceOf(ValidationException.class)); + assertThat(exception.getMessage(), equalTo("invalid argument")); + } + + @Test + public void test_map_failedPrecondition() { + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.FAILED_PRECONDITION)); + + assertThat(exception, instanceOf(ValidationException.class)); + assertThat(exception.getMessage(), equalTo("failed precondition")); + } + + @Test + public void test_map_unexpectedValue() { + var exception = grpcExceptionMapper.map(new StatusRuntimeException(Status.CANCELLED)); + + assertThat(exception, instanceOf(ClientException.class)); + assertThat(exception.getMessage(), containsString("unexpected status:")); + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTest.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTest.java new file mode 100644 index 0000000..0e3e6d1 --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTest.java @@ -0,0 +1,192 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.authzed.api.v1.Core; +import com.authzed.api.v1.PermissionService; +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.authzed.api.v1.SchemaServiceGrpc; +import com.oviva.spicegen.api.ObjectRef; +import com.oviva.spicegen.api.SubjectRef; +import com.oviva.spicegen.spicedbbinding.test.Fixtures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@Tag("contract") +class SpiceDbContractTest { + + private SchemaServiceGrpc.SchemaServiceBlockingStub schemaService; + private PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService; + + @BeforeEach + void before( + SchemaServiceGrpc.SchemaServiceBlockingStub schemaService, + PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService) { + + this.schemaService = schemaService; + this.permissionsService = permissionsService; + } + + @TestTemplate + @ExtendWith(SpiceDbContractTestContextProvider.class) + void test_patientWithDocument() { + + // given + writeDocumentSchema(); + + var tenantId = SpiceDbUtils.newId(); + var tenant1 = ObjectRef.of("tenant", tenantId); + + var document1 = ObjectRef.of("document", SpiceDbUtils.newId()); + + var patientId = SpiceDbUtils.newId(); + var patient1 = ObjectRef.of("patient", patientId); + + var coachJulieId = SpiceDbUtils.newId(); + var julie = ObjectRef.of("user", coachJulieId); + + var coachElsaId = SpiceDbUtils.newId(); + var elsa = ObjectRef.of("user", coachElsaId); + + updateRelationship(tenant1, "coach", julie); + updateRelationship(tenant1, "coach", elsa); + updateRelationship(patient1, "tenant", tenant1); + + updateRelationship(patient1, "coacher", julie); + + updateRelationship(document1, "patient", patient1); + + // when & then + assertTrue( + checkPermission( + document1, "write", SubjectRef.ofObject(ObjectRef.of("user", coachJulieId)))); + assertFalse( + checkPermission( + document1, "write", SubjectRef.ofObject(ObjectRef.of("user", coachElsaId)))); + + assertFalse( + checkPermission( + ObjectRef.of("document", "1"), + "write", + SubjectRef.ofObject(ObjectRef.of("user", coachElsaId)))); + } + + @TestTemplate + @ExtendWith(SpiceDbContractTestContextProvider.class) + void test_permissionUpsert() { + + // given + writeDocumentSchema(); + + var tenantId = SpiceDbUtils.newId(); + var tenant1 = ObjectRef.of("tenant", tenantId); + + var document1 = ObjectRef.of("document", SpiceDbUtils.newId()); + + var patientId = SpiceDbUtils.newId(); + var patient1 = ObjectRef.of("patient", patientId); + + var coachJulieId = SpiceDbUtils.newId(); + var julie = ObjectRef.of("user", coachJulieId); + + updateRelationship(tenant1, "coach", julie); + updateRelationship(patient1, "tenant", tenant1); + + updateRelationship(patient1, "coacher", julie); + + updateRelationship(document1, "patient", patient1); + + // when + updateRelationship(tenant1, "coach", julie); + updateRelationship(tenant1, "coach", julie); + updateRelationship(tenant1, "coach", julie); + + // then + assertTrue( + checkPermission( + document1, "write", SubjectRef.ofObject(ObjectRef.of("user", coachJulieId)))); + } + + @TestTemplate + @ExtendWith(SpiceDbContractTestContextProvider.class) + void test_permissionDelete() { + + // given + writeDocumentSchema(); + + var tenantId = SpiceDbUtils.newId(); + var tenant1 = ObjectRef.of("tenant", tenantId); + + var document1 = ObjectRef.of("document", SpiceDbUtils.newId()); + + var patientId = SpiceDbUtils.newId(); + var patient1 = ObjectRef.of("patient", patientId); + + var coachJulieId = SpiceDbUtils.newId(); + var julie = ObjectRef.of("user", coachJulieId); + + updateRelationship(tenant1, "coach", julie); + updateRelationship(patient1, "tenant", tenant1); + + updateRelationship(patient1, "coacher", julie); + + updateRelationship(document1, "patient", patient1); + + // when + updateRelationship(tenant1, "coach", julie); + + deleteRelationship(tenant1, "coach", julie); + deleteRelationship(tenant1, "coach", julie); + + // then + assertFalse( + checkPermission( + document1, "write", SubjectRef.ofObject(ObjectRef.of("user", coachJulieId)))); + } + + private void writeDocumentSchema() { + + var schema = Fixtures.contractTestSchema(); + schemaService.writeSchema(SpiceDbUtils.writeSchemaRequest(schema)); + } + + private boolean checkPermission(ObjectRef resource, String permission, SubjectRef subject) { + + var req = SpiceDbUtils.checkPermissionRequest(resource, permission, subject); + var res = permissionsService.checkPermission(req); + return res.getPermissionship() + == PermissionService.CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION; + } + + private String updateRelationship(ObjectRef resource, String relation, ObjectRef subject) { + return writeRelationship( + resource, relation, subject, Core.RelationshipUpdate.Operation.OPERATION_TOUCH); + } + + private String deleteRelationship(ObjectRef resource, String relation, ObjectRef subject) { + + return writeRelationship( + resource, relation, subject, Core.RelationshipUpdate.Operation.OPERATION_DELETE); + } + + private String writeRelationship( + ObjectRef resource, + String relation, + ObjectRef subject, + Core.RelationshipUpdate.Operation operation) { + + var req = SpiceDbUtils.writeRelationshipRequest(resource, relation, subject, operation); + var res = permissionsService.writeRelationships(req); + return res.getWrittenAt().getToken(); + } + + private boolean lookupResources(String resourceType, String permission, SubjectRef subject) { + + var req = SpiceDbUtils.lookupResourcesRequest(resourceType, permission, subject); + var res = permissionsService.lookupResources(req); + return res.hasNext(); + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTestContextProvider.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTestContextProvider.java new file mode 100644 index 0000000..410dd83 --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbContractTestContextProvider.java @@ -0,0 +1,156 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.PermissionsServiceGrpc; +import com.authzed.api.v1.SchemaServiceGrpc; +import com.authzed.grpcutil.BearerToken; +import com.oviva.spicegen.spicedbbinding.test.GenericTypedParameterResolver; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.io.File; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.extension.*; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class SpiceDbContractTestContextProvider implements TestTemplateInvocationContextProvider { + + private static final String TOKEN = "t0ken"; + private static final int GRPC_PORT = 50051; + + @Override + public boolean supportsTestTemplate(ExtensionContext extensionContext) { + return true; + } + + @Override + public Stream provideTestTemplateInvocationContexts( + ExtensionContext extensionContext) { + + return Stream.of(inMemorySpiceDB(), postgresSpiceDB()); + } + + private TestTemplateInvocationContext inMemorySpiceDB() { + + var spicedb = + new GenericContainer<>(DockerImageName.parse("quay.io/authzed/spicedb:v1.32.0")) + .withCommand("serve", "--grpc-preshared-key", TOKEN) + .withExposedPorts( + GRPC_PORT, // grpc + 8080, // dashboard + 9090 // metrics + ); + + spicedb.start(); + + var host = spicedb.getHost(); + var port = spicedb.getMappedPort(GRPC_PORT); + + var services = createServices(host, port); + + return createContext( + "in-memory SpiceDB", + services, + () -> { + quitelyShutdown(services.channel()); + spicedb.stop(); + }); + } + + private TestTemplateInvocationContext postgresSpiceDB() { + + var spiceDbServiceName = "spicedb_1"; + var environment = + new DockerComposeContainer<>(new File("src/main/docker/compose.dev.yaml")) + .withExposedService(spiceDbServiceName, GRPC_PORT); + + environment.start(); + + var host = environment.getServiceHost(spiceDbServiceName, GRPC_PORT); + var port = environment.getServicePort(spiceDbServiceName, GRPC_PORT); + var services = createServices(host, port); + return createContext( + "postgres backed SpiceDB", + services, + () -> { + quitelyShutdown(services.channel()); + environment.stop(); + }); + } + + private void quitelyShutdown(ManagedChannel channel) { + channel.shutdownNow(); + try { + channel.awaitTermination(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // quietly ignore + } + } + + public TestTemplateInvocationContext createContext( + String displayName, TestServices services, Runnable done) { + return new TestTemplateInvocationContext() { + @Override + public String getDisplayName(int invocationIndex) { + return displayName; + } + + @Override + public List getAdditionalExtensions() { + return List.of( + new GenericTypedParameterResolver<>(services.permissionsService()), + new GenericTypedParameterResolver<>(services.schemaService()), + new GenericTypedParameterResolver<>(services.channel()), + (AfterAllCallback) + extensionContext -> { + if (done != null) { + done.run(); + } + }); + } + }; + } + + public TestServices createServices(String host, int port) { + + var channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + + var bearerToken = new BearerToken(TOKEN); + + var schemaService = SchemaServiceGrpc.newBlockingStub(channel).withCallCredentials(bearerToken); + + var permissionsService = + PermissionsServiceGrpc.newBlockingStub(channel).withCallCredentials(bearerToken); + return new TestServices(channel, schemaService, permissionsService); + } + + public static final class TestServices { + + private final ManagedChannel channel; + private final SchemaServiceGrpc.SchemaServiceBlockingStub schemaService; + private final PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService; + + public TestServices( + ManagedChannel channel, + SchemaServiceGrpc.SchemaServiceBlockingStub schemaService, + PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService) { + this.channel = channel; + this.schemaService = schemaService; + this.permissionsService = permissionsService; + } + + public ManagedChannel channel() { + return channel; + } + + public SchemaServiceGrpc.SchemaServiceBlockingStub schemaService() { + return schemaService; + } + + public PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsService() { + return permissionsService; + } + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbUtils.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbUtils.java new file mode 100644 index 0000000..0760c8f --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/internal/SpiceDbUtils.java @@ -0,0 +1,104 @@ +package com.oviva.spicegen.spicedbbinding.internal; + +import com.authzed.api.v1.Core; +import com.authzed.api.v1.PermissionService; +import com.authzed.api.v1.SchemaServiceOuterClass; +import com.oviva.spicegen.api.ObjectRef; +import com.oviva.spicegen.api.SubjectRef; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SpiceDbUtils { + + private static Logger logger = LoggerFactory.getLogger(SpiceDbUtils.class); + + public static String newId() { + // removing `-` to avoid problems on the playground + return UUID.randomUUID().toString().replaceAll("-", ""); + } + + public static Core.ObjectReference toRef(ObjectRef ref) { + + return Core.ObjectReference.newBuilder() + .setObjectType(ref.kind()) + .setObjectId(ref.id()) + .build(); + } + + public static Core.SubjectReference toRef(SubjectRef ref) { + + return Core.SubjectReference.newBuilder() + .setObject( + Core.ObjectReference.newBuilder().setObjectId(ref.id()).setObjectType(ref.kind())) + .build(); + } + + public static PermissionService.WriteRelationshipsRequest updateRelationshipRequest( + ObjectRef resource, String relation, ObjectRef subject) { + + logger.info("update: " + resource.toString() + "#" + relation + "@" + subject); + return writeRelationshipRequest( + resource, relation, subject, Core.RelationshipUpdate.Operation.OPERATION_TOUCH); + } + + public static PermissionService.WriteRelationshipsRequest deleteRelationshipRequest( + ObjectRef resource, String relation, ObjectRef subject) { + + logger.info("delete: " + resource.toString() + "#" + relation + "@" + subject); + return writeRelationshipRequest( + resource, relation, subject, Core.RelationshipUpdate.Operation.OPERATION_DELETE); + } + + public static PermissionService.WriteRelationshipsRequest writeRelationshipRequest( + ObjectRef resource, + String relation, + ObjectRef subject, + Core.RelationshipUpdate.Operation operation) { + + logger.info("write: " + resource.toString() + "#" + relation + "@" + subject); + + var subjectRef = toRef(subject); + + var resourceRef = toRef(resource); + + return PermissionService.WriteRelationshipsRequest.newBuilder() + .addUpdates( + Core.RelationshipUpdate.newBuilder() + .setOperation(operation) + .setRelationship( + Core.Relationship.newBuilder() + .setRelation(relation) + .setSubject( + Core.SubjectReference.newBuilder().setObject(subjectRef).build()) + .setResource(resourceRef)) + .build()) + .build(); + } + + public static SchemaServiceOuterClass.WriteSchemaRequest writeSchemaRequest(String schema) { + return SchemaServiceOuterClass.WriteSchemaRequest.newBuilder().setSchema(schema).build(); + } + + public static PermissionService.CheckPermissionRequest checkPermissionRequest( + ObjectRef resource, String permission, SubjectRef subject) { + + return PermissionService.CheckPermissionRequest.newBuilder() + .setConsistency(PermissionService.Consistency.newBuilder().setFullyConsistent(true).build()) + .setResource(toRef(resource)) + .setPermission(permission) + .setSubject(toRef(subject)) + .build(); + } + + public static PermissionService.LookupResourcesRequest lookupResourcesRequest( + String resourceType, String permission, SubjectRef subject) { + + return PermissionService.LookupResourcesRequest.newBuilder() + .setConsistency(PermissionService.Consistency.newBuilder().setFullyConsistent(true).build()) + .setResourceObjectType(resourceType) + .setPermission(permission) + .setSubject(toRef(subject)) + .build(); + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/Fixtures.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/Fixtures.java new file mode 100644 index 0000000..bb8bbbb --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/Fixtures.java @@ -0,0 +1,24 @@ +package com.oviva.spicegen.spicedbbinding.test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class Fixtures { + + public static String contractTestSchema() { + var data = readFixture("/spicedb_contract_test_schema.zed"); + return new String(data, StandardCharsets.UTF_8); + } + + private static byte[] readFixture(String path) { + + try (var is = Fixtures.class.getResourceAsStream(path)) { + if (is == null) { + throw new IllegalArgumentException("resource not found: " + path); + } + return is.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException("cannot read resource: " + path, e); + } + } +} diff --git a/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/GenericTypedParameterResolver.java b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/GenericTypedParameterResolver.java new file mode 100644 index 0000000..ce247b5 --- /dev/null +++ b/spicedb-binding/src/test/java/com/oviva/spicegen/spicedbbinding/test/GenericTypedParameterResolver.java @@ -0,0 +1,30 @@ +package com.oviva.spicegen.spicedbbinding.test; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +// borrowed: +// https://github.com/eugenp/tutorials/blob/master/testing-modules/junit5-annotations/src/test/java/com/baeldung/junit5/templates/GenericTypedParameterResolver.java +public class GenericTypedParameterResolver implements ParameterResolver { + T data; + + public GenericTypedParameterResolver(T data) { + this.data = data; + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return parameterContext.getParameter().getType().isInstance(data); + } + + @Override + public Object resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return data; + } +} diff --git a/spicedb-binding/src/test/resources/spicedb_contract_test_schema.zed b/spicedb-binding/src/test/resources/spicedb_contract_test_schema.zed new file mode 100644 index 0000000..51129b0 --- /dev/null +++ b/spicedb-binding/src/test/resources/spicedb_contract_test_schema.zed @@ -0,0 +1,17 @@ +definition user {} +definition tenant { + relation patient: user + relation coach: user +} + +definition patient { + relation tenant: tenant + relation coacher: user + + permission write = coacher & tenant->coach +} + +definition document { + relation patient: patient + permission write = patient->write +} \ No newline at end of file