From d00b461269b257e62495678b0102c30eaff5b100 Mon Sep 17 00:00:00 2001 From: Valdemar Grange Date: Wed, 7 Feb 2024 22:10:23 +0100 Subject: [PATCH] new bulk api --- .../scala/spice4s/client/SpiceClient.scala | 38 ++++++++----- .../models/BulkCheckPermissionPair.scala | 49 +++++++++++++++++ .../models/BulkCheckPermissionRequest.scala | 30 +++++++++++ .../BulkCheckPermissionRequestItem.scala | 43 +++++++++++++++ .../models/BulkCheckPermissionResponse.scala | 34 ++++++++++++ .../BulkCheckPermissionResponseItem.scala | 31 +++++++++++ .../client/models/RelationshipFilter.scala | 1 + .../authzed/api/v1/error_reason.proto | 14 +++++ .../authzed/api/v1/experimental_service.proto | 54 +++++++++++++++++++ .../authzed/api/v1/permission_service.proto | 24 +++++++-- .../authzed/api/v1/schema_service.proto | 2 +- .../authzed/api/v1/watch_service.proto | 9 ++++ .../scala/spice4s/testkit/StubClient.scala | 21 ++++++++ 13 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 client/src/main/scala/spice4s/client/models/BulkCheckPermissionPair.scala create mode 100644 client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequest.scala create mode 100644 client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequestItem.scala create mode 100644 client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponse.scala create mode 100644 client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponseItem.scala diff --git a/client/src/main/scala/spice4s/client/SpiceClient.scala b/client/src/main/scala/spice4s/client/SpiceClient.scala index c720821..68bf18b 100644 --- a/client/src/main/scala/spice4s/client/SpiceClient.scala +++ b/client/src/main/scala/spice4s/client/SpiceClient.scala @@ -20,6 +20,7 @@ import cats.effect._ import cats.implicits._ import cats._ import com.authzed.api.v1.permission_service.PermissionsServiceFs2Grpc +import com.authzed.api.v1.experimental_service.ExperimentalServiceFs2Grpc import spice4s.client.models._ import spice4s.client.util._ import fs2.Stream @@ -40,6 +41,8 @@ trait SpiceClient[F[_]] { self => def lookupSubjects(x: LookupSubjectsRequest): Stream[F, LookupSubjectsResponse] + def bulkCheckPermission(x: BulkCheckPermissionRequest): F[BulkCheckPermissionResponse] + def mapK[G[_]](fk: F ~> G): SpiceClient[G] = new SpiceClient[G] { override def readRelationships(x: ReadRelationshipsRequest): Stream[G, ReadRelationshipsResponse] = self.readRelationships(x).translate(fk) @@ -61,6 +64,9 @@ trait SpiceClient[F[_]] { self => override def lookupSubjects(x: LookupSubjectsRequest): Stream[G, LookupSubjectsResponse] = self.lookupSubjects(x).translate(fk) + + override def bulkCheckPermission(x: BulkCheckPermissionRequest): G[BulkCheckPermissionResponse] = + fk(self.bulkCheckPermission(x)) } } @@ -68,19 +74,22 @@ object SpiceClient { val AUTHORIZATION = "authorization" val METADATA_KEY = Metadata.Key.of(AUTHORIZATION, Metadata.ASCII_STRING_MARSHALLER) - def fromChannel[F[_]: Async](channel: Channel, token: String): Resource[F, SpiceClient[F]] = - PermissionsServiceFs2Grpc - .clientResource[F, Unit]( - channel, - { _ => - val m = new Metadata() - m.put(METADATA_KEY, s"Bearer ${token}") - m - } - ) - .map(fromClient[F]) - - def fromClient[F[_]: MonadThrow](client: PermissionsServiceFs2Grpc[F, Unit]): SpiceClient[F] = { + def fromChannel[F[_]: Async](channel: Channel, token: String): Resource[F, SpiceClient[F]] = { + val metadata = { + val m = new Metadata() + m.put(METADATA_KEY, s"Bearer ${token}") + m + } + ( + PermissionsServiceFs2Grpc.clientResource[F, Unit](channel, _ => metadata), + ExperimentalServiceFs2Grpc.clientResource[F, Unit](channel, _ => metadata) + ).mapN(fromClient[F]) + } + + def fromClient[F[_]: MonadThrow]( + client: PermissionsServiceFs2Grpc[F, Unit], + experimental: ExperimentalServiceFs2Grpc[F, Unit] + ): SpiceClient[F] = { def decodeWith[A, B](f: A => Decoded[B]): A => F[B] = a => raiseIn[F, B](f(a)) new SpiceClient[F] { @@ -104,6 +113,9 @@ object SpiceClient { def lookupSubjects(x: LookupSubjectsRequest): Stream[F, LookupSubjectsResponse] = client.lookupSubjects(x.encode, ()).evalMap(decodeWith(LookupSubjectsResponse.decode)) + + def bulkCheckPermission(x: BulkCheckPermissionRequest): F[BulkCheckPermissionResponse] = + experimental.bulkCheckPermission(x.encode, ()).flatMap(decodeWith(BulkCheckPermissionResponse.decode)) } } } diff --git a/client/src/main/scala/spice4s/client/models/BulkCheckPermissionPair.scala b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionPair.scala new file mode 100644 index 0000000..2af657b --- /dev/null +++ b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionPair.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2023 CaseHubDK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package spice4s.client.models + +import spice4s.client.util._ +import cats.implicits._ +import com.authzed.api.v1.{experimental_service => es} + +final case class BulkCheckPermissionPair( + request: BulkCheckPermissionRequestItem, + response: BulkCheckPermissionPair.Response +) + +object BulkCheckPermissionPair { + sealed trait Response extends Product with Serializable + object Response { + final case class Item(value: BulkCheckPermissionResponseItem) extends Response + final case class Error(value: com.google.rpc.status.Status) extends Response + + def decode(x: es.BulkCheckPermissionPair.Response): Decoded[Response] = { + import es.BulkCheckPermissionPair.{Response => R} + x match { + case R.Item(x) => BulkCheckPermissionResponseItem.decode(x).map(Item.apply) + case R.Error(x) => Error(x).pure[Decoded] + case R.Empty => raise("empty response") + } + } + } + + def decode(x: es.BulkCheckPermissionPair): Decoded[BulkCheckPermissionPair] = + ( + field("request")(req(x.request) andThen BulkCheckPermissionRequestItem.decode), + field("response")(Response.decode(x.response)) + ).mapN(BulkCheckPermissionPair.apply) +} diff --git a/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequest.scala b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequest.scala new file mode 100644 index 0000000..a594b80 --- /dev/null +++ b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequest.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2023 CaseHubDK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package spice4s.client.models + +import com.authzed.api.v1.{experimental_service => es} +import cats.data.NonEmptyList + +final case class BulkCheckPermissionRequest( + consistency: Option[Consistency] = None, + items: NonEmptyList[BulkCheckPermissionRequestItem] +) { + def encode = es.BulkCheckPermissionRequest.of( + consistency.map(_.encode), + items.map(_.encode).toList + ) +} diff --git a/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequestItem.scala b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequestItem.scala new file mode 100644 index 0000000..c8e5fed --- /dev/null +++ b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionRequestItem.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2023 CaseHubDK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package spice4s.client.models + +import spice4s.client.util._ +import cats.implicits._ +import com.authzed.api.v1.{experimental_service => es} + +final case class BulkCheckPermissionRequestItem( + resource: ObjectReference, + permission: Relation, + subject: SubjectReference +) { + def encode = es.BulkCheckPermissionRequestItem.of( + resource.encode.some, + permission.value, + subject.encode.some, + None + ) +} + +object BulkCheckPermissionRequestItem { + def decode(x: es.BulkCheckPermissionRequestItem): Decoded[BulkCheckPermissionRequestItem] = + ( + field("resource")(req(x.resource) andThen ObjectReference.decode), + field("permission")(Relation.decode(x.permission) andThen req), + field("subject")(req(x.subject) andThen SubjectReference.decode) + ).mapN(BulkCheckPermissionRequestItem.apply) +} diff --git a/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponse.scala b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponse.scala new file mode 100644 index 0000000..2c28958 --- /dev/null +++ b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponse.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2023 CaseHubDK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package spice4s.client.models + +import spice4s.client.util._ +import cats.implicits._ +import com.authzed.api.v1.{experimental_service => es} + +final case class BulkCheckPermissionResponse( + checkedAt: Option[ZedToken], + pairs: List[BulkCheckPermissionPair] +) + +object BulkCheckPermissionResponse { + def decode(x: es.BulkCheckPermissionResponse): Decoded[BulkCheckPermissionResponse] = + ( + field("checkedAt")(x.checkedAt.flatTraverse(ZedToken.decode)), + field("pairs")(indexed(x.pairs.toList.map(BulkCheckPermissionPair.decode))) + ).mapN(BulkCheckPermissionResponse.apply) +} diff --git a/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponseItem.scala b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponseItem.scala new file mode 100644 index 0000000..2d08b07 --- /dev/null +++ b/client/src/main/scala/spice4s/client/models/BulkCheckPermissionResponseItem.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2023 CaseHubDK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package spice4s.client.models + +import spice4s.client.util._ +import com.authzed.api.v1.{experimental_service => es} + +final case class BulkCheckPermissionResponseItem( + permissionship: CheckPermissionResponse.Permissionship +) + +object BulkCheckPermissionResponseItem { + def decode(x: es.BulkCheckPermissionResponseItem): Decoded[BulkCheckPermissionResponseItem] = + ( + field("permissionship")(CheckPermissionResponse.Permissionship.decode(x.permissionship)) + ).map(BulkCheckPermissionResponseItem.apply) +} diff --git a/client/src/main/scala/spice4s/client/models/RelationshipFilter.scala b/client/src/main/scala/spice4s/client/models/RelationshipFilter.scala index c3480d8..227914f 100644 --- a/client/src/main/scala/spice4s/client/models/RelationshipFilter.scala +++ b/client/src/main/scala/spice4s/client/models/RelationshipFilter.scala @@ -28,6 +28,7 @@ final case class RelationshipFilter( def encode = ps.RelationshipFilter.of( resourceType.value, resourceId.foldMap(_.value), + "", relation.foldMap(_.value), subjectFilter.map(_.encode) ) diff --git a/proto/src/main/protobuf/authzed/api/v1/error_reason.proto b/proto/src/main/protobuf/authzed/api/v1/error_reason.proto index fb9d3e8..bbeedf9 100644 --- a/proto/src/main/protobuf/authzed/api/v1/error_reason.proto +++ b/proto/src/main/protobuf/authzed/api/v1/error_reason.proto @@ -285,4 +285,18 @@ enum ErrorReason { // } // } ERROR_REASON_MAXIMUM_DEPTH_EXCEEDED = 19; + + // The request failed due to a serialization error in the backend database. + // This typically indicates that various in flight transactions conflicted with each other + // and the database had to abort one or more of them. SpiceDB will retry a few times before returning + // the error to the client. + // + // Example of an ErrorInfo: + // + // { + // "reason": "ERROR_REASON_SERIALIZATION_FAILURE", + // "domain": "authzed.com", + // "metadata": {} + // } + ERROR_REASON_SERIALIZATION_FAILURE = 20; } \ No newline at end of file diff --git a/proto/src/main/protobuf/authzed/api/v1/experimental_service.proto b/proto/src/main/protobuf/authzed/api/v1/experimental_service.proto index 73489c5..efa6b32 100644 --- a/proto/src/main/protobuf/authzed/api/v1/experimental_service.proto +++ b/proto/src/main/protobuf/authzed/api/v1/experimental_service.proto @@ -6,6 +6,8 @@ option java_package = "com.authzed.api.v1"; import "google/api/annotations.proto"; import "validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "google/rpc/status.proto"; import "authzed/api/v1/core.proto"; import "authzed/api/v1/permission_service.proto"; @@ -41,6 +43,54 @@ service ExperimentalService { body: "*" }; } + + rpc BulkCheckPermission(BulkCheckPermissionRequest) + returns (BulkCheckPermissionResponse) { + option (google.api.http) = { + post: "/v1/experimental/permissions/bulkcheckpermission" + body: "*" + }; + } +} + +message BulkCheckPermissionRequest { + Consistency consistency = 1; + + repeated BulkCheckPermissionRequestItem items = 2 [ (validate.rules).repeated .items.message.required = true ]; +} + +message BulkCheckPermissionRequestItem { + ObjectReference resource = 1 [ (validate.rules).message.required = true ]; + + string permission = 2 [ (validate.rules).string = { + pattern : "^([a-z][a-z0-9_]{1,62}[a-z0-9])?$", + max_bytes : 64, + } ]; + + SubjectReference subject = 3 [ (validate.rules).message.required = true ]; + + google.protobuf.Struct context = 4 [ (validate.rules).message.required = false ]; +} + +message BulkCheckPermissionResponse { + ZedToken checked_at = 1 [ (validate.rules).message.required = false ]; + + repeated BulkCheckPermissionPair pairs = 2 [ (validate.rules).repeated .items.message.required = true ]; +} + +message BulkCheckPermissionPair { + BulkCheckPermissionRequestItem request = 1; + oneof response { + BulkCheckPermissionResponseItem item = 2; + google.rpc.Status error = 3; + } +} + +message BulkCheckPermissionResponseItem { + + CheckPermissionResponse.Permissionship permissionship = 1 [ (validate.rules).enum = {defined_only: true, not_in: [0]} ]; + + PartialCaveatInfo partial_caveat_info = 2 [ (validate.rules).message.required = false ]; } // BulkImportRelationshipsRequest represents one batch of the streaming @@ -73,6 +123,10 @@ message BulkExportRelationshipsRequest { // should resume being returned. The cursor can be found on the // BulkExportRelationshipsResponse object. Cursor optional_cursor = 3; + + // optional_relationship_filter, if specified, indicates the + // filter to apply to each relationship to be exported. + RelationshipFilter optional_relationship_filter = 4; } // BulkExportRelationshipsResponse is one page in a stream of relationship diff --git a/proto/src/main/protobuf/authzed/api/v1/permission_service.proto b/proto/src/main/protobuf/authzed/api/v1/permission_service.proto index eecc8ee..e28b73e 100644 --- a/proto/src/main/protobuf/authzed/api/v1/permission_service.proto +++ b/proto/src/main/protobuf/authzed/api/v1/permission_service.proto @@ -122,24 +122,42 @@ message Consistency { // RelationshipFilter is a collection of filters which when applied to a // relationship will return relationships that have exactly matching fields. // -// resource_type is required. All other fields are optional and if left -// unspecified will not filter relationships. +// All fields are optional and if left unspecified will not filter relationships, +// but at least one field must be specified. +// +// NOTE: The performance of the API will be affected by the selection of fields +// on which to filter. If a field is not indexed, the performance of the API +// can be significantly slower. message RelationshipFilter { + // resource_type is the *optional* resource type of the relationship. + // NOTE: It is not prefixed with "optional_" for legacy compatibility. string resource_type = 1 [ (validate.rules).string = { - pattern : "^([a-z][a-z0-9_]{1,61}[a-z0-9]/)*[a-z][a-z0-9_]{1,62}[a-z0-9]$", + pattern : "^(([a-z][a-z0-9_]{1,61}[a-z0-9]/)*[a-z][a-z0-9_]{1,62}[a-z0-9])?$", max_bytes : 128, } ]; + // optional_resource_id is the *optional* resource ID of the relationship. + // If specified, optional_resource_id_prefix cannot be specified. string optional_resource_id = 2 [ (validate.rules).string = { pattern : "^([a-zA-Z0-9/_|\\-=+]{1,})?$", max_bytes : 1024, } ]; + // optional_resource_id_prefix is the *optional* prefix for the resource ID of the relationship. + // If specified, optional_resource_id cannot be specified. + string optional_resource_id_prefix = 5 [ (validate.rules).string = { + pattern : "^([a-zA-Z0-9/_|\\-=+]{1,})?$", + max_bytes : 1024, + } ]; + + + // relation is the *optional* relation of the relationship. string optional_relation = 3 [ (validate.rules).string = { pattern : "^([a-z][a-z0-9_]{1,62}[a-z0-9])?$", max_bytes : 64, } ]; + // optional_subject_filter is the optional filter for the subjects of the relationships. SubjectFilter optional_subject_filter = 4; } diff --git a/proto/src/main/protobuf/authzed/api/v1/schema_service.proto b/proto/src/main/protobuf/authzed/api/v1/schema_service.proto index 91b9eb4..e13f554 100644 --- a/proto/src/main/protobuf/authzed/api/v1/schema_service.proto +++ b/proto/src/main/protobuf/authzed/api/v1/schema_service.proto @@ -50,7 +50,7 @@ message ReadSchemaResponse { message WriteSchemaRequest { // The Schema containing one or more Object Definitions that will be written // to the Permissions System. - string schema = 1 [ (validate.rules).string.max_bytes = 262144 ]; // 256KiB + string schema = 1 [ (validate.rules).string.max_bytes = 4194304 ]; // 4MiB } // WriteSchemaResponse is the resulting data after having written a Schema to diff --git a/proto/src/main/protobuf/authzed/api/v1/watch_service.proto b/proto/src/main/protobuf/authzed/api/v1/watch_service.proto index 3c14d40..b24e72a 100644 --- a/proto/src/main/protobuf/authzed/api/v1/watch_service.proto +++ b/proto/src/main/protobuf/authzed/api/v1/watch_service.proto @@ -8,6 +8,7 @@ import "google/api/annotations.proto"; import "validate/validate.proto"; import "authzed/api/v1/core.proto"; +import "authzed/api/v1/permission_service.proto"; service WatchService { rpc Watch(WatchRequest) returns (stream WatchResponse) { @@ -22,6 +23,9 @@ service WatchService { // watching mutations, and an optional start snapshot for when to start // watching. message WatchRequest { + // optional_object_types is a filter of resource object types to watch for changes. + // If specified, only changes to the specified object types will be returned and + // optional_relationship_filters cannot be used. repeated string optional_object_types = 1 [ (validate.rules).repeated .min_items = 0, (validate.rules).repeated .items.string = { @@ -39,6 +43,11 @@ message WatchRequest { // Note that if this cursor references a point-in-time containing data // that has been garbage collected, an error will be returned. ZedToken optional_start_cursor = 2; + + // optional_relationship_filters, if specified, indicates the + // filter(s) to apply to each relationship to be returned by watch. + // If specified, optional_object_types cannot be used. + repeated RelationshipFilter optional_relationship_filters = 3; } // WatchResponse contains all tuple modification events in ascending diff --git a/testkit/src/main/scala/spice4s/testkit/StubClient.scala b/testkit/src/main/scala/spice4s/testkit/StubClient.scala index 3e187e9..c610eff 100644 --- a/testkit/src/main/scala/spice4s/testkit/StubClient.scala +++ b/testkit/src/main/scala/spice4s/testkit/StubClient.scala @@ -154,6 +154,27 @@ class StubClient[F[_]: Monad]( } } } + + override def bulkCheckPermission(x: BulkCheckPermissionRequest): F[BulkCheckPermissionResponse] = + x.items + .traverse { req => + checkPermission( + CheckPermissionRequest( + x.consistency, + req.resource, + req.permission, + req.subject + ) + ).map(res => + BulkCheckPermissionPair( + req, + BulkCheckPermissionPair.Response.Item( + BulkCheckPermissionResponseItem(res.permissionship) + ) + ) + ) + } + .map(xs => BulkCheckPermissionResponse(None, xs.toList)) } object StubClient {