Skip to content

Commit

Permalink
feat: Update relationship handling and deletion
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tanczosm committed May 20, 2024
1 parent 20831fd commit 097ed95
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 32 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions SpiceDb.Tests/SpiceDbClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public async Task ReadRelationshipsAsyncTest_FilterSubjectType()
var expected = GetRelationships("group:security");
List<string> 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()!);
}
Expand Down Expand Up @@ -127,7 +127,7 @@ public async Task DeleteRelationshipsAsyncTest()

List<string> 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()!);
}
Expand All @@ -138,14 +138,14 @@ await _client.DeleteRelationshipsAsync(new RelationshipFilter
OptionalId = "delete",
OptionalRelation = "manager"
},
new RelationshipFilter
new SubjectFilter
{
Type = "user"
});

List<string> 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()!);
}
Expand All @@ -163,7 +163,7 @@ public async Task DeleteRelationshipsAsyncWithSubjectIdTest()

List<string> 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()!);
}
Expand All @@ -174,15 +174,15 @@ await _client.DeleteRelationshipsAsync(new RelationshipFilter
OptionalId = "delete",
OptionalRelation = "manager"
},
new RelationshipFilter
new SubjectFilter
{
Type = "user",
OptionalId = "test1"
});

List<string> 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()!);
}
Expand All @@ -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]
Expand Down
43 changes: 38 additions & 5 deletions SpiceDb/Api/SpiceDbPermissions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -294,6 +298,8 @@ public async Task<PermissionResponse> CheckPermissionAsync(string resourceType,

public async IAsyncEnumerable<SpiceDb.Models.ReadRelationshipsResponse> 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)
{
Expand All @@ -305,7 +311,12 @@ public async Task<PermissionResponse> 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)
Expand Down Expand Up @@ -334,7 +345,8 @@ public async Task<PermissionResponse> CheckPermissionAsync(string resourceType,
Context = resp.Relationship.OptionalCaveat.Context.FromStruct()
}
: null
)
),
AfterResultCursor = resp.AfterResultCursor != null ? new Cursor { Token = resp.AfterResultCursor.Token } : null
};

yield return response;
Expand All @@ -355,17 +367,38 @@ public bool UpdateRelationships(ref RepeatedField<RelationshipUpdate> updateColl
}
}

