Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bulk deletion of relation tuples #799

Merged
merged 13 commits into from
Jan 21, 2022
Merged

feat: bulk deletion of relation tuples #799

merged 13 commits into from
Jan 21, 2022

Conversation

vancanhuit
Copy link
Contributor

Related issue(s)

Closes #599

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change
    introduces a new feature.
  • I am following the
    contributing code guidelines.
  • I have read the security policy.
  • I confirm that this pull request does not address a security
    vulnerability. If this pull request addresses a security. vulnerability, I
    confirm that I got green light (please contact
    [email protected]) from the maintainers to push
    the changes.
  • I have added tests that prove my fix is effective or that my feature
    works.
  • I have added or changed the documentation.

Further Comments

@vancanhuit vancanhuit requested a review from zepatrik as a code owner December 18, 2021 08:28
Copy link
Member

@zepatrik zepatrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already a good start, but needs some improvements. Also, the same behavior should be reflected over gRPC as well.

}
l.Debug("deleting relation tuples")

rels, _, err := h.d.RelationTupleManager().GetRelationTuples(r.Context(), query)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite problematic as you only delete the first page here. Can you instead implement the selection of delete candidates on the persistence layer? A request DELETE /relation-tuples?namespace=foo should result in the following SQL statement to be executed: DELETE FROM keto_relation_tuples WHERE namespace=foo.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, yeah I will try. For the gRPC part, I may need time to understand it because I've never worked with gRPC before.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah so there is no specific delete RPC, but only the transact one. I would add a DeleteReleationTuples RPC here

// The write service to create and delete Access Control Lists.
//
// This service is part of the [write-APIs](../concepts/api-overview.mdx#write-apis).
service WriteService {
// Writes one or more relation tuples in a single transaction.
rpc TransactRelationTuples(TransactRelationTuplesRequest) returns (TransactRelationTuplesResponse);
}

It should behave exactly as the REST handler, so you can probably use the same internal helper function. The current transaction handler is implemented here:
func (h *handler) TransactRelationTuples(ctx context.Context, req *acl.TransactRelationTuplesRequest) (*acl.TransactRelationTuplesResponse, error) {
insertTuples, err := protoTuplesWithAction(req.RelationTupleDeltas, acl.RelationTupleDelta_INSERT)
if err != nil {
return nil, err
}
deleteTuples, err := protoTuplesWithAction(req.RelationTupleDeltas, acl.RelationTupleDelta_DELETE)
if err != nil {
return nil, err
}
err = h.d.RelationTupleManager().TransactRelationTuples(ctx, insertTuples, deleteTuples)
if err != nil {
return nil, err
}
snaptokens := make([]string, len(insertTuples))
for i := range insertTuples {
snaptokens[i] = "not yet implemented"
}
return &acl.TransactRelationTuplesResponse{
Snaptokens: snaptokens,
}, nil
}
, you can just follow the implementation of that as a guidance.

Copy link
Member

@zepatrik zepatrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting there 😉

Comment on lines 205 to 222
sqlRawQuery := "DELETE FROM keto_relation_tuples WHERE"
args := []interface{}{}
if query.Namespace != "" {
n, err := p.GetNamespaceByName(ctx, query.Namespace)
if err != nil {
return err
}
sqlRawQuery += " namespace_id = ?"
args = append(args, n.ID)
}
if query.Object != "" {
sqlRawQuery += " AND object = ?"
args = append(args, query.Object)
}
if query.Relation != "" {
sqlRawQuery += " AND relation = ?"
args = append(args, query.Relation)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the pop query builder for this instead of raw query. Just as we do here:

if query.Relation != "" {
sqlQuery.Where("relation = ?", query.Relation)
}
if query.Object != "" {
sqlQuery.Where("object = ?", query.Object)
}
if s := query.Subject(); s != nil {
if err := p.whereSubject(ctx, sqlQuery, s); err != nil {
return nil, "", err
}
}
if query.Namespace != "" {
n, err := p.GetNamespaceByName(ctx, query.Namespace)
if err != nil {
return nil, "", err
}
sqlQuery.Where("namespace_id = ?", n.ID)
}

and here
q := p.QueryWithNetwork(ctx).
Where("namespace_id = ?", n.ID).
Where("object = ?", r.Object).
Where("relation = ?", r.Relation)
if err := p.whereSubject(ctx, q, r.Subject); err != nil {
return err
}
if err := q.Delete(&RelationTuple{}); err != nil {
return err
}

As is, there is no SQL injection possible, but it is easy to add one later if you don't pay attention.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, my bad. I actually used pop query builder but for some reason, it didn't work. Now I realized why 🤪
I will push a new commit.

@vancanhuit
Copy link
Contributor Author

vancanhuit commented Dec 23, 2021

For the gRPC part, I had a basic understanding of it, now trying to figure out how to do it.

@@ -52,6 +52,23 @@ func (h *handler) TransactRelationTuples(ctx context.Context, req *acl.TransactR
}, nil
}

