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