public async Task<ZedToken?> DeleteRelationshipsAsync(string resourceType, string optionalResourceId = "", string optionalRelation = "", string optionalSubjectType ="", string optionalSubjectId = "", string? optionalSubjectRelation = null, RepeatedField<Precondition>? optionalPreconditions = null, DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken))
public async Task<Models.DeleteRelationshipsResponse> DeleteRelationshipsAsync(string resourceType, string optionalResourceId = "", string optionalRelation = "", string optionalSubjectType ="", string optionalSubjectId = "", string? optionalSubjectRelation = null,
RepeatedField<Precondition>? optionalPreconditions = null,
bool allowPartialDeletions = false,
int limit = 0,
DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken))
{
var req = new DeleteRelationshipsRequest
{
OptionalPreconditions = { optionalPreconditions },
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,
Expand Down
26 changes: 26 additions & 0 deletions SpiceDb/Enum/DeletionProgress.cs
Original file line number Diff line number Diff line change
@@ -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,

/// <summary>
/// Indicates that all remaining relationships matching the filter were deleted. Will be returned
/// even if no relationships were deleted.
/// </summary>
Complete = 1,

/// <summary>
/// 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.
/// </summary>
Partial = 2
}
39 changes: 29 additions & 10 deletions SpiceDb/ISpiceDbClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,25 @@ namespace SpiceDb;
public interface ISpiceDbClient
{
/// <summary>
/// ReadRelationships reads a set of the relationships matching one or more filters.
/// Asynchronously reads a set of relationships matching one or more filters.
/// </summary>
/// <param name="resource"></param>
/// <param name="subject"></param>
/// <param name="excludePrefix">If true the schema prefix will be removed from all returned relationships</param>
/// <param name="zedToken"></param>
/// <param name="cacheFreshness"></param>
/// <returns></returns>
IAsyncEnumerable<SpiceDb.Models.ReadRelationshipsResponse> ReadRelationshipsAsync(Models.RelationshipFilter resource, Models.SubjectFilter? subject = null,
/// <param name="resource">The filter to apply to the resource part of the relationships.</param>
/// <param name="subject">An optional filter to apply to the subject part of the relationships.</param>
/// <param name="excludePrefix">Indicates whether the prefix should be excluded from the response.</param>
/// <param name="limit">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.</param>
/// <param name="cursor">If provided indicates the cursor after which results should resume being returned.
/// The cursor can be found on the ReadRelationshipsResponse object.</param>
/// <param name="zedToken">An optional ZedToken for specifying a version of the data to read.</param>
/// <param name="cacheFreshness">Specifies the acceptable freshness of the data to be read from the cache.</param>
/// <returns>An async enumerable of <see cref="ReadRelationshipsResponse"/> objects matching the specified filters.</returns>
IAsyncEnumerable<ReadRelationshipsResponse> ReadRelationshipsAsync(RelationshipFilter resource,
SubjectFilter? subject = null,
bool excludePrefix = false,
int limit = 0,
Cursor? cursor = null,
ZedToken? zedToken = null,
CacheFreshness cacheFreshness = CacheFreshness.AnyFreshness);

Expand All @@ -36,11 +45,21 @@ public interface ISpiceDbClient
/// <param name="resourceFilter">resourceFilter.Type is required, all other fields are optional</param>
/// <param name="optionalSubjectFilter">An optional additional subject filter</param>
/// <param name="optionalPreconditions">An optional set of preconditions can be provided that must be satisfied for the operation to commit.</param>
/// <param name="allowPartialDeletions">if true and a limit is specified, will delete matching found
/// relationships up to the count specified in optional_limit, and no more.</param>
/// <param name="limit">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.</param>
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
/// <param name="cancellationToken">An optional token for canceling the call.</param>
/// <returns></returns>
Task<ZedToken?> DeleteRelationshipsAsync(SpiceDb.Models.RelationshipFilter resourceFilter, Models.SubjectFilter? optionalSubjectFilter = null, List<SpiceDb.Models.Precondition>? optionalPreconditions = null, DateTime? deadline = null, CancellationToken cancellationToken = default(CancellationToken));

Task<DeleteRelationshipsResponse> DeleteRelationshipsAsync(RelationshipFilter resourceFilter,
SubjectFilter? optionalSubjectFilter = null, List<Precondition>? optionalPreconditions = null,
bool allowPartialDeletions = false, int limit = 0,
DateTime? deadline = null, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
Expand Down
14 changes: 14 additions & 0 deletions SpiceDb/Models/DeleteRelationshipsResponse.cs
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions SpiceDb/Models/ReadRelationshipsResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

public class ReadRelationshipsResponse
{
/// <summary>
/// ZedToken at which the relationship was found.
/// </summary>
public ZedToken? Token { get; set; }
/// <summary>
/// Relationship is the found relationship.
/// </summary>
public Relationship Relationship { get; set; } = null!;
/// <summary>
/// Cursor that can be used to resume the ReadRelationships stream after this result.
/// </summary>
public Cursor? AfterResultCursor { get; set; }
}
15 changes: 15 additions & 0 deletions SpiceDb/Models/RelationshipFilter.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
namespace SpiceDb.Models;

/// <summary>
/// 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.
/// </summary>
public class RelationshipFilter
{
/// <summary>
/// Optional resource type of teh relationship
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Optional relation of the relationship
/// </summary>
public string OptionalRelation { get; set; } = string.Empty;
/// <summary>
/// Optional Id of the relationship
/// </summary>
public string OptionalId { get; set; } = string.Empty;
}
12 changes: 12 additions & 0 deletions SpiceDb/Models/SubjectFilter.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
namespace SpiceDb.Models;

/// <summary>
/// Specifies a filter on the subject of a relationship.
/// </summary>
public class SubjectFilter
{
/// <summary>
/// Required subject type of the relationship
/// </summary>
public required string Type { get; set; }
/// <summary>
/// Optional relation of the relationship
/// </summary>
public string? OptionalRelation { get; set; }
/// <summary>
/// Optional resource ID of the relationship
/// </summary>
public string OptionalId { get; set; } = string.Empty;
}
Loading

0 comments on commit 097ed95

Please sign in to comment.