func (h *handler) DeleteRelationTuples(ctx context.Context, req *acl.DeleteRelationTuplesRequest) (*acl.DeleteRelationTuplesResponse, error) {
if req.Query == nil {
return nil, errors.New("invalid request")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always return errors.WithStack(herodot.ErrInvalidRequest.WithReason(...)) or similar errors, they will automatically set the right response codes through the middle-ware.

Copy link
Member

@zepatrik zepatrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now this looks very good now. The very last thing would be to add e2e tests.

  • here are all cases, add some more that test the new feature: https://github.com/ory/keto/blob/7ee65b5cd0827a3de87be3b6a7fa62d9038ea2df/internal/e2e/cases_test.go
  • here is the interface definition for the clients, add a deleteAll function and implement it in all clients:
    client interface {
    createTuple(t require.TestingT, r *relationtuple.InternalRelationTuple)
    deleteTuple(t require.TestingT, r *relationtuple.InternalRelationTuple)
    queryTuple(t require.TestingT, q *relationtuple.RelationQuery, opts ...x.PaginationOptionSetter) *relationtuple.GetResponse
    queryTupleErr(t require.TestingT, expected herodot.DefaultError, q *relationtuple.RelationQuery, opts ...x.PaginationOptionSetter)
    check(t require.TestingT, r *relationtuple.InternalRelationTuple) bool
    expand(t require.TestingT, r *relationtuple.SubjectSet, depth int) *expand.Tree
    waitUntilLive(t require.TestingT)
    }

Comment on lines 206 to 231
if query.Namespace != "" {
n, err := p.GetNamespaceByName(ctx, query.Namespace)
if err != nil {
return err
}
sqlQuery.Where("namespace_id = ?", n.ID)
}
if query.Object != "" {
sqlQuery.Where("object = ?", query.Object)
}
if query.Relation != "" {
sqlQuery.Where("relation = ?", query.Relation)
}
if s := query.Subject(); s != nil {
if err := p.whereSubject(ctx, sqlQuery, s); err != nil {
return err
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now this looks way better, but it is the same code as in the GetRelationTuples function.
Can you instead add a helper function similar to this (naming is just a draft):

// whereQuery takes a relation tuple query and appends where clauses to the base pop query
func whereQuery(ctx context.Context, baseQuery *pop.Query, rq *relationtuple.RelationQuery) (*pop.Query, error) {
	if query.Namespace != "" {
		n, err := p.GetNamespaceByName(ctx, rq.Namespace)
		if err != nil {
			return nil, err
		}
		baseQuery.Where("namespace_id = ?", n.ID)
	}
	if rq.Object != "" {
		baseQuery.Where("object = ?", rq.Object)
	}
	if rq.Relation != "" {
		baseQuery.Where("relation = ?", rq.Relation)
	}
	if s := rq.Subject(); s != nil {
		if err := p.whereSubject(ctx, baseQuery, s); err != nil {
			return err
		}
	}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then of course use it in both functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I will update it.

return nil, errors.New("invalid request")
}

q, err := (&RelationQuery{}).FromProto((*acl.ListRelationTuplesRequest_Query)(req.Query))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated proto definitions have getter functions, so you can instead rewrite func (q *RelationQuery) FromProto(...) to take an interface with the getter functions. That way you don't have to convert the type which might break in the future at some point.

@vancanhuit
Copy link
Contributor Author

I will add e2e tests later on.

@vancanhuit
Copy link
Contributor Author

vancanhuit commented Jan 8, 2022

@zepatrik Currently I'm getting stuck in the CLI client test. The keto relation-tuple delete command only supports JSON files containing a list of tuples. It is impossible to create a test for it based on a relation query. What should I do?

@zepatrik
Copy link
Member

I think it would make sense to not break keto relation-tuple delete behavior, but maybe add a flag --all-matching and then flags for each key you could delete by? Similar to how the list command works:

const (
FlagSubject = "subject"
FlagSubjectID = "subject-id"
FlagSubjectSet = "subject-set"
FlagRelation = "relation"
FlagObject = "object"
FlagPageSize = "page-size"
FlagPageToken = "page-token"
)
func registerRelationTupleFlags(flags *pflag.FlagSet) {
flags.String(FlagSubjectID, "", "Set the requested subject ID")
flags.String(FlagSubjectSet, "", `Set the requested subject set; format: "namespace:object#relation"`)
flags.String(FlagRelation, "", "Set the requested relation")
flags.String(FlagObject, "", "Set the requested object")
flags.String(FlagSubject, "", "")
if err := flags.MarkHidden(FlagSubject); err != nil {
panic(err.Error())
}
}
func readQueryFromFlags(cmd *cobra.Command, namespace string) (*acl.ListRelationTuplesRequest_Query, error) {
query := &acl.ListRelationTuplesRequest_Query{
Namespace: namespace,
Object: flagx.MustGetString(cmd, FlagObject),
Relation: flagx.MustGetString(cmd, FlagRelation),
}
switch flags := cmd.Flags(); {
case flags.Changed(FlagSubjectID) && flags.Changed(FlagSubjectSet):
return nil, relationtuple.ErrDuplicateSubject
case flags.Changed(FlagSubjectID):
query.Subject = (&relationtuple.SubjectID{ID: flagx.MustGetString(cmd, FlagSubjectID)}).ToProto()
case flags.Changed(FlagSubjectSet):
s, err := (&relationtuple.SubjectSet{}).FromString(flagx.MustGetString(cmd, FlagSubjectSet))
if err != nil {
return nil, err
}
query.Subject = s.ToProto()
}
return query, nil
}

@vancanhuit
Copy link
Contributor Author

I think it would make sense to not break keto relation-tuple delete behavior, but maybe add a flag --all-matching and then flags for each key you could delete by? Similar to how the list command works:

const (
FlagSubject = "subject"
FlagSubjectID = "subject-id"
FlagSubjectSet = "subject-set"
FlagRelation = "relation"
FlagObject = "object"
FlagPageSize = "page-size"
FlagPageToken = "page-token"
)
func registerRelationTupleFlags(flags *pflag.FlagSet) {
flags.String(FlagSubjectID, "", "Set the requested subject ID")
flags.String(FlagSubjectSet, "", `Set the requested subject set; format: "namespace:object#relation"`)
flags.String(FlagRelation, "", "Set the requested relation")
flags.String(FlagObject, "", "Set the requested object")
flags.String(FlagSubject, "", "")
if err := flags.MarkHidden(FlagSubject); err != nil {
panic(err.Error())
}
}
func readQueryFromFlags(cmd *cobra.Command, namespace string) (*acl.ListRelationTuplesRequest_Query, error) {
query := &acl.ListRelationTuplesRequest_Query{
Namespace: namespace,
Object: flagx.MustGetString(cmd, FlagObject),
Relation: flagx.MustGetString(cmd, FlagRelation),
}
switch flags := cmd.Flags(); {
case flags.Changed(FlagSubjectID) && flags.Changed(FlagSubjectSet):
return nil, relationtuple.ErrDuplicateSubject
case flags.Changed(FlagSubjectID):
query.Subject = (&relationtuple.SubjectID{ID: flagx.MustGetString(cmd, FlagSubjectID)}).ToProto()
case flags.Changed(FlagSubjectSet):
s, err := (&relationtuple.SubjectSet{}).FromString(flagx.MustGetString(cmd, FlagSubjectSet))
if err != nil {
return nil, err
}
query.Subject = s.ToProto()
}
return query, nil
}

Does this mean that we will support two cases: one is deleting from JSON files and one is deleting by specifying query keys?

@vancanhuit
Copy link
Contributor Author

@zepatrik How do you think about my latest change?

Copy link
Member

@zepatrik zepatrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this looks very good now 👍
I am really sorry for the late review.

@@ -152,11 +166,11 @@ func (h *handler) createRelation(w http.ResponseWriter, r *http.Request, _ httpr
h.d.Writer().WriteCreated(w, r, RouteBase+"?"+q.Encode(), &rel)
}

// swagger:route DELETE /relation-tuples write deleteRelationTuple
// swagger:route DELETE /relation-tuples write deleteRelationTuples
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will result in breaking changes in the SDK, right @aeneasr? The behavior changed a bit, but nothing that should break existing integrations.

Comment on lines +20 to +21
// Deletes relation tuples based on relation query
rpc DeleteRelationTuples(DeleteRelationTuplesRequest) returns (DeleteRelationTuplesResponse);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, as this is a new rpc it will not break existing integrations.

@zepatrik
Copy link
Member

I actually just realized that splitting the delete and delete-all into two subcommands makes more sense, as it now reflects the two behaviors and will not break existing behavior. Also, with this it should be harder to delete stuff by mistake. Thought it is easier if I just quickly do that instead of annoying you again 😅
Thanks again for your work there, appreciate 👍

@zepatrik zepatrik merged commit c1e8546 into ory:master Jan 21, 2022
@vancanhuit vancanhuit deleted the feat/bulk-deletion-relation-tuples branch January 23, 2022 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Bulk deletion of relation tuples
2 participants