From 097ed955021f22854430bfba6f5c50242d886764 Mon Sep 17 00:00:00 2001 From: Michael Tanczos Date: Mon, 20 May 2024 08:51:43 -0400 Subject: [PATCH] feat: Update relationship handling and deletion Significant changes have been made to the way relationships are handled and deleted. The ReadRelationships method now accepts a SubjectFilter for the second parameter, along with an optional relationship limit and cursor. DeleteRelationshipsAsync has also been updated to use an optional SubjectFilter instead of a RelationshipFilter, and it now returns a DeleteRelationshipsResponse object with the ZedToken and deletion progress instead of just the ZedToken. In addition, new parameters have been added to several methods in ISpiceDbClient interface to support partial deletions and limiting results. A new enum DeletionProgress has been introduced to track progress of deletions. The code changes also include updates to tests reflecting these modifications. --- README.md | 5 ++ SpiceDb.Tests/SpiceDbClientTests.cs | 16 ++--- SpiceDb/Api/SpiceDbPermissions.cs | 43 ++++++++++-- SpiceDb/Enum/DeletionProgress.cs | 26 +++++++ SpiceDb/ISpiceDbClient.cs | 39 ++++++++--- SpiceDb/Models/DeleteRelationshipsResponse.cs | 14 ++++ SpiceDb/Models/ReadRelationshipsResponse.cs | 10 +++ SpiceDb/Models/RelationshipFilter.cs | 15 ++++ SpiceDb/Models/SubjectFilter.cs | 12 ++++ SpiceDb/SpiceDbClient.cs | 70 ++++++++++++++++--- 10 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 SpiceDb/Enum/DeletionProgress.cs create mode 100644 SpiceDb/Models/DeleteRelationshipsResponse.cs diff --git a/README.md b/README.md index 9ee4645..7fc61c7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ SpiceDb.net was created by Michael Tanczos and has contributions from Pavel Akim ## What's New? +1.5.2 +- BREAKING CHANGE: ReadRelationships now accepts a SubjectFilter (thanks to @epbensimpson) for the second parameter along with an optional relationship limit and cursor +- BREAKING CHANGE: DeleteRelationshipsAsync utilizes an optional SubjectFilter (thanks to @epbensimpson) instead of a RelationshipFilter +- BREAKING CHANGE: DeleteRelationshipsAsync returns a DeleteRelationshipsResponse object with the ZedToken and deletion progress instead of just the ZedToken + 1.5.1 - @nexthash added caveat handling for relationship creation - added WriteSchemaAsync method to client diff --git a/SpiceDb.Tests/SpiceDbClientTests.cs b/SpiceDb.Tests/SpiceDbClientTests.cs index 9f510b0..f0713d0 100644 --- a/SpiceDb.Tests/SpiceDbClientTests.cs +++ b/SpiceDb.Tests/SpiceDbClientTests.cs @@ -85,7 +85,7 @@ public async Task ReadRelationshipsAsyncTest_FilterSubjectType() var expected = GetRelationships("group:security"); List relationships = new(); - await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group" }, new RelationshipFilter { Type = "user", OptionalId = "jimmy" }, excludePrefix: true)) + await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group" }, new SubjectFilter { Type = "user", OptionalId = "jimmy" }, excludePrefix: true)) { relationships.Add(response.Relationship.ToString()!); } @@ -127,7 +127,7 @@ public async Task DeleteRelationshipsAsyncTest() List relationships = new(); - await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new RelationshipFilter { Type = "user" }, excludePrefix: true)) + await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new SubjectFilter { Type = "user" }, excludePrefix: true)) { relationships.Add(response.Relationship.ToString()!); } @@ -138,14 +138,14 @@ await _client.DeleteRelationshipsAsync(new RelationshipFilter OptionalId = "delete", OptionalRelation = "manager" }, - new RelationshipFilter + new SubjectFilter { Type = "user" }); List relationships2 = new(); - await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new RelationshipFilter { Type = "user" }, excludePrefix: true)) + await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new SubjectFilter { Type = "user" }, excludePrefix: true)) { relationships2.Add(response.Relationship.ToString()!); } @@ -163,7 +163,7 @@ public async Task DeleteRelationshipsAsyncWithSubjectIdTest() List relationships = new(); - await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new RelationshipFilter { Type = "user" }, excludePrefix: true)) + await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new SubjectFilter { Type = "user" }, excludePrefix: true)) { relationships.Add(response.Relationship.ToString()!); } @@ -174,7 +174,7 @@ await _client.DeleteRelationshipsAsync(new RelationshipFilter OptionalId = "delete", OptionalRelation = "manager" }, - new RelationshipFilter + new SubjectFilter { Type = "user", OptionalId = "test1" @@ -182,7 +182,7 @@ await _client.DeleteRelationshipsAsync(new RelationshipFilter List relationships2 = new(); - await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new RelationshipFilter { Type = "user" }, excludePrefix: true)) + await foreach (var response in _client!.ReadRelationshipsAsync(new RelationshipFilter { Type = "group", OptionalId = "delete" }, new SubjectFilter { Type = "user" }, excludePrefix: true)) { relationships2.Add(response.Relationship.ToString()!); } @@ -205,7 +205,7 @@ public async Task AddRelationshipsAsync_AddBatchRelationships_ReturnsValidToken( // Assert: Check that a valid ZedToken is returned, indicating success ClassicAssert.IsNotNull(resultToken); - ClassicAssert.IsNotEmpty(resultToken.Token); + ClassicAssert.IsNotEmpty(resultToken!.Token); } [Test] diff --git a/SpiceDb/Api/SpiceDbPermissions.cs b/SpiceDb/Api/SpiceDbPermissions.cs index dad5583..5c7bd5a 100644 --- a/SpiceDb/Api/SpiceDbPermissions.cs +++ b/SpiceDb/Api/SpiceDbPermissions.cs @@ -1,8 +1,12 @@ -using Authzed.Api.V1; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Authzed.Api.V1; using Google.Protobuf.Collections; using Grpc.Core; using SpiceDb.Enum; using SpiceDb.Models; +using Cursor = SpiceDb.Models.Cursor; +using DeleteRelationshipsResponse = SpiceDb.Models.DeleteRelationshipsResponse; using Precondition = Authzed.Api.V1.Precondition; using Relationship = Authzed.Api.V1.Relationship; using RelationshipUpdate = Authzed.Api.V1.RelationshipUpdate; @@ -294,6 +298,8 @@ public async Task CheckPermissionAsync(string resourceType, public async IAsyncEnumerable ReadRelationshipsAsync(string resourceType, string optionalResourceId = "", string optionalRelation = "", string optionalSubjectType = "", string optionalSubjectId = "", string? optionalSubjectRelation = null, + int? limit = null, + Cursor? cursor = null, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness) { @@ -305,7 +311,12 @@ public async Task CheckPermissionAsync(string resourceType, ReadRelationshipsRequest req = new ReadRelationshipsRequest() { Consistency = new Consistency { MinimizeLatency = true, AtExactSnapshot = zedToken }, - RelationshipFilter = CreateRelationshipFilter(resourceType, optionalResourceId, optionalRelation, optionalSubjectType, optionalSubjectId, optionalSubjectRelation) + RelationshipFilter = CreateRelationshipFilter(resourceType, optionalResourceId, optionalRelation, optionalSubjectType, optionalSubjectId, optionalSubjectRelation), + OptionalLimit = limit != null ? Math.Clamp((uint)limit, 0, 1000) : 0, + OptionalCursor = cursor is null ? null : new Authzed.Api.V1.Cursor + { + Token = cursor.Token + } }; if (cacheFreshness == CacheFreshness.AtLeastAsFreshAs) @@ -334,7 +345,8 @@ public async Task CheckPermissionAsync(string resourceType, Context = resp.Relationship.OptionalCaveat.Context.FromStruct() } : null - ) + ), + AfterResultCursor = resp.AfterResultCursor != null ? new Cursor { Token = resp.AfterResultCursor.Token } : null }; yield return response; @@ -355,7 +367,11 @@ public bool UpdateRelationships(ref RepeatedField updateColl } } - public async Task DeleteRelationshipsAsync(string resourceType, string optionalResourceId = "", string optionalRelation = "", string optionalSubjectType ="", string optionalSubjectId = "", string? optionalSubjectRelation = null, RepeatedField? optionalPreconditions = null, DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task DeleteRelationshipsAsync(string resourceType, string optionalResourceId = "", string optionalRelation = "", string optionalSubjectType ="", string optionalSubjectId = "", string? optionalSubjectRelation = null, + RepeatedField? optionalPreconditions = null, + bool allowPartialDeletions = false, + int limit = 0, + DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken)) { var req = new DeleteRelationshipsRequest { @@ -363,9 +379,26 @@ public bool UpdateRelationships(ref RepeatedField updateColl RelationshipFilter = CreateRelationshipFilter(resourceType, optionalResourceId, optionalRelation, optionalSubjectType, optionalSubjectId, optionalSubjectRelation) }; + if (allowPartialDeletions) + { + req.OptionalLimit = (uint)Math.Clamp(limit, 0, 1000); + req.OptionalAllowPartialDeletions = allowPartialDeletions; + } + var response = await _acl!.DeleteRelationshipsAsync(req, deadline: deadline, cancellationToken: cancellationToken); - return response?.DeletedAt; + return new DeleteRelationshipsResponse + { + DeletedAt = response.DeletedAt?.ToSpiceDbToken(), + DeletionProgress = response.DeletionProgress switch + { + Authzed.Api.V1.DeleteRelationshipsResponse.Types.DeletionProgress.Unspecified => DeletionProgress + .Unspecified, + Authzed.Api.V1.DeleteRelationshipsResponse.Types.DeletionProgress.Complete => DeletionProgress.Complete, + Authzed.Api.V1.DeleteRelationshipsResponse.Types.DeletionProgress.Partial => DeletionProgress.Partial, + _ => throw new SwitchExpressionException(response.DeletionProgress) + } + }; } public RelationshipUpdate GetRelationshipUpdate(string resourceType, string resourceId, diff --git a/SpiceDb/Enum/DeletionProgress.cs b/SpiceDb/Enum/DeletionProgress.cs new file mode 100644 index 0000000..548db1e --- /dev/null +++ b/SpiceDb/Enum/DeletionProgress.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SpiceDb.Enum; + +public enum DeletionProgress +{ + Unspecified = 0, + + /// + /// Indicates that all remaining relationships matching the filter were deleted. Will be returned + /// even if no relationships were deleted. + /// + Complete = 1, + + /// + /// Indicates that a subset of the relationships matching the filter + /// were deleted. Only returned if optional_allow_partial_deletions was true, an optional_limit was + /// specified, and there existed more relationships matching the filter than optional_limit would allow. + /// Once all remaining relationships have been deleted, DeletionProgress.Complete will be returned. + /// + Partial = 2 +} diff --git a/SpiceDb/ISpiceDbClient.cs b/SpiceDb/ISpiceDbClient.cs index b19d4de..fd42aad 100644 --- a/SpiceDb/ISpiceDbClient.cs +++ b/SpiceDb/ISpiceDbClient.cs @@ -6,16 +6,25 @@ namespace SpiceDb; public interface ISpiceDbClient { /// - /// ReadRelationships reads a set of the relationships matching one or more filters. + /// Asynchronously reads a set of relationships matching one or more filters. /// - /// - /// - /// If true the schema prefix will be removed from all returned relationships - /// - /// - /// - IAsyncEnumerable ReadRelationshipsAsync(Models.RelationshipFilter resource, Models.SubjectFilter? subject = null, + /// The filter to apply to the resource part of the relationships. + /// An optional filter to apply to the subject part of the relationships. + /// Indicates whether the prefix should be excluded from the response. + /// If non-zero, specifies the limit on the number of relationships to return + /// before the stream is closed on the server side. By default, the stream will continue + /// resolving relationships until exhausted or the stream is closed due to the client or a + /// network issue. + /// If provided indicates the cursor after which results should resume being returned. + /// The cursor can be found on the ReadRelationshipsResponse object. + /// An optional ZedToken for specifying a version of the data to read. + /// Specifies the acceptable freshness of the data to be read from the cache. + /// An async enumerable of objects matching the specified filters. + IAsyncEnumerable ReadRelationshipsAsync(RelationshipFilter resource, + SubjectFilter? subject = null, bool excludePrefix = false, + int limit = 0, + Cursor? cursor = null, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness); @@ -36,11 +45,21 @@ public interface ISpiceDbClient /// resourceFilter.Type is required, all other fields are optional /// An optional additional subject filter /// An optional set of preconditions can be provided that must be satisfied for the operation to commit. + /// if true and a limit is specified, will delete matching found + /// relationships up to the count specified in optional_limit, and no more. + /// if non-zero, specifies the limit on the number of relationships to be deleted. + /// If there are more matching relationships found to be deleted than the limit specified here, + /// the deletion call will fail with an error to prevent partial deletion. If partial deletion + /// is needed, specify below that partial deletion is allowed. Partial deletions can be used + /// in a loop to delete large amounts of relationships in a *non-transactional* manner. /// An optional deadline for the call. The call will be cancelled if deadline is hit. /// An optional token for canceling the call. /// - Task DeleteRelationshipsAsync(SpiceDb.Models.RelationshipFilter resourceFilter, Models.SubjectFilter? optionalSubjectFilter = null, List? optionalPreconditions = null, DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken)); - + Task DeleteRelationshipsAsync(RelationshipFilter resourceFilter, + SubjectFilter? optionalSubjectFilter = null, List? optionalPreconditions = null, + bool allowPartialDeletions = false, int limit = 0, + DateTime? deadline = null, CancellationToken cancellationToken = default); + /// /// CheckPermission determines for a given resource whether a subject computes to having a permission or is a direct member of /// a particular relation. Contains support for context as well where context objects can be string, bool, double, int, uint, or long. diff --git a/SpiceDb/Models/DeleteRelationshipsResponse.cs b/SpiceDb/Models/DeleteRelationshipsResponse.cs new file mode 100644 index 0000000..b1d9ea4 --- /dev/null +++ b/SpiceDb/Models/DeleteRelationshipsResponse.cs @@ -0,0 +1,14 @@ +using SpiceDb.Enum; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SpiceDb.Models; + +public class DeleteRelationshipsResponse +{ + public ZedToken? DeletedAt { get; set; } + public DeletionProgress DeletionProgress { get; set; } = DeletionProgress.Unspecified; +} diff --git a/SpiceDb/Models/ReadRelationshipsResponse.cs b/SpiceDb/Models/ReadRelationshipsResponse.cs index 99dde1b..c8df7e2 100644 --- a/SpiceDb/Models/ReadRelationshipsResponse.cs +++ b/SpiceDb/Models/ReadRelationshipsResponse.cs @@ -2,6 +2,16 @@ public class ReadRelationshipsResponse { + /// + /// ZedToken at which the relationship was found. + /// public ZedToken? Token { get; set; } + /// + /// Relationship is the found relationship. + /// public Relationship Relationship { get; set; } = null!; + /// + /// Cursor that can be used to resume the ReadRelationships stream after this result. + /// + public Cursor? AfterResultCursor { get; set; } } diff --git a/SpiceDb/Models/RelationshipFilter.cs b/SpiceDb/Models/RelationshipFilter.cs index 48743c6..91d2ad2 100644 --- a/SpiceDb/Models/RelationshipFilter.cs +++ b/SpiceDb/Models/RelationshipFilter.cs @@ -1,8 +1,23 @@ namespace SpiceDb.Models; +/// +/// RelationshipFilter is a collection of filters which when applied to a relationship will return +/// relationships that have exactly matching fields. +/// All fields are optional and if left unspecified will not filter relationships, but at least one +/// field must be specified. +/// public class RelationshipFilter { + /// + /// Optional resource type of teh relationship + /// public string Type { get; set; } = string.Empty; + /// + /// Optional relation of the relationship + /// public string OptionalRelation { get; set; } = string.Empty; + /// + /// Optional Id of the relationship + /// public string OptionalId { get; set; } = string.Empty; } diff --git a/SpiceDb/Models/SubjectFilter.cs b/SpiceDb/Models/SubjectFilter.cs index 319310d..8ceb9a4 100644 --- a/SpiceDb/Models/SubjectFilter.cs +++ b/SpiceDb/Models/SubjectFilter.cs @@ -1,8 +1,20 @@ namespace SpiceDb.Models; +/// +/// Specifies a filter on the subject of a relationship. +/// public class SubjectFilter { + /// + /// Required subject type of the relationship + /// public required string Type { get; set; } + /// + /// Optional relation of the relationship + /// public string? OptionalRelation { get; set; } + /// + /// Optional resource ID of the relationship + /// public string OptionalId { get; set; } = string.Empty; } \ No newline at end of file diff --git a/SpiceDb/SpiceDbClient.cs b/SpiceDb/SpiceDbClient.cs index a51a691..7392465 100644 --- a/SpiceDb/SpiceDbClient.cs +++ b/SpiceDb/SpiceDbClient.cs @@ -1,11 +1,14 @@ using Google.Protobuf.Collections; using Grpc.Core; +using Grpc.Gateway.ProtocGenOpenapiv2.Options; using Grpc.Net.Client; +using Microsoft.Extensions.FileSystemGlobbing; using SpiceDb.Api; using SpiceDb.Enum; using SpiceDb.Models; using System.Net; using System.Runtime.CompilerServices; +using System.Security; using System.Text.RegularExpressions; namespace SpiceDb; @@ -95,20 +98,30 @@ private ChannelBase CreateDefaultAuthenticatedChannel(string address, string? to /// The filter to apply to the resource part of the relationships. /// An optional filter to apply to the subject part of the relationships. /// Indicates whether the prefix should be excluded from the response. + /// If non-zero, specifies the limit on the number of relationships to return + /// before the stream is closed on the server side. By default, the stream will continue + /// resolving relationships until exhausted or the stream is closed due to the client or a + /// network issue. + /// If provided indicates the cursor after which results should resume being returned. + /// The cursor can be found on the ReadRelationshipsResponse object. /// An optional ZedToken for specifying a version of the data to read. /// Specifies the acceptable freshness of the data to be read from the cache. /// An async enumerable of objects matching the specified filters. public async IAsyncEnumerable ReadRelationshipsAsync(RelationshipFilter resource, SubjectFilter? subject = null, bool excludePrefix = false, + int limit = 0, + Cursor? cursor = null, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness) { + var token = zedToken.ToAuthzedToken(); + await foreach (var rs in _spiceDbCore.Permissions.ReadRelationshipsAsync(EnsurePrefix(resource.Type)!, resource.OptionalId, resource.OptionalRelation, EnsurePrefix(subject?.Type) ?? string.Empty, subject?.OptionalId ?? string.Empty, - subject?.OptionalRelation, zedToken.ToAuthzedToken(), cacheFreshness)) + subject?.OptionalRelation, limit, cursor, token, cacheFreshness)) { if (excludePrefix) { @@ -203,7 +216,7 @@ public async IAsyncEnumerable ReadRelationshipsAsync( return response?.WrittenAt.ToSpiceDbToken(); } - + /// /// DeleteRelationships atomically bulk deletes all relationships matching the provided filter. If no relationships /// match, none will be deleted and the operation will succeed. An optional set of preconditions can be provided @@ -212,11 +225,19 @@ public async IAsyncEnumerable ReadRelationshipsAsync( /// resourceFilter.Type is required, all other fields are optional /// An optional additional subject filter /// An optional set of preconditions can be provided that must be satisfied for the operation to commit. + /// if true and a limit is specified, will delete matching found + /// relationships up to the count specified in optional_limit, and no more. + /// if non-zero, specifies the limit on the number of relationships to be deleted. + /// If there are more matching relationships found to be deleted than the limit specified here, + /// the deletion call will fail with an error to prevent partial deletion. If partial deletion + /// is needed, specify below that partial deletion is allowed. Partial deletions can be used + /// in a loop to delete large amounts of relationships in a *non-transactional* manner. /// An optional deadline for the call. The call will be cancelled if deadline is hit. /// An optional token for canceling the call. /// - public async Task DeleteRelationshipsAsync(RelationshipFilter resourceFilter, + public async Task DeleteRelationshipsAsync(RelationshipFilter resourceFilter, SubjectFilter? optionalSubjectFilter = null, List? optionalPreconditions = null, + bool allowPartialDeletions = false, int limit = 0, DateTime? deadline = null, CancellationToken cancellationToken = default) { RepeatedField preconditionCollection = new(); @@ -251,12 +272,13 @@ public async IAsyncEnumerable ReadRelationshipsAsync( preconditionCollection.AddRange(conditions); return (await _spiceDbCore.Permissions.DeleteRelationshipsAsync(EnsurePrefix(resourceFilter.Type)!, - resourceFilter.OptionalId, resourceFilter.OptionalRelation, - EnsurePrefix(optionalSubjectFilter?.Type) ?? string.Empty, - optionalSubjectFilter?.OptionalId ?? string.Empty, - optionalSubjectFilter?.OptionalRelation, preconditionCollection, deadline, - cancellationToken)) - .ToSpiceDbToken(); + resourceFilter.OptionalId, resourceFilter.OptionalRelation, + EnsurePrefix(optionalSubjectFilter?.Type) ?? string.Empty, + optionalSubjectFilter?.OptionalId ?? string.Empty, + optionalSubjectFilter?.OptionalRelation, preconditionCollection, + allowPartialDeletions, limit, + deadline, + cancellationToken)); } /// @@ -583,6 +605,16 @@ public async Task ImportSchemaFromStringAsync(string schema) return (await _spiceDbCore.Permissions.WriteRelationshipsAsync(updateCollection)).WrittenAt.ToSpiceDbToken(); } + /// + /// CheckBulkPermissionsAsync issues a check on whether a subject has permission or is a member of a relation on a specific + /// resource for each item in the list. + /// The ordering of the items in the response is maintained in the response.Checks with the same subject/permission + /// will automatically be batched for performance optimization. + /// + /// + /// + /// + /// public async Task CheckBulkPermissionsAsync(IEnumerable permissions, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness) { @@ -592,6 +624,16 @@ public async Task ImportSchemaFromStringAsync(string schema) return await CheckBulkPermissionsAsync(items, zedToken, cacheFreshness); } + /// + /// CheckBulkPermissionsAsync issues a check on whether a subject has permission or is a member of a relation on a specific + /// resource for each item in the list. + /// The ordering of the items in the response is maintained in the response.Checks with the same subject/permission + /// will automatically be batched for performance optimization. + /// + /// + /// + /// + /// public async Task CheckBulkPermissionsAsync( IEnumerable permissions, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness) @@ -601,6 +643,16 @@ public async Task ImportSchemaFromStringAsync(string schema) return await CheckBulkPermissionsAsync(items, zedToken, cacheFreshness); } + /// + /// CheckBulkPermissionsAsync issues a check on whether a subject has permission or is a member of a relation on a specific + /// resource for each item in the list. + /// The ordering of the items in the response is maintained in the response.Checks with the same subject/permission + /// will automatically be batched for performance optimization. + /// + /// + /// + /// + /// public async Task CheckBulkPermissionsAsync( IEnumerable items, ZedToken? zedToken = null, CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness)