snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"context"
"sync"
"github.com/gofrs/uuid"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/ketoapi"
)
type (
ManagerProvider interface {
RelationTupleManager() Manager
}
// SubjectSetType identifies a subject-set pointer by its target namespace and
// relation, as declared in OPL. AllowsDirect indicates that OPL permits a
// direct subject match for this type.
SubjectSetType struct {
Namespace string
Relation string
AllowsDirect bool
}
Manager interface {
GetRelationTuples(ctx context.Context, query *RelationQuery, options ...keysetpagination.Option) ([]*RelationTuple, *keysetpagination.Paginator, error)
ExistsRelationTuples(ctx context.Context, query *RelationQuery) (bool, error)
WriteRelationTuples(ctx context.Context, rs ...*RelationTuple) error
DeleteRelationTuples(ctx context.Context, rs ...*RelationTuple) error
DeleteAllRelationTuples(ctx context.Context, query *RelationQuery) error
TransactRelationTuples(ctx context.Context, insert []*RelationTuple, delete []*RelationTuple) error
// TraverseSubjectSetExpansion gets all subject sets for the tuple's
// object#relation and checks whether the tuple's subject is a member of
// each. When allowedSubjectSets is non-empty, only subject-set pointers
// matching the declared (namespace, relation) pairs are considered.
TraverseSubjectSetExpansion(ctx context.Context, tuple *RelationTuple, allowedSubjectSets []SubjectSetType) ([]*TraversalResult, error)
// FindTupleWithRelations returns the first tuple that matches the given
// tuple on namespace, object, and subject and has any of the given
// relations, or nil if there is none.
FindTupleWithRelations(ctx context.Context, tuple *RelationTuple, relations []string) (*RelationTuple, error)
// Transaction runs f inside a database transaction. Nested calls reuse
// the outer transaction.
Transaction(ctx context.Context, f func(ctx context.Context) error) error
// WithLatestSnapshot runs f inside a snapshot-isolated read transaction,
// ensuring all SQL queries within f see a consistent database state.
WithLatestSnapshot(ctx context.Context, f func(ctx context.Context) error) error
}
SubjectID struct {
ID uuid.UUID `json:"id"`
}
RelationQuery struct {
Namespace *string `json:"namespace"`
Object *uuid.UUID `json:"object"`
Relation *string `json:"relation"`
Subject Subject `json:"subject_id,omitempty"`
}
TupleData interface {
GetSubject() *rts.Subject
GetObject() string
GetNamespace() string
GetRelation() string
}
Subject interface {
Equals(Subject) bool
UniqueID() uuid.UUID
String() string
}
RelationTuple struct {
Namespace string `json:"namespace"`
Object uuid.UUID `json:"object"`
Relation string `json:"relation"`
Subject Subject `json:"subject"`
}
InternalRelationTuples []*RelationTuple
SubjectSet struct {
Namespace string `json:"namespace"`
Object uuid.UUID `json:"object"`
Relation string `json:"relation"`
}
// TODO(hperl): Also use a ketoapi.Tree here.
Tree struct {
Type ketoapi.TreeNodeType `json:"type"`
Subject Subject `json:"subject"`
Children []*Tree `json:"children,omitempty"`
Truncation *Truncation `json:"truncation,omitempty"`
}
Truncation struct {
Reason TruncationReason `json:"reason,omitempty"`
Cursor *ExpandCursor `json:"cursor,omitempty"`
}
TruncationReason string
ExpandCursorKind string
ExpandCursor struct {
Kind ExpandCursorKind `json:"kind"`
SubjectSet *SubjectSet `json:"subject_set,omitempty"`
NextPageToken keysetpagination.PageToken `json:"next_page_token,omitempty"`
// TraverseRelation is the ComputedRelation in TupleToSubjectSet relation
TraverseRelation *string `json:"traverse_relation,omitempty"`
}
)
const (
ExpandCursorKindDirect ExpandCursorKind = "direct"
ExpandCursorKindTTU ExpandCursorKind = "ttu"
)
type (
TraversalResult struct {
From *RelationTuple
To *RelationTuple
Via Traversal
Found bool
}
Traversal string
)
const (
TraversalUnknown Traversal = "unknown"
TraversalSubjectSetExpand Traversal = "subject set expand"
TraversalComputedUserset Traversal = "computed userset"
TraversalTupleToUserset Traversal = "tuple to userset"
)
const (
TruncationReasonDepthLimit TruncationReason = "depth_limit"
TruncationReasonTupleLimit TruncationReason = "tuple_limit"
TruncationReasonCycle TruncationReason = "cycle"
)
var _, _ Subject = (*SubjectID)(nil), (*SubjectSet)(nil)
func (s *SubjectID) Equals(other Subject) bool {
uv, ok := other.(*SubjectID)
if !ok {
return false
}
return uv.ID == s.ID
}
func (s *SubjectID) UniqueID() uuid.UUID { return s.ID }
func (s *SubjectID) String() string { return s.ID.String() }
func (s *SubjectSet) Equals(other Subject) bool {
uv, ok := other.(*SubjectSet)
if !ok {
return false
}
return uv.Relation == s.Relation && uv.Object == s.Object && uv.Namespace == s.Namespace
}
func (s *SubjectSet) UniqueID() uuid.UUID {
return uuid.NewV5(s.Object, s.Namespace+"-"+s.Relation)
}
func (s *SubjectSet) String() string {
if s == nil {
return ""
}
return s.Namespace + ":" + s.Object.String() + "#" + s.Relation
}
func (t *RelationTuple) ToQuery() *RelationQuery {
return &RelationQuery{
Namespace: &t.Namespace,
Object: &t.Object,
Relation: &t.Relation,
Subject: t.Subject,
}
}
func (t *RelationTuple) String() string {
if t == nil {
return ""
}
subject := ""
if t.Subject != nil {
subject = t.Subject.String()
}
return t.Namespace + ":" + t.Object.String() + "#" + t.Relation + "@" + subject
}
func (t *RelationTuple) FromProto(proto *rts.RelationTuple) *RelationTuple {
// TODO(hperl)
return t
}
func (t *RelationTuple) ToProto() *rts.RelationTuple {
// TODO(hperl)
return &rts.RelationTuple{}
}
type ManagerWrapper struct {
Reg ManagerProvider
PageOpts []keysetpagination.Option
RequestedPages []keysetpagination.PageToken
// lock is necessary so that GetRelationTuples() is safe for concurrency.
requestedPagesLock sync.Mutex
}
var (
_ Manager = (*ManagerWrapper)(nil)
_ ManagerProvider = (*ManagerWrapper)(nil)
)
func NewManagerWrapper(_ any, reg ManagerProvider, options ...keysetpagination.Option) *ManagerWrapper {
return &ManagerWrapper{
Reg: reg,
PageOpts: options,
}
}
func (t *ManagerWrapper) GetRelationTuples(ctx context.Context, query *RelationQuery, options ...keysetpagination.Option) ([]*RelationTuple, *keysetpagination.Paginator, error) {
p, err := keysetpagination.NewPaginator(options...)
if err != nil {
return nil, nil, err
}
t.requestedPagesLock.Lock()
defer t.requestedPagesLock.Unlock()
t.RequestedPages = append(t.RequestedPages, p.PageToken())
return t.Reg.RelationTupleManager().GetRelationTuples(ctx, query, append(t.PageOpts, options...)...)
}
func (t *ManagerWrapper) ExistsRelationTuples(ctx context.Context, query *RelationQuery) (bool, error) {
return t.Reg.RelationTupleManager().ExistsRelationTuples(ctx, query)
}
func (t *ManagerWrapper) WriteRelationTuples(ctx context.Context, rs ...*RelationTuple) error {
return t.Reg.RelationTupleManager().WriteRelationTuples(ctx, rs...)
}
func (t *ManagerWrapper) DeleteRelationTuples(ctx context.Context, rs ...*RelationTuple) error {
return t.Reg.RelationTupleManager().DeleteRelationTuples(ctx, rs...)
}
func (t *ManagerWrapper) DeleteAllRelationTuples(ctx context.Context, query *RelationQuery) error {
return t.Reg.RelationTupleManager().DeleteAllRelationTuples(ctx, query)
}
func (t *ManagerWrapper) TransactRelationTuples(ctx context.Context, insert []*RelationTuple, delete []*RelationTuple) error {
return t.Reg.RelationTupleManager().TransactRelationTuples(ctx, insert, delete)
}
func (t *ManagerWrapper) TraverseSubjectSetExpansion(ctx context.Context, tuple *RelationTuple, allowedSubjectSets []SubjectSetType) ([]*TraversalResult, error) {
return t.Reg.RelationTupleManager().TraverseSubjectSetExpansion(ctx, tuple, allowedSubjectSets)
}
func (t *ManagerWrapper) FindTupleWithRelations(ctx context.Context, tuple *RelationTuple, relations []string) (*RelationTuple, error) {
return t.Reg.RelationTupleManager().FindTupleWithRelations(ctx, tuple, relations)
}
func (t *ManagerWrapper) Transaction(ctx context.Context, f func(ctx context.Context) error) error {
return t.Reg.RelationTupleManager().Transaction(ctx, f)
}
func (t *ManagerWrapper) WithLatestSnapshot(ctx context.Context, f func(ctx context.Context) error) error {
return t.Reg.RelationTupleManager().WithLatestSnapshot(ctx, f)
}
func (t *ManagerWrapper) RelationTupleManager() Manager {
return t
}

View File

@@ -0,0 +1,94 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"fmt"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
)
func TestSubject(t *testing.T) {
t.Run("method=equals", func(t *testing.T) {
a, b := uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4())
for i, tc := range []struct {
a, b Subject
equals bool
}{
{
a: &SubjectID{ID: a},
b: &SubjectID{ID: a},
equals: true,
},
{
a: &SubjectID{ID: a},
b: &SubjectID{ID: b},
equals: false,
},
{
a: &SubjectSet{},
b: &SubjectID{},
equals: false,
},
{
a: &SubjectSet{
Namespace: "1",
Object: a,
Relation: "R",
},
b: &SubjectSet{
Namespace: "1",
Object: a,
Relation: "R",
},
equals: true,
},
{
a: &SubjectSet{
Object: a,
Relation: "R",
},
b: &SubjectSet{
Namespace: "1",
Object: a,
Relation: "R",
},
equals: false,
},
{
a: &SubjectSet{
Namespace: "1",
Relation: "R",
},
b: &SubjectSet{
Namespace: "1",
Object: a,
Relation: "R",
},
equals: false,
},
{
a: &SubjectSet{
Namespace: "1",
Object: a,
},
b: &SubjectSet{
Namespace: "1",
Object: a,
Relation: "R",
},
equals: false,
},
} {
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
assert.Equal(t, tc.equals, tc.a.Equals(tc.b))
assert.Equal(t, tc.equals, tc.b.Equals(tc.a))
})
}
})
}

View File

@@ -0,0 +1,98 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"connectrpc.com/connect"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/x"
)
type (
handlerDeps interface {
ManagerProvider
MapperProvider
config.Provider
logrusx.Provider
httpx.WriterProvider
otelx.Provider
x.NetworkIDProvider
x.HandlerOptionsProvider
}
ReadHandler struct {
relationtuplesconnect.UnimplementedReadServiceHandler
d handlerDeps
}
WriteHandler struct {
relationtuplesconnect.UnimplementedWriteServiceHandler
d handlerDeps
}
)
const (
ReadRouteBase = "/relation-tuples"
WriteRouteBase = "/admin/relation-tuples"
)
func NewReadHandler(d handlerDeps) *ReadHandler { return &ReadHandler{d: d} }
func NewWriteHandler(d handlerDeps) *WriteHandler { return &WriteHandler{d: d} }
func (h *ReadHandler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
r.GET(ReadRouteBase, h.getRelations)
listRelationTuplesHandler := connect.NewUnaryHandler(
relationtuplesconnect.ReadServiceListRelationTuplesProcedure,
h.ListRelationTuples,
connect.WithSchema(rts.File_ory_keto_relation_tuples_v1alpha2_read_service_proto.
Services().ByName("ReadService").
Methods().ByName("ListRelationTuples")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.ReadServiceListRelationTuplesProcedure, listRelationTuplesHandler)
}
func (h *WriteHandler) RegisterWriteRoutes(r *httprouterx.RouterAdmin) {
r.PUT(WriteRouteBase, h.createRelation)
r.DELETE(WriteRouteBase, h.deleteRelations)
r.PATCH(WriteRouteBase, h.patchRelationTuples)
writeServiceMethods := rts.File_ory_keto_relation_tuples_v1alpha2_write_service_proto.Services().ByName("WriteService").Methods()
transactRelationTuplesHandler := connect.NewUnaryHandler(
relationtuplesconnect.WriteServiceTransactRelationTuplesProcedure,
h.TransactRelationTuples,
connect.WithSchema(writeServiceMethods.ByName("TransactRelationTuples")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.WriteServiceTransactRelationTuplesProcedure, transactRelationTuplesHandler)
deleteRelationTuplesHandler := connect.NewUnaryHandler(
relationtuplesconnect.WriteServiceDeleteRelationTuplesProcedure,
h.DeleteRelationTuples,
connect.WithSchema(writeServiceMethods.ByName("DeleteRelationTuples")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.WriteServiceDeleteRelationTuplesProcedure, deleteRelationTuplesHandler)
}
func (h *ReadHandler) ProtoFiles() []protoreflect.FileDescriptor {
return []protoreflect.FileDescriptor{
rts.File_ory_keto_relation_tuples_v1alpha2_read_service_proto,
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
}
}
func (h *WriteHandler) ProtoFiles() []protoreflect.FileDescriptor {
return []protoreflect.FileDescriptor{
rts.File_ory_keto_relation_tuples_v1alpha2_write_service_proto,
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
}
}

View File

@@ -0,0 +1,175 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/ory/herodot"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/ketoapi"
)
var _ relationtuplesconnect.ReadServiceHandler = (*ReadHandler)(nil)
type (
queryWrapper struct {
*rts.RelationQuery
}
deprecatedQueryWrapper struct {
*rts.ListRelationTuplesRequest_Query
}
)
func (q *queryWrapper) GetObject() *string { return q.Object }
func (q *queryWrapper) GetNamespace() *string { return q.Namespace }
func (q *queryWrapper) GetRelation() *string { return q.Relation }
func (q *deprecatedQueryWrapper) GetObject() *string {
if q.Object == "" {
return nil
}
return new(q.Object)
}
func (q *deprecatedQueryWrapper) GetNamespace() *string {
if q.Namespace == "" {
return nil
}
return new(q.Namespace)
}
func (q *deprecatedQueryWrapper) GetRelation() *string {
if q.Relation == "" {
return nil
}
return new(q.Relation)
}
func (h *ReadHandler) ListRelationTuples(ctx context.Context, req *connect.Request[rts.ListRelationTuplesRequest]) (*connect.Response[rts.ListRelationTuplesResponse], error) {
var q ketoapi.RelationQuery
switch {
case req.Msg.RelationQuery != nil:
q.FromDataProvider(&queryWrapper{req.Msg.RelationQuery})
case req.Msg.Query != nil: //nolint:staticcheck //lint:ignore SA1019 backwards compatibility
q.FromDataProvider(&deprecatedQueryWrapper{req.Msg.Query}) //nolint:staticcheck //lint:ignore SA1019 backwards compatibility
default:
return nil, herodot.ErrBadRequest().WithError("you must provide a query")
}
iq, err := h.d.ReadOnlyMapper().FromQuery(ctx, &q)
if err != nil {
return nil, err
}
paginationKeys := h.d.Config(ctx).PaginationEncryptionKeys()
pageOpts := make([]keysetpagination.Option, 0, 2)
if req.Msg.PageSize > 0 {
pageOpts = append(pageOpts, keysetpagination.WithSize(int(req.Msg.PageSize)))
}
if req.Msg.PageToken != "" {
token, err := keysetpagination.ParsePageToken(paginationKeys, req.Msg.PageToken)
if err != nil {
return nil, herodot.ErrBadRequest().WithError(err.Error())
}
pageOpts = append(pageOpts, keysetpagination.WithToken(token))
}
ir, nextPage, err := h.d.RelationTupleManager().GetRelationTuples(ctx, iq, pageOpts...)
if err != nil {
return nil, err
}
relations, err := h.d.ReadOnlyMapper().ToTuple(ctx, ir...)
if err != nil {
return nil, err
}
resp := &rts.ListRelationTuplesResponse{
RelationTuples: make([]*rts.RelationTuple, len(ir)),
}
if !nextPage.IsLast() {
resp.NextPageToken = nextPage.PageToken().Encrypt(paginationKeys)
}
for i, r := range relations {
resp.RelationTuples[i] = r.ToProto()
}
return connect.NewResponse(resp), nil
}
// swagger:route GET /relation-tuples relationship getRelationships
//
// # Query relationships
//
// Get all relationships that match the query. Only the namespace field is required.
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: relationships
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-medium
func (h *ReadHandler) getRelations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query()
query, err := (&ketoapi.RelationQuery{}).FromURLQuery(q)
if err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
l := h.d.Logger()
for k := range q {
l = l.WithField(k, q.Get(k))
}
l.Debug("querying relationships")
paginationKeys := h.d.Config(ctx).PaginationEncryptionKeys()
paginationOpts, err := keysetpagination.ParseQueryParams(paginationKeys, q)
if err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
iq, err := h.d.ReadOnlyMapper().FromQuery(ctx, query)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
ir, nextPage, err := h.d.RelationTupleManager().GetRelationTuples(ctx, iq, paginationOpts...)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
relations, err := h.d.ReadOnlyMapper().ToTuple(ctx, ir...)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
resp := &ketoapi.GetResponse{
RelationTuples: relations,
}
if !nextPage.IsLast() {
resp.NextPageToken = nextPage.PageToken().Encrypt(paginationKeys)
}
h.d.Writer().Write(w, r, resp)
}

View File

@@ -0,0 +1,345 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"github.com/ory/x/httprouterx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"google.golang.org/grpc"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func TestReadHandlers(t *testing.T) {
ctx := context.Background()
r := httprouterx.NewRouterPublic()
reg := driver.NewSqliteTestRegistry(t)
h := relationtuple.NewReadHandler(reg)
h.RegisterReadRoutes(r)
ts, clientTLS := driver.HTTP2TestServer(t, r)
var newNamespace func(*testing.T) *namespace.Namespace
{
nspaces := 0
newNamespace = func(t *testing.T) *namespace.Namespace {
n := &namespace.Namespace{Name: fmt.Sprintf("relation tuple read test %d", nspaces)}
nspaces++
require.NoError(t, reg.Config(ctx).Set(config.KeyNamespaces, []*namespace.Namespace{n}))
return n
}
}
t.Run("method=get", func(t *testing.T) {
t.Run("case=empty response is not nil", func(t *testing.T) {
nspace := newNamespace(t)
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"namespace": {nspace.Name},
}.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "[]", gjson.GetBytes(body, "relation_tuples").Raw)
var respMsg ketoapi.GetResponse
require.NoError(t, json.Unmarshal(body, &respMsg))
assert.Equal(t, ketoapi.GetResponse{
RelationTuples: []*ketoapi.RelationTuple{},
NextPageToken: "",
}, respMsg)
})
t.Run("case=returns tuples", func(t *testing.T) {
nspace := newNamespace(t)
tuples := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "o1",
Relation: "r1",
SubjectID: new("s1"),
},
{
Namespace: nspace.Name,
Object: "o2",
Relation: "r2",
SubjectSet: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "o1",
Relation: "r1",
},
},
}
testhelpers.MapAndInsertTuples(t, reg, tuples...)
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"namespace": {nspace.Name},
}.Encode())
require.NoError(t, err)
var respMsg ketoapi.GetResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&respMsg))
assert.ElementsMatch(t, tuples, respMsg.RelationTuples)
assert.Equal(t, "", respMsg.NextPageToken)
})
t.Run("case=return tuples without namespace", func(t *testing.T) {
nspace := newNamespace(t)
tuples := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "obj",
Relation: "r1",
SubjectID: new("s1"),
},
}
testhelpers.MapAndInsertTuples(t, reg, tuples...)
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"object": {"obj"},
}.Encode())
require.NoError(t, err)
assert.Equal(t, resp.StatusCode, http.StatusOK)
var respMsg ketoapi.GetResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&respMsg))
assert.Equal(t, 1, len(respMsg.RelationTuples))
assert.Containsf(t, tuples, respMsg.RelationTuples[0], "expected to find %q in %q", respMsg.RelationTuples[0].String(), tuples)
assert.Equal(t, "", respMsg.NextPageToken)
})
t.Run("case=returns bad request on malformed subject", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"subject": {"not#a valid subject"},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("case=paginates", func(t *testing.T) {
nspace := newNamespace(t)
tuples := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "o1",
Relation: "r1",
SubjectID: new("s1"),
},
{
Namespace: nspace.Name,
Object: "o2",
Relation: "r2",
SubjectID: new("s2"),
},
}
testhelpers.MapAndInsertTuples(t, reg, tuples...)
var firstResp ketoapi.GetResponse
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"namespace": {nspace.Name},
"page_size": {"1"},
}.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&firstResp))
require.Len(t, firstResp.RelationTuples, 1)
assert.Contains(t, tuples, firstResp.RelationTuples[0])
assert.NotEqual(t, "", firstResp.NextPageToken)
// second page
resp, err = ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"namespace": {nspace.Name},
"page_size": {"1"},
"page_token": {firstResp.NextPageToken},
}.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
secondResp := ketoapi.GetResponse{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&secondResp))
require.Len(t, secondResp.RelationTuples, 1)
assert.NotEqual(t, firstResp.RelationTuples, secondResp.RelationTuples)
assert.Contains(t, tuples, secondResp.RelationTuples[0])
assert.Equal(t, "", secondResp.NextPageToken)
})
t.Run("case=returs bad request on invalid page size", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + relationtuple.ReadRouteBase + "?" + url.Values{
"page_size": {"foo"},
}.Encode())
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, string(body), "invalid syntax")
})
})
t.Run("method=grpc", func(t *testing.T) {
type requestEnhancer = func(req *rts.ListRelationTuplesRequest, query *ketoapi.RelationQuery)
withRelationQuery := func(req *rts.ListRelationTuplesRequest, query *ketoapi.RelationQuery) {
req.RelationQuery = query.ToProto()
}
withDeprecatedQuery := func(req *rts.ListRelationTuplesRequest, query *ketoapi.RelationQuery) {
pq := query.ToProto()
//nolint:staticcheck
req.Query = &rts.ListRelationTuplesRequest_Query{ //lint:ignore SA1019 backwards compatibility
Subject: pq.Subject,
}
if pq.Namespace != nil {
//nolint:staticcheck
req.Query.Namespace = *pq.Namespace //lint:ignore SA1019 backwards compatibility
}
if pq.Object != nil {
//nolint:staticcheck
req.Query.Object = *pq.Object //lint:ignore SA1019 backwards compatibility
}
if pq.Relation != nil {
//nolint:staticcheck
req.Query.Relation = *pq.Relation //lint:ignore SA1019 backwards compatibility
}
}
apiTuplesFromProto := func(t *testing.T, pts ...*rts.RelationTuple) []*ketoapi.RelationTuple {
actual := make([]*ketoapi.RelationTuple, len(pts))
for i, rt := range pts {
var err error
actual[i], err = (&ketoapi.RelationTuple{}).FromDataProvider(rt)
require.NoError(t, err)
}
return actual
}
con, err := grpc.NewClient(ts.Listener.Addr().String(), grpc.WithTransportCredentials(clientTLS))
require.NoError(t, err)
t.Run("method=list", func(t *testing.T) {
client := rts.NewReadServiceClient(con)
for key, enhancer := range map[string]requestEnhancer{"relation query": withRelationQuery, "deprecated query": withDeprecatedQuery} {
t.Run("enhancer="+key, func(t *testing.T) {
t.Run("case=returns empty list on no tuples", func(t *testing.T) {
nspace := newNamespace(t)
req := &rts.ListRelationTuplesRequest{}
enhancer(req, &ketoapi.RelationQuery{
Namespace: &nspace.Name,
})
resp, err := client.ListRelationTuples(ctx, req)
require.NoError(t, err)
assert.Len(t, resp.RelationTuples, 0)
})
t.Run("case=gets tuples", func(t *testing.T) {
nspace := newNamespace(t)
tuples := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "o1",
Relation: "rel",
SubjectID: new("s1"),
},
{
Namespace: nspace.Name,
Object: "o2",
Relation: "rel",
SubjectSet: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "o1",
Relation: "r1",
},
},
}
testhelpers.MapAndInsertTuples(t, reg, tuples...)
req := &rts.ListRelationTuplesRequest{}
enhancer(req, &ketoapi.RelationQuery{
Namespace: &nspace.Name,
})
resp, err := client.ListRelationTuples(ctx, req)
require.NoError(t, err)
assert.Len(t, resp.RelationTuples, 2)
assert.ElementsMatch(t, tuples, apiTuplesFromProto(t, resp.RelationTuples...))
})
t.Run("case=paginates", func(t *testing.T) {
nspace := newNamespace(t)
tuples := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "o1",
Relation: "rel",
SubjectID: new("s1"),
},
{
Namespace: nspace.Name,
Object: "o2",
Relation: "rel",
SubjectID: new("s2"),
},
{
Namespace: nspace.Name,
Object: "o3",
Relation: "rel",
SubjectID: new("s3"),
},
}
testhelpers.MapAndInsertTuples(t, reg, tuples...)
query := &ketoapi.RelationQuery{
Namespace: &nspace.Name,
}
firstReq := &rts.ListRelationTuplesRequest{}
enhancer(firstReq, query)
firstReq.PageSize = int32(2)
firstResp, err := client.ListRelationTuples(ctx, firstReq)
require.NoError(t, err)
secondReq := &rts.ListRelationTuplesRequest{}
enhancer(secondReq, query)
secondReq.PageSize = int32(2)
secondReq.PageToken = firstResp.NextPageToken
secondResp, err := client.ListRelationTuples(ctx, secondReq)
require.NoError(t, err)
assert.Len(t, firstResp.RelationTuples, 2)
assert.Len(t, secondResp.RelationTuples, 1)
assert.Zero(t, secondResp.NextPageToken)
assert.ElementsMatch(t, tuples, apiTuplesFromProto(t, append(firstResp.RelationTuples, secondResp.RelationTuples...)...))
})
})
}
})
})
}

View File

@@ -0,0 +1,115 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/keto/ketoapi"
)
var (
_ = (*patchRelationships)(nil)
_ = (*getRelationships)(nil)
_ = (*relationshipInQuery)(nil)
)
// Patch Relationships Request Parameters
//
// swagger:parameters patchRelationships
type patchRelationships struct {
// in:body
Body []*ketoapi.PatchDelta
}
// Get Relationships Request Parameters
//
// swagger:parameters getRelationships
type getRelationships struct {
// Namespace of the Relationship
//
// in: query
Namespace string `json:"namespace"`
// Object of the Relationship
//
// in: query
Object string `json:"object"`
// Relation of the Relationship
//
// in: query
Relation string `json:"relation"`
// SubjectID of the Relationship
//
// in: query
// Either subject_set.* or subject_id are required.
SubjectID string `json:"subject_id"`
// Namespace of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SNamespace string `json:"subject_set.namespace"`
// Object of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SObject string `json:"subject_set.object"`
// Relation of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SRelation string `json:"subject_set.relation"`
// swagger:allOf
keysetpagination.RequestParameters
}
// The relationship parameters in the URL query.
//
// swagger:parameters checkPermission checkPermissionOrError deleteRelationships
type relationshipInQuery struct {
// Namespace of the Relationship
//
// in: query
Namespace string `json:"namespace"`
// Object of the Relationship
//
// in: query
Object string `json:"object"`
// Relation of the Relationship
//
// in: query
Relation string `json:"relation"`
// SubjectID of the Relationship
//
// in: query
// Either subject_set.* or subject_id are required.
SubjectID string `json:"subject_id"`
// Namespace of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SNamespace string `json:"subject_set.namespace"`
// Object of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SObject string `json:"subject_set.object"`
// Relation of the Subject Set
//
// in: query
// Either subject_set.* or subject_id are required.
SRelation string `json:"subject_set.relation"`
}

View File

@@ -0,0 +1,803 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"fmt"
"testing"
"github.com/gofrs/uuid"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func ManagerTest(t *testing.T, m relationtuple.Manager) {
t.Run("method=Write", func(t *testing.T) {
t.Run("case=success", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
tuples := []*relationtuple.RelationTuple{
tf.Tuple(t, "Obj:o1#rel@s1"),
tf.Tuple(t, "Obj:o2#rel@Obj:o3#sub rel"),
}
require.NoError(t, m.WriteRelationTuples(t.Context(), tuples...))
resp, nextPage, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.True(t, nextPage.IsLast())
assert.ElementsMatch(t, tuples, resp)
})
})
t.Run("method=Get", func(t *testing.T) {
t.Run("case=queries", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
nspace := tf.NS("Obj")
obj0 := tf.UUID("o0")
// Tuple i has object o{i%2}, relation "r {i%4}", and subject s{i}.
tuples := make([]*relationtuple.RelationTuple, 10)
for i := range tuples {
tuples[i] = tf.Tuple(t, fmt.Sprintf("Obj:o%d#r %d@s%d", i%2, i%4, i))
}
require.NoError(t, m.WriteRelationTuples(t.Context(), tuples...))
for i, tc := range []struct {
query *relationtuple.RelationQuery
expected []*relationtuple.RelationTuple
}{
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
},
expected: tuples,
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Object: &obj0,
},
expected: []*relationtuple.RelationTuple{
tuples[0],
tuples[2],
tuples[4],
tuples[6],
tuples[8],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Relation: new("r 0"),
},
expected: []*relationtuple.RelationTuple{
tuples[0],
tuples[4],
tuples[8],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Object: &obj0,
Relation: new("r 0"),
},
expected: []*relationtuple.RelationTuple{
tuples[0],
tuples[4],
tuples[8],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Subject: tuples[0].Subject,
},
expected: []*relationtuple.RelationTuple{
tuples[0],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Object: &obj0,
Subject: tuples[0].Subject,
},
expected: []*relationtuple.RelationTuple{
tuples[0],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Relation: new("r 0"),
Subject: tuples[0].Subject,
},
expected: []*relationtuple.RelationTuple{
tuples[0],
},
},
{
query: &relationtuple.RelationQuery{
Namespace: &nspace,
Object: &obj0,
Relation: new("r 0"),
Subject: tuples[0].Subject,
},
expected: []*relationtuple.RelationTuple{
tuples[0],
},
},
} {
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
res, nextPage, err := m.GetRelationTuples(t.Context(), tc.query)
require.NoError(t, err)
assert.True(t, nextPage.IsLast())
assert.ElementsMatch(t, tc.expected, res)
})
}
})
t.Run("case=pagination", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
tuples := make([]*relationtuple.RelationTuple, 20)
for i := range tuples {
tuples[i] = tf.Tuple(t, fmt.Sprintf("Obj:o#r@s%d", i))
}
require.NoError(t, m.WriteRelationTuples(t.Context(), tuples...))
tests := []struct {
name string
searchCriteria *relationtuple.RelationQuery
}{
{
name: "search=ns,obj,rel",
searchCriteria: &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
Object: new(tf.UUID("o")),
Relation: new("r"),
},
},
{
name: "search=ns",
searchCriteria: &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
Object: nil,
Relation: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
res, thisPage []*relationtuple.RelationTuple
err error
)
nextPage, err := keysetpagination.NewPaginator(keysetpagination.WithSize(1))
require.NoError(t, err)
for range tuples[:len(tuples)-1] {
thisPage, nextPage, err = m.GetRelationTuples(t.Context(), tt.searchCriteria, nextPage.ToOptions()...)
require.NoError(t, err)
assert.False(t, nextPage.IsLast())
require.Len(t, thisPage, 1)
res = append(res, thisPage[0])
}
thisPage, nextPage, err = m.GetRelationTuples(t.Context(), tt.searchCriteria, nextPage.ToOptions()...)
require.NoError(t, err)
assert.True(t, nextPage.IsLast())
require.Len(t, thisPage, 1)
res = append(res, thisPage[0])
assert.ElementsMatch(t, tuples, res)
})
}
})
// Comparing paginated results against a non-paginated fetch,
// asserting order and correctness with different page sizes
t.Run("case=pagination returns all rows without skips in correct order", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
// 11 tuples, mixing SubjectID and SubjectSet subjects, including one
// exact duplicate row.
duplicate := tf.Tuple(t, "Obj:o#r@teams:t1#editor")
tuples := []*relationtuple.RelationTuple{
tf.Tuple(t, "Obj:o#r@groups:g1#member"),
tf.Tuple(t, "Obj:o#r@s1"),
tf.Tuple(t, "Obj:o#r@s2"),
tf.Tuple(t, "Obj:o#r@teams:t2#viewer"),
tf.Tuple(t, "Obj:o#r@groups:g2#owner"),
tf.Tuple(t, "Obj:o#r@s3"),
tf.Tuple(t, "Obj:o#r@orgs:org1#admin"),
tf.Tuple(t, "Obj:o#r@s4"),
duplicate,
duplicate,
tf.Tuple(t, "Obj:o#r@s5"),
}
require.NoError(t, m.WriteRelationTuples(t.Context(), tuples...))
tests := []struct {
name string
searchCriteria *relationtuple.RelationQuery
pageSizes []int
}{
{
name: "search=ns",
searchCriteria: &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
},
pageSizes: []int{1, 2, 50},
},
{
name: "search=obj",
searchCriteria: &relationtuple.RelationQuery{
Object: new(tf.UUID("o")),
},
pageSizes: []int{1, 2, 50},
},
{
name: "search=ns,obj,rel",
searchCriteria: &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
Object: new(tf.UUID("o")),
Relation: new("r"),
},
pageSizes: []int{1, 2, 50},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expected, next, err := m.GetRelationTuples(t.Context(), tt.searchCriteria, keysetpagination.WithSize(9999))
require.NoError(t, err)
require.True(t, next.IsLast())
require.Len(t, expected, len(tuples))
for _, pageSize := range tt.pageSizes {
nextPage, _ := keysetpagination.NewPaginator(keysetpagination.WithSize(pageSize))
var got []*relationtuple.RelationTuple
for {
page, np, err := m.GetRelationTuples(t.Context(), tt.searchCriteria, nextPage.ToOptions()...)
require.NoError(t, err)
require.LessOrEqual(t, len(page), pageSize)
if !np.IsLast() {
require.Len(t, page, pageSize)
}
got = append(got, page...)
nextPage = np
if nextPage.IsLast() {
break
}
}
// Same order as non-paginated fetch.
require.Equal(t, expected, got)
}
})
}
})
t.Run("case=empty list", func(t *testing.T) {
t.Parallel()
nspace := t.Name()
res, nextPage, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: &nspace,
})
assert.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{}, res)
assert.True(t, nextPage.IsLast())
})
})
t.Run("method=Delete", func(t *testing.T) {
t.Run("case=deletes tuple", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
for _, rt := range []*relationtuple.RelationTuple{
tf.Tuple(t, "Obj:o#r to delete@s"),
tf.Tuple(t, "Obj:o#r to delete@Obj:o2#r2"),
} {
t.Run(fmt.Sprintf("subject_type=%T", rt.Subject), func(t *testing.T) {
require.NoError(t, m.WriteRelationTuples(t.Context(), rt))
res, _, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{rt}, res)
require.NoError(t, m.DeleteRelationTuples(t.Context(), rt))
res, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.Len(t, res, 0)
})
}
})
t.Run("case=deletes only one tuple", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
rs := make([]*relationtuple.RelationTuple, 4)
for i := range rs {
rs[i] = tf.Tuple(t, fmt.Sprintf("Obj:o%d#r%d@s%d", i, i, i))
}
require.NoError(t, m.WriteRelationTuples(t.Context(), rs...))
res, _, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
for _, rt := range rs {
assert.Contains(t, res, rt)
}
require.NoError(t, m.DeleteRelationTuples(t.Context(), rs[0], rs[2]))
res, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.ElementsMatch(t, []*relationtuple.RelationTuple{rs[1], rs[3]}, res)
})
t.Run("case=tuple and subject namespace differ", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
rt := tf.Tuple(t, "N0:o#r@N1:o#r")
require.NoError(t, m.WriteRelationTuples(t.Context(), rt))
actual, _, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: new(tf.NS("N0"))})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{rt}, actual)
require.NoError(t, m.DeleteRelationTuples(t.Context(), rt))
actual, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: new(tf.NS("N0"))})
require.NoError(t, err)
assert.Len(t, actual, 0)
})
})
t.Run("method=Transact", func(t *testing.T) {
t.Run("case=success", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
rs := make([]*relationtuple.RelationTuple, 4)
for i := range rs {
rs[i] = tf.Tuple(t, fmt.Sprintf("Obj:o%d#r%d@s%d", i, i, i))
}
require.NoError(t, m.WriteRelationTuples(t.Context(), rs[0], rs[1]))
res, _, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.ElementsMatch(t, []*relationtuple.RelationTuple{rs[0], rs[1]}, res)
require.NoError(t, m.TransactRelationTuples(t.Context(), []*relationtuple.RelationTuple{rs[2], rs[3]}, []*relationtuple.RelationTuple{rs[0]}))
res, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
for _, rt := range []*relationtuple.RelationTuple{rs[1], rs[2], rs[3]} {
assert.Contains(t, res, rt)
}
})
t.Run("case=err rolls back all", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
rs := []*relationtuple.RelationTuple{
tf.Tuple(t, "Obj:o0#r0@s0"),
tf.Tuple(t, "Obj:o1#r1@s1"),
}
invalidRt := &relationtuple.RelationTuple{
Namespace: tf.NS("Obj"),
Object: tf.UUID("o0"),
Relation: "r0",
Subject: nil, // subject is not allowed to be nil
}
require.NoError(t, m.WriteRelationTuples(t.Context(), rs[0]))
res, _, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{rs[0]}, res)
t.Run("invalid=insert", func(t *testing.T) {
assert.ErrorIs(t, m.TransactRelationTuples(t.Context(), []*relationtuple.RelationTuple{invalidRt}, []*relationtuple.RelationTuple{rs[0]}), ketoapi.ErrNilSubject())
})
res, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{rs[0]}, res)
t.Run("invalid=delete", func(t *testing.T) {
assert.ErrorIs(t, m.TransactRelationTuples(t.Context(), []*relationtuple.RelationTuple{rs[1]}, []*relationtuple.RelationTuple{invalidRt}), ketoapi.ErrNilSubject())
})
res, _, err = m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: new(tf.NS("Obj")),
})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{rs[0]}, res)
})
})
traversalTest(t, m)
}
func traversalTest(t *testing.T, m relationtuple.Manager) {
t.Run("method=FindTupleWithRelations", func(t *testing.T) {
t.Run("case=nil relations returns nil", func(t *testing.T) {
t.Parallel()
result, err := m.FindTupleWithRelations(t.Context(), &relationtuple.RelationTuple{}, nil)
require.NoError(t, err)
assert.Nil(t, result)
})
t.Run("case=matching relation returns tuple", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
tuple := tf.Tuple(t, "Obj:o#viewer@User:u")
require.NoError(t, m.WriteRelationTuples(t.Context(), tuple))
// The query tuple's own relation is ignored; only the relations list matters.
result, err := m.FindTupleWithRelations(t.Context(), tf.Tuple(t, "Obj:o#editor@User:u"), []string{"viewer"})
require.NoError(t, err)
assert.Equal(t, tuple, result)
})
t.Run("case=non-matching relation returns nil", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
tuple := tf.Tuple(t, "Obj:o#viewer@u")
require.NoError(t, m.WriteRelationTuples(t.Context(), tuple))
result, err := m.FindTupleWithRelations(t.Context(), tuple, []string{"editor"})
require.NoError(t, err)
assert.Nil(t, result)
})
t.Run("case=multiple relations with one matching returns tuple", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
tuple := tf.Tuple(t, "Obj:o#viewer@u")
require.NoError(t, m.WriteRelationTuples(t.Context(), tuple))
result, err := m.FindTupleWithRelations(t.Context(), tuple, []string{"editor", "viewer", "owner"})
require.NoError(t, err)
assert.Equal(t, tuple, result)
})
})
t.Run("method=TraverseSubjectSetExpansion", func(t *testing.T) {
t.Run("case=no subject set tuples returns empty", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
res, err := m.TraverseSubjectSetExpansion(t.Context(), tf.Tuple(t, "Obj:o#rel@u"), nil)
require.NoError(t, err)
assert.Empty(t, res)
})
t.Run("case=subject found in subject set", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
membership := tf.Tuple(t, "Group:g#member@u")
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Group:g#member"),
membership,
))
start := tf.Tuple(t, "Obj:o#rel@u")
res, err := m.TraverseSubjectSetExpansion(t.Context(), start, nil)
require.NoError(t, err)
require.Len(t, res, 1)
assert.True(t, res[0].Found)
assert.Equal(t, relationtuple.TraversalSubjectSetExpand, res[0].Via)
assert.Equal(t, start, res[0].From)
assert.Equal(t, membership.Namespace, res[0].To.Namespace)
assert.Equal(t, membership.Object, res[0].To.Object)
assert.Equal(t, membership.Relation, res[0].To.Relation)
})
t.Run("case=subject not found in subject set", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
// Subject-set pointer without a membership tuple behind it.
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Group:g#member"),
))
res, err := m.TraverseSubjectSetExpansion(t.Context(), tf.Tuple(t, "Obj:o#rel@u"), nil)
require.NoError(t, err)
require.Len(t, res, 1)
assert.False(t, res[0].Found)
})
t.Run("case=allowedSubjectSets filters out non-matching types", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Doc:d#member"),
tf.Tuple(t, "Obj:o#rel@Group:g#admin"),
))
res, err := m.TraverseSubjectSetExpansion(t.Context(), tf.Tuple(t, "Obj:o#rel@u"), []relationtuple.SubjectSetType{{Namespace: tf.NS("Doc"), Relation: "member"}})
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, tf.NS("Doc"), res[0].To.Namespace)
assert.Equal(t, "member", res[0].To.Relation)
})
t.Run("case=nil allowedSubjectSets returns all types", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Doc:d#member"),
tf.Tuple(t, "Obj:o#rel@Group:g#admin"),
))
res, err := m.TraverseSubjectSetExpansion(t.Context(), tf.Tuple(t, "Obj:o#rel@u"), nil)
require.NoError(t, err)
assert.Len(t, res, 2)
})
t.Run("case=AllowsDirect=false direct relationship is not checked", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Group:g#member"),
tf.Tuple(t, "Group:g#member@User:u"),
))
start := tf.Tuple(t, "Obj:o#rel@User:u")
res, err := m.TraverseSubjectSetExpansion(t.Context(), start, []relationtuple.SubjectSetType{{Namespace: tf.NS("Group"), Relation: "member", AllowsDirect: false}})
require.NoError(t, err)
require.Len(t, res, 1)
assert.False(t, res[0].Found)
})
t.Run("case=AllowsDirect gates EXISTS per type", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Allowed:a#member"),
tf.Tuple(t, "Obj:o#rel@NotAllowed:n#member"),
tf.Tuple(t, "Allowed:a#member@User:u"),
tf.Tuple(t, "NotAllowed:n#member@User:u"),
))
start := tf.Tuple(t, "Obj:o#rel@User:u")
t.Run("non-allowed type returns found=false", func(t *testing.T) {
t.Parallel()
res, err := m.TraverseSubjectSetExpansion(t.Context(), start,
[]relationtuple.SubjectSetType{{Namespace: tf.NS("NotAllowed"), Relation: "member", AllowsDirect: false}},
)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, tf.NS("NotAllowed"), res[0].To.Namespace)
assert.False(t, res[0].Found)
})
t.Run("mixed: allowed returns found=true, non-allowed returns found=false", func(t *testing.T) {
t.Parallel()
res, err := m.TraverseSubjectSetExpansion(t.Context(), start, []relationtuple.SubjectSetType{
{Namespace: tf.NS("Allowed"), Relation: "member", AllowsDirect: true},
{Namespace: tf.NS("NotAllowed"), Relation: "member", AllowsDirect: false},
})
require.NoError(t, err)
// TraverseSubjectSetExpansion can short-circuit if it reads the allowed
// namespace first, returning only 1 item.
require.GreaterOrEqual(t, len(res), 1)
byNs := make(map[string]*relationtuple.TraversalResult)
for _, r := range res {
byNs[r.To.Namespace] = r
}
assert.True(t, byNs[tf.NS("Allowed")].Found)
})
})
t.Run("case=multiple AllowsDirect types are checked in one query", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@GroupA:a#member"),
tf.Tuple(t, "Obj:o#rel@GroupB:b#member"),
tf.Tuple(t, "Obj:o#rel@Stale:s#member"),
// A stale membership under the type that does not allow direct matches.
tf.Tuple(t, "Stale:s#member@User:u"),
))
start := tf.Tuple(t, "Obj:o#rel@User:u")
types := []relationtuple.SubjectSetType{
{Namespace: tf.NS("GroupA"), Relation: "member", AllowsDirect: true},
{Namespace: tf.NS("GroupB"), Relation: "member", AllowsDirect: true},
{Namespace: tf.NS("Stale"), Relation: "member", AllowsDirect: false},
}
// Nothing is found, so the traversal cannot short-circuit and must
// return all three pointers with found=false.
res, err := m.TraverseSubjectSetExpansion(t.Context(), start, types)
require.NoError(t, err)
require.Len(t, res, 3)
for _, r := range res {
assert.False(t, r.Found, "namespace %s must not be found", r.To.Namespace)
}
})
t.Run("case=multiple allowedSubjectSets entries each match independently", func(t *testing.T) {
t.Parallel()
tf := testhelpers.NewTupleFactory()
require.NoError(t, m.WriteRelationTuples(t.Context(),
tf.Tuple(t, "Obj:o#rel@Doc:d#member"),
tf.Tuple(t, "Obj:o#rel@Group:g#admin"),
// Not in the allowed list below.
tf.Tuple(t, "Obj:o#rel@Group:g2#viewer"),
))
res, err := m.TraverseSubjectSetExpansion(t.Context(), tf.Tuple(t, "Obj:o#rel@u"), []relationtuple.SubjectSetType{
{Namespace: tf.NS("Doc"), Relation: "member"},
{Namespace: tf.NS("Group"), Relation: "admin"},
})
require.NoError(t, err)
assert.Len(t, res, 2)
})
})
}
func twice(t *testing.T, m0, m1 relationtuple.Manager) func(string, func(*testing.T, relationtuple.Manager, relationtuple.Manager)) {
return func(desc string, run func(*testing.T, relationtuple.Manager, relationtuple.Manager)) {
t.Run(desc+"-0", func(t *testing.T) {
run(t, m0, m1)
})
t.Run(desc+"-1", func(t *testing.T) {
run(t, m1, m0)
})
}
}
func reset(t *testing.T, ms ...relationtuple.Manager) {
for _, m := range ms {
for {
rts, next, err := m.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{})
require.NoError(t, err)
require.NoError(t, m.DeleteRelationTuples(t.Context(), rts...))
if next.IsLast() {
break
}
}
}
}
func IsolationTest(t *testing.T, m0, m1 relationtuple.Manager) {
run := twice(t, m0, m1)
run("suite=lifecycle", func(t *testing.T, m0, m1 relationtuple.Manager) {
nspace := t.Name()
rts := []*relationtuple.RelationTuple{
{
Namespace: nspace,
Object: uuid.Must(uuid.NewV4()),
Relation: "r",
Subject: &relationtuple.SubjectSet{
Namespace: nspace,
Object: uuid.Must(uuid.NewV4()),
Relation: "r",
},
},
{
Namespace: nspace,
Object: uuid.Must(uuid.NewV4()),
Relation: "r",
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
},
}
t.Run("case=write and get", func(t *testing.T) {
reset(t, m0, m1)
require.NoError(t, m0.WriteRelationTuples(t.Context(), rts...))
other, _, err := m1.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
assert.Len(t, other, 0)
actual, _, err := m0.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
assert.ElementsMatch(t, rts, actual)
})
t.Run("case=delete", func(t *testing.T) {
reset(t, m0, m1)
require.NoError(t, m1.WriteRelationTuples(t.Context(), rts...))
require.NoError(t, m0.DeleteRelationTuples(t.Context(), rts...))
deleted, _, err := m0.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
assert.Len(t, deleted, 0)
actual, _, err := m1.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
assert.ElementsMatch(t, rts, actual)
})
t.Run("case=transact", func(t *testing.T) {
reset(t, m0, m1)
// note that the reset is outside this subtest, so in the second run we actually delete what we had before
twice(t, m0, m1)("insert and delete", func(t *testing.T, m0, m1 relationtuple.Manager) {
require.NoError(t, m0.TransactRelationTuples(t.Context(), []*relationtuple.RelationTuple{rts[0]}, []*relationtuple.RelationTuple{rts[1]}))
require.NoError(t, m1.TransactRelationTuples(t.Context(), []*relationtuple.RelationTuple{rts[1]}, []*relationtuple.RelationTuple{rts[0]}))
r0, _, err := m0.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
r1, _, err := m1.GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace})
require.NoError(t, err)
assert.Equal(t, rts[:1], r0)
assert.Equal(t, rts[1:], r1)
})
})
t.Run("case=cancelled", func(t *testing.T) {
reset(t, m0, m1)
ctx, cancel := context.WithCancel(t.Context())
require.NoError(t, m0.WriteRelationTuples(ctx, rts...))
cancel()
_, _, err := m0.GetRelationTuples(ctx, &relationtuple.RelationQuery{Namespace: &nspace})
assert.ErrorIs(t, err, context.Canceled)
})
})
}

View File

@@ -0,0 +1,53 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/relationtuple"
)
func MappingManagerTest(t *testing.T, m relationtuple.MappingManager) {
t.Run("case=str -> uuid -> str", func(t *testing.T) {
const s = "rep1"
u, err := m.MapStringsToUUIDs(t.Context(), s)
require.NoError(t, err)
actual, err := m.MapUUIDsToStrings(t.Context(), u[0])
require.NoError(t, err)
require.Len(t, actual, 1)
assert.Equal(t, s, actual[0])
t.Run("case=batch", func(t *testing.T) {
s := []string{"rep1", "rep2", "rep3"}
u, err := m.MapStringsToUUIDs(t.Context(), s...)
require.NoError(t, err)
require.Len(t, u, len(s))
assert.NotContains(t, u, uuid.Nil)
actual, err := m.MapUUIDsToStrings(t.Context(), u...)
require.NoError(t, err)
require.Len(t, actual, len(s))
assert.Equal(t, s, actual)
})
})
t.Run("case=deterministic MapStringsToUUIDs", func(t *testing.T) {
const s = "some string"
u0, err := m.MapStringsToUUIDs(t.Context(), s)
require.NoError(t, err)
u1, err := m.MapStringsToUUIDs(t.Context(), s)
require.NoError(t, err)
assert.Equal(t, u0, u1)
})
}

View File

@@ -0,0 +1,320 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"context"
"encoding/json"
"net/http"
"connectrpc.com/connect"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/internal/x/validate"
"github.com/ory/keto/ketoapi"
"github.com/ory/keto/x/events"
)
var _ relationtuplesconnect.WriteServiceHandler = (*WriteHandler)(nil)
func protoTuplesWithAction(deltas []*rts.RelationTupleDelta, action rts.RelationTupleDelta_Action) (filtered []*ketoapi.RelationTuple, err error) {
for _, d := range deltas {
if d.Action == action {
it, err := (&ketoapi.RelationTuple{}).FromDataProvider(d.RelationTuple)
if err != nil {
return nil, err
}
filtered = append(filtered, it)
}
}
return
}
func (h *WriteHandler) TransactRelationTuples(ctx context.Context, req *connect.Request[rts.TransactRelationTuplesRequest]) (*connect.Response[rts.TransactRelationTuplesResponse], error) {
insertTuples, err := protoTuplesWithAction(req.Msg.RelationTupleDeltas, rts.RelationTupleDelta_ACTION_INSERT)
if err != nil {
return nil, err
}
deleteTuples, err := protoTuplesWithAction(req.Msg.RelationTupleDeltas, rts.RelationTupleDelta_ACTION_DELETE)
if err != nil {
return nil, err
}
err = h.d.RelationTupleManager().Transaction(ctx, func(ctx context.Context) error {
its, err := h.d.Mapper().FromTuple(ctx, append(insertTuples, deleteTuples...)...)
if err != nil {
return err
}
return h.d.RelationTupleManager().TransactRelationTuples(ctx, its[:len(insertTuples)], its[len(insertTuples):])
})
if err != nil {
return nil, err
}
trace.SpanFromContext(ctx).AddEvent(events.NewRelationtuplesChanged(ctx))
snaptokens := make([]string, len(insertTuples))
for i := range insertTuples {
snaptokens[i] = "not yet implemented"
}
return connect.NewResponse(&rts.TransactRelationTuplesResponse{
Snaptokens: snaptokens,
}), nil
}
func (h *WriteHandler) DeleteRelationTuples(ctx context.Context, req *connect.Request[rts.DeleteRelationTuplesRequest]) (*connect.Response[rts.DeleteRelationTuplesResponse], error) {
var q ketoapi.RelationQuery
switch {
case req.Msg.RelationQuery != nil:
q.FromDataProvider(&queryWrapper{req.Msg.RelationQuery})
//lint:ignore SA1019 required for compatibility
case req.Msg.Query != nil: //nolint:staticcheck
//lint:ignore SA1019 backwards compatibility
q.FromDataProvider(&deprecatedQueryWrapper{(*rts.ListRelationTuplesRequest_Query)(req.Msg.Query)}) //nolint:staticcheck
default:
return nil, errors.WithStack(herodot.ErrBadRequest().WithReason("invalid request"))
}
iq, err := h.d.ReadOnlyMapper().FromQuery(ctx, &q)
if err != nil {
return nil, err
}
if err := h.d.RelationTupleManager().DeleteAllRelationTuples(ctx, iq); err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError().WithError(err.Error()))
}
trace.SpanFromContext(ctx).AddEvent(events.NewRelationtuplesDeleted(ctx))
return connect.NewResponse(&rts.DeleteRelationTuplesResponse{}), nil
}
// Create Relationship Request Parameters
//
// swagger:parameters createRelationship
//
//lint:ignore U1000 required for OpenAPI
type createRelationship struct {
// in: body
Body createRelationshipBody
}
// Create Relationship Request Body
//
// swagger:model createRelationshipBody
type createRelationshipBody struct {
ketoapi.RelationQuery
}
// swagger:route PUT /admin/relation-tuples relationship createRelationship
//
// # Create a Relationship
//
// Use this endpoint to create a relationship.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 201: relationship
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-low
func (h *WriteHandler) createRelation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var rt ketoapi.RelationTuple
if err := json.NewDecoder(r.Body).Decode(&rt); err != nil {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithError(err.Error())))
return
}
if err := rt.Validate(); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Logger().WithFields(rt.ToLoggerFields()).Debug("creating relation tuple")
err := h.d.RelationTupleManager().Transaction(ctx, func(ctx context.Context) error {
it, err := h.d.Mapper().FromTuple(ctx, &rt)
if err != nil {
h.d.Logger().WithError(err).WithFields(rt.ToLoggerFields()).Errorf("could not map relation tuple to UUIDs")
return err
}
if err := h.d.RelationTupleManager().WriteRelationTuples(ctx, it...); err != nil {
h.d.Logger().WithError(err).WithFields(rt.ToLoggerFields()).Errorf("got an error while creating the relation tuple")
return err
}
return nil
})
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
trace.SpanFromContext(ctx).AddEvent(events.NewRelationtuplesCreated(ctx))
h.d.Writer().WriteCreated(w, r,
ReadRouteBase+"?"+rt.ToURLQuery().Encode(),
&rt,
)
}
// swagger:route DELETE /admin/relation-tuples relationship deleteRelationships
//
// # Delete Relationships
//
// Use this endpoint to delete relationships
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 204: emptyResponse
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-low
func (h *WriteHandler) deleteRelations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := validate.All(r,
validate.NoExtraQueryParams(ketoapi.RelationQueryKeys...),
validate.QueryParamsContainsOneOf(ketoapi.NamespaceKey),
validate.HasEmptyBody(),
); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
q := r.URL.Query()
query, err := (&ketoapi.RelationQuery{}).FromURLQuery(q)
if err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
l := h.d.Logger()
for k := range q {
l = l.WithField(k, q.Get(k))
}
l.Debug("deleting relationships")
iq, err := h.d.ReadOnlyMapper().FromQuery(ctx, query)
if err != nil {
h.d.Logger().WithError(err).Errorf("could not map fields to UUIDs")
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RelationTupleManager().DeleteAllRelationTuples(ctx, iq); err != nil {
l.WithError(err).Errorf("got an error while deleting relationships")
h.d.Writer().WriteError(w, r, herodot.ErrInternalServerError().WithError(err.Error()))
return
}
trace.SpanFromContext(ctx).AddEvent(events.NewRelationtuplesDeleted(ctx))
w.WriteHeader(http.StatusNoContent)
}
func internalTuplesWithAction(deltas []*ketoapi.PatchDelta, action ketoapi.PatchAction) (filtered []*ketoapi.RelationTuple) {
for _, d := range deltas {
if d.Action == action {
filtered = append(filtered, d.RelationTuple)
}
}
return
}
// swagger:route PATCH /admin/relation-tuples relationship patchRelationships
//
// # Patch Multiple Relationships
//
// Use this endpoint to patch one or more relationships.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 204: emptyResponse
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-low
func (h *WriteHandler) patchRelationTuples(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var deltas []*ketoapi.PatchDelta
if err := json.NewDecoder(r.Body).Decode(&deltas); err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
for _, d := range deltas {
if d.RelationTuple == nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError("relation_tuple is missing"))
return
}
if err := d.RelationTuple.Validate(); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
switch d.Action {
case ketoapi.ActionInsert, ketoapi.ActionDelete:
default:
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError("unknown action "+string(d.Action)))
return
}
}
insertTuples := internalTuplesWithAction(deltas, ketoapi.ActionInsert)
deleteTuples := internalTuplesWithAction(deltas, ketoapi.ActionDelete)
err := h.d.RelationTupleManager().Transaction(ctx, func(ctx context.Context) error {
its, err := h.d.Mapper().FromTuple(ctx, append(insertTuples, deleteTuples...)...)
if err != nil {
h.d.Logger().WithError(err).Errorf("got an error while mapping fields to UUID")
return err
}
return h.d.RelationTupleManager().TransactRelationTuples(ctx, its[:len(insertTuples)], its[len(insertTuples):])
})
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
trace.SpanFromContext(ctx).AddEvent(events.NewRelationtuplesChanged(ctx))
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,486 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/ory/x/httprouterx"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func TestWriteHandlers(t *testing.T) {
r := httprouterx.NewRouterAdmin()
reg := driver.NewSqliteTestRegistry(t)
var nspaces []*namespace.Namespace
addNamespace := func(t *testing.T) *namespace.Namespace {
n := &namespace.Namespace{
Name: t.Name(),
}
nspaces = append(nspaces, n)
require.NoError(t, reg.Config(t.Context()).Set(config.KeyNamespaces, nspaces))
return n
}
relationtuple.NewWriteHandler(reg).RegisterWriteRoutes(r)
relationtuple.NewReadHandler(reg).RegisterReadRoutes(r.ToPublic())
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("method=create", func(t *testing.T) {
doCreate := func(raw []byte) *http.Response {
req, err := http.NewRequest(http.MethodPut, ts.URL+relationtuple.WriteRouteBase, bytes.NewBuffer(raw))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
return resp
}
t.Run("case=creates tuple", func(t *testing.T) {
nspace := addNamespace(t)
rt := &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "obj",
Relation: "rel",
SubjectID: new("subj"),
}
payload, err := json.Marshal(rt)
require.NoError(t, err)
resp := doCreate(payload)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.JSONEq(t, string(payload), string(body))
t.Run("check=is contained in the manager", func(t *testing.T) {
mapped, err := reg.Mapper().FromTuple(t.Context(), rt)
require.NoError(t, err)
// set a size > 1 just to make sure it gets all
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), mapped[0].ToQuery(), keysetpagination.WithSize(10))
require.NoError(t, err)
actual, err := reg.Mapper().ToTuple(t.Context(), actualRTs...)
require.NoError(t, err)
assert.Equalf(t, []*ketoapi.RelationTuple{rt}, actual, "want: %s\ngot: %s", rt.String(), actual[0].String())
})
t.Run("check=is gettable with the returned URL", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + resp.Header.Get("Location"))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
respDec := ketoapi.GetResponse{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&respDec))
assert.Equal(t, []*ketoapi.RelationTuple{rt}, respDec.RelationTuples)
})
})
t.Run("case=returns bad request on JSON parse error", func(t *testing.T) {
resp := doCreate([]byte("foo"))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("case=special chars", func(t *testing.T) {
nspace := addNamespace(t)
rts := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "group:B",
Relation: "member",
SubjectSet: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "group:A",
Relation: "member",
},
},
{
Namespace: nspace.Name,
Object: "@all",
Relation: "member",
SubjectID: new("this:could#be interpreted:as a@subject set"),
},
}
for _, rt := range rts {
payload, err := json.Marshal(rt)
require.NoError(t, err)
resp := doCreate(payload)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
actual, next, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: &nspace.Name,
})
require.NoError(t, err)
actualMapped, err := reg.Mapper().ToTuple(t.Context(), actual...)
require.NoError(t, err)
assert.True(t, next.IsLast())
assert.ElementsMatch(t, rts, actualMapped)
})
})
t.Run("method=delete", func(t *testing.T) {
t.Run("case=deletes a tuple", func(t *testing.T) {
nspace := addNamespace(t)
rt := &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "deleted obj",
Relation: "deleted rel",
SubjectID: new("deleted subj"),
}
testhelpers.MapAndInsertTuples(t, reg, rt)
req, err := http.NewRequest(http.MethodDelete, ts.URL+relationtuple.WriteRouteBase+"?"+rt.ToURLQuery().Encode(), nil)
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// set a size > 1 just to make sure it gets all
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace.Name}, keysetpagination.WithSize(10))
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{}, actualRTs)
})
t.Run("case=deletes multiple tuples", func(t *testing.T) {
nspace := addNamespace(t)
rts := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "deleted obj",
Relation: "deleted rel",
SubjectID: new("deleted subj 1"),
},
{
Namespace: nspace.Name,
Object: "deleted obj",
Relation: "deleted rel",
SubjectID: new("deleted subj 2"),
},
}
testhelpers.MapAndInsertTuples(t, reg, rts...)
q := url.Values{
"namespace": {nspace.Name},
"object": {"deleted obj"},
"relation": {"deleted rel"},
}
req, err := http.NewRequest(http.MethodDelete, ts.URL+relationtuple.WriteRouteBase+"?"+q.Encode(), nil)
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
query, err := (&ketoapi.RelationQuery{}).FromURLQuery(q)
require.NoError(t, err)
mappedQuery, err := reg.Mapper().FromQuery(t.Context(), query)
require.NoError(t, err)
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), mappedQuery, keysetpagination.WithSize(10))
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{}, actualRTs)
})
t.Run("suite=bad requests", func(t *testing.T) {
nspace := addNamespace(t)
rts := []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "deleted obj",
Relation: "deleted rel",
SubjectID: new("deleted subj 1"),
},
{
Namespace: nspace.Name,
Object: "deleted obj",
Relation: "deleted rel",
SubjectID: new("deleted subj 2"),
},
}
testhelpers.MapAndInsertTuples(t, reg, rts...)
assertBadRequest := func(t *testing.T, req *http.Request) {
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
assertTuplesExist := func(t *testing.T) {
mappedQuery, err := reg.Mapper().FromQuery(t.Context(), &ketoapi.RelationQuery{
Namespace: &nspace.Name,
})
require.NoError(t, err)
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), mappedQuery, keysetpagination.WithSize(10))
require.NoError(t, err)
mappedRTs, err := reg.Mapper().ToTuple(t.Context(), actualRTs...)
require.NoError(t, err)
assert.ElementsMatch(t, rts, mappedRTs)
}
t.Run("case=bad request if body sent", func(t *testing.T) {
q := url.Values{
"namespace": {nspace.Name},
"object": {"deleted obj"},
"relation": {"deleted rel"},
}
req, err := http.NewRequest(
http.MethodDelete,
ts.URL+relationtuple.WriteRouteBase+"?"+q.Encode(),
strings.NewReader("some body"))
require.NoError(t, err)
assertBadRequest(t, req)
assertTuplesExist(t)
})
t.Run("case=bad request query param misspelled", func(t *testing.T) {
req, err := http.NewRequest(
http.MethodDelete,
ts.URL+relationtuple.WriteRouteBase+"?invalid=param",
nil)
require.NoError(t, err)
assertBadRequest(t, req)
assertTuplesExist(t)
})
t.Run("case=bad request if query params misssing", func(t *testing.T) {
req, err := http.NewRequest(
http.MethodDelete,
ts.URL+relationtuple.WriteRouteBase,
nil)
require.NoError(t, err)
assertBadRequest(t, req)
assertTuplesExist(t)
})
})
})
t.Run("method=patch", func(t *testing.T) {
t.Run("case=create and delete", func(t *testing.T) {
nspace := addNamespace(t)
relation := t.Name()
deltas := []*ketoapi.PatchDelta{
{
Action: ketoapi.ActionInsert,
RelationTuple: &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "create obj",
Relation: relation,
SubjectID: new("create sub"),
},
},
{
Action: ketoapi.ActionDelete,
RelationTuple: &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "delete obj",
Relation: relation,
SubjectID: new("delete sub"),
},
},
}
testhelpers.MapAndInsertTuples(t, reg, deltas[1].RelationTuple)
body, err := json.Marshal(deltas)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBuffer(body))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: &nspace.Name,
Relation: &relation,
})
require.NoError(t, err)
mapped, err := reg.Mapper().ToTuple(t.Context(), actualRTs...)
require.NoError(t, err)
assert.Equal(t, []*ketoapi.RelationTuple{deltas[0].RelationTuple}, mapped)
})
t.Run("case=ignores rest on err", func(t *testing.T) {
nspace := addNamespace(t)
deltas := []*ketoapi.PatchDelta{
{
Action: ketoapi.ActionInsert,
RelationTuple: &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "create obj",
Relation: t.Name(),
SubjectID: new("create sub"),
},
},
{
Action: ketoapi.ActionDelete,
RelationTuple: &ketoapi.RelationTuple{
Namespace: "not " + nspace.Name,
Object: "o",
Relation: "r",
SubjectID: new("s"),
},
},
}
body, err := json.Marshal(deltas)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBuffer(body))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
// set a size > 1 just to make sure it gets all
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{Namespace: &nspace.Name}, keysetpagination.WithSize(10))
require.NoError(t, err)
assert.Len(t, actualRTs, 0)
})
t.Run("case=only create", func(t *testing.T) {
nspace := addNamespace(t)
deltas := []*ketoapi.PatchDelta{
{
Action: ketoapi.ActionInsert,
RelationTuple: &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "create obj",
Relation: "rel",
SubjectID: new("create sub"),
},
},
}
body, err := json.Marshal(deltas)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBuffer(body))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: &nspace.Name,
})
require.NoError(t, err)
mapped, err := reg.Mapper().ToTuple(t.Context(), actualRTs...)
require.NoError(t, err)
assert.Equal(t, []*ketoapi.RelationTuple{deltas[0].RelationTuple}, mapped)
})
t.Run("case=only delete", func(t *testing.T) {
nspace := addNamespace(t)
deltas := []*ketoapi.PatchDelta{
{
Action: ketoapi.ActionDelete,
RelationTuple: &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: "delete obj",
Relation: "rel",
SubjectID: new("delete sub"),
},
},
}
body, err := json.Marshal(deltas)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBuffer(body))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
actualRTs, _, err := reg.RelationTupleManager().GetRelationTuples(t.Context(), &relationtuple.RelationQuery{
Namespace: &nspace.Name,
})
require.NoError(t, err)
assert.Equal(t, []*relationtuple.RelationTuple{}, actualRTs)
})
t.Run("case=valid JSON, invalid content", func(t *testing.T) {
rawJSON := `
[
{
"action": "insert",
"namespace":"role",
"object":"super-admin",
"relation":"member",
"subject":"role:company-admin"
}
]`
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBufferString(rawJSON))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
defer func() { _ = resp.Body.Close() }()
errContent, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(errContent), "relation_tuple is missing")
})
t.Run("case=unknown action", func(t *testing.T) {
rawJSON := `
[
{
"action": "unknown_action_foo",
"relation_tuple": {
"namespace":"role",
"object":"super-admin",
"relation":"member",
"subject_id":"role:company-admin"
}
}
]`
req, err := http.NewRequest(http.MethodPatch, ts.URL+relationtuple.WriteRouteBase, bytes.NewBufferString(rawJSON))
require.NoError(t, err)
resp, err := ts.Client().Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
defer func() { _ = resp.Body.Close() }()
errContent, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(errContent), "unknown_action_foo")
})
})
}

View File

@@ -0,0 +1,397 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple
import (
"context"
"github.com/ory/x/otelx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/ketoapi"
"github.com/gofrs/uuid"
)
type (
mapperDependencies interface {
MappingManagerProvider
config.Provider
}
MappingManagerProvider interface {
MappingManager() MappingManager
}
MappingManager interface {
MapStringsToUUIDs(ctx context.Context, s ...string) ([]uuid.UUID, error)
MapStringsToUUIDsReadOnly(ctx context.Context, s ...string) ([]uuid.UUID, error)
MapUUIDsToStrings(ctx context.Context, u ...uuid.UUID) ([]string, error)
}
MapperProvider interface {
Mapper() *Mapper
ReadOnlyMapper() *Mapper
}
Mapper struct {
D mapperDependencies
ReadOnly bool
}
)
type success struct {
fs []func()
err *error
}
func newSuccess(err *error) *success {
return &success{
err: err,
}
}
func (c *success) do(f func()) {
c.fs = append(c.fs, f)
}
func (c *success) apply() {
if *c.err != nil {
return
}
for _, f := range c.fs {
f()
}
}
func (m *Mapper) FromQuery(ctx context.Context, apiQuery *ketoapi.RelationQuery) (res *RelationQuery, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.FromQuery")
defer otelx.End(span, &err)
onSuccess := newSuccess(&err)
defer onSuccess.apply()
var s []string
var u []uuid.UUID
res = &RelationQuery{
Relation: apiQuery.Relation,
}
nm, err := m.D.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
if apiQuery.Namespace != nil {
n, err := nm.GetNamespaceByName(ctx, *apiQuery.Namespace)
if err != nil {
return nil, err
}
res.Namespace = new(n.Name)
}
if apiQuery.Object != nil {
s = append(s, *apiQuery.Object)
onSuccess.do(func(i int) func() {
return func() {
res.Object = new(u[i])
}
}(len(s) - 1))
}
if apiQuery.SubjectID != nil {
s = append(s, *apiQuery.SubjectID)
onSuccess.do(func(i int) func() {
return func() {
res.Subject = &SubjectID{u[i]}
}
}(len(s) - 1))
}
if apiQuery.SubjectSet != nil {
s = append(s, apiQuery.SubjectSet.Object)
n, err := nm.GetNamespaceByName(ctx, apiQuery.SubjectSet.Namespace)
if err != nil {
return nil, err
}
onSuccess.do(func(i int) func() {
return func() {
res.Subject = &SubjectSet{
Namespace: n.Name,
Object: u[i],
Relation: apiQuery.SubjectSet.Relation,
}
}
}(len(s) - 1))
}
if m.ReadOnly {
u, err = m.D.MappingManager().MapStringsToUUIDsReadOnly(ctx, s...)
} else {
u, err = m.D.MappingManager().MapStringsToUUIDs(ctx, s...)
}
if err != nil {
return nil, err
}
return res, nil
}
func (m *Mapper) ToQuery(ctx context.Context, q *RelationQuery) (res *ketoapi.RelationQuery, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.ToQuery")
defer otelx.End(span, &err)
onSuccess := newSuccess(&err)
defer onSuccess.apply()
var s []string
var u []uuid.UUID
res = &ketoapi.RelationQuery{
Relation: q.Relation,
}
nm, err := m.D.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
if q.Namespace != nil {
n, err := nm.GetNamespaceByName(ctx, *q.Namespace)
if err != nil {
return nil, err
}
res.Namespace = new(n.Name)
}
if q.Object != nil {
u = append(u, *q.Object)
onSuccess.do(func() {
res.Object = new(s[0])
})
}
if q.Subject != nil {
switch sub := q.Subject.(type) {
case *SubjectID:
u = append(u, sub.ID)
onSuccess.do(func() {
res.SubjectID = new(s[len(s)-1])
})
case *SubjectSet:
u = append(u, sub.Object)
n, err := nm.GetNamespaceByName(ctx, sub.Namespace)
if err != nil {
return nil, err
}
onSuccess.do(func() {
res.SubjectSet = &ketoapi.SubjectSet{
Namespace: n.Name,
Object: s[len(s)-1],
Relation: sub.Relation,
}
})
}
}
s, err = m.D.MappingManager().MapUUIDsToStrings(ctx, u...)
if err != nil {
return nil, err
}
return res, nil
}
func (m *Mapper) FromTuple(ctx context.Context, ts ...*ketoapi.RelationTuple) (res []*RelationTuple, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.FromTuple")
defer otelx.End(span, &err)
onSuccess := newSuccess(&err)
defer onSuccess.apply()
res = make([]*RelationTuple, 0, len(ts))
s := make([]string, 0, len(ts)*2)
var u []uuid.UUID
nm, err := m.D.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
for _, t := range ts {
t := t
n, err := nm.GetNamespaceByName(ctx, t.Namespace)
if err != nil {
return nil, err
}
mt := RelationTuple{
Namespace: n.Name,
Relation: t.Relation,
}
i := len(res)
if err := t.Validate(); err != nil {
return nil, err
}
if t.SubjectID != nil {
s = append(s, *t.SubjectID)
onSuccess.do(func() {
mt.Subject = &SubjectID{u[i*2]}
})
} else if t.SubjectSet != nil {
n, err := nm.GetNamespaceByName(ctx, t.SubjectSet.Namespace)
if err != nil {
return nil, err
}
s = append(s, t.SubjectSet.Object)
onSuccess.do(func() {
mt.Subject = &SubjectSet{
Namespace: n.Name,
Object: u[i*2],
Relation: t.SubjectSet.Relation,
}
})
}
s = append(s, t.Object)
onSuccess.do(func() {
mt.Object = u[i*2+1]
})
res = append(res, &mt)
}
if m.ReadOnly {
u, err = m.D.MappingManager().MapStringsToUUIDsReadOnly(ctx, s...)
} else {
u, err = m.D.MappingManager().MapStringsToUUIDs(ctx, s...)
}
if err != nil {
return nil, err
}
return res, nil
}
func (m *Mapper) ToTuple(ctx context.Context, ts ...*RelationTuple) (res []*ketoapi.RelationTuple, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.ToTuple")
defer otelx.End(span, &err)
onSuccess := newSuccess(&err)
defer onSuccess.apply()
res = make([]*ketoapi.RelationTuple, 0, len(ts))
u := make([]uuid.UUID, 0, len(ts)*2)
var s []string
for _, t := range ts {
mt := ketoapi.RelationTuple{
Namespace: t.Namespace,
Relation: t.Relation,
}
i := len(res)
switch sub := t.Subject.(type) {
case *SubjectID:
u = append(u, sub.ID)
onSuccess.do(func() {
mt.SubjectID = new(s[2*i])
})
case *SubjectSet:
u = append(u, sub.Object)
onSuccess.do(func() {
mt.SubjectSet = &ketoapi.SubjectSet{
Namespace: sub.Namespace,
Object: s[2*i],
Relation: sub.Relation,
}
})
}
u = append(u, t.Object)
onSuccess.do(func() {
mt.Object = s[2*i+1]
})
res = append(res, &mt)
}
s, err = m.D.MappingManager().MapUUIDsToStrings(ctx, u...)
if err != nil {
return nil, err
}
return res, nil
}
func (m *Mapper) FromSubjectSet(ctx context.Context, set *ketoapi.SubjectSet) (_ *SubjectSet, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.FromSubjectSet")
defer otelx.End(span, &err)
nm, err := m.D.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
n, err := nm.GetNamespaceByName(ctx, set.Namespace)
if err != nil {
return nil, err
}
var u []uuid.UUID
if m.ReadOnly {
u, err = m.D.MappingManager().MapStringsToUUIDsReadOnly(ctx, set.Object)
} else {
u, err = m.D.MappingManager().MapStringsToUUIDs(ctx, set.Object)
}
if err != nil {
return nil, err
}
return &SubjectSet{
Namespace: n.Name,
Object: u[0],
Relation: set.Relation,
}, nil
}
func (m *Mapper) ToTree(ctx context.Context, tree *Tree) (_ *ketoapi.Tree[*ketoapi.RelationTuple], err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("keto/internal/relationtuple").Start(ctx, "Mapper.ToTree")
defer otelx.End(span, &err)
var u []uuid.UUID
var targets []*string
// collect walks the tree, and collects the UUIDs and target pointers
var collect func(*Tree) *ketoapi.Tree[*ketoapi.RelationTuple]
collect = func(tree *Tree) *ketoapi.Tree[*ketoapi.RelationTuple] {
res := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: tree.Type,
Tuple: &ketoapi.RelationTuple{},
Children: make([]*ketoapi.Tree[*ketoapi.RelationTuple], len(tree.Children)),
}
switch sub := tree.Subject.(type) {
case *SubjectSet:
ss := &ketoapi.SubjectSet{
Namespace: sub.Namespace,
Relation: sub.Relation,
}
res.Tuple.SubjectSet = ss
targets = append(targets, &ss.Object)
u = append(u, sub.Object)
case *SubjectID:
res.Tuple.SubjectID = new(string)
targets = append(targets, res.Tuple.SubjectID)
u = append(u, sub.ID)
}
for i, c := range tree.Children {
res.Children[i] = collect(c)
}
// to support backward compatibility: Nodes without children were always treated as Leaf,
// even if the type is Union or Intersection. Keep the backward compatibility on api level.
if len(res.Children) == 0 {
res.Type = ketoapi.TreeNodeLeaf
}
return res
}
res := collect(tree)
s, err := m.D.MappingManager().MapUUIDsToStrings(ctx, u...)
if err != nil {
return nil, err
}
for i, ptr := range targets {
*ptr = s[i]
}
return res, nil
}

View File

@@ -0,0 +1,402 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package relationtuple_test
import (
"context"
"fmt"
"slices"
"testing"
"github.com/ory/herodot"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
)
func runWithMappers(t *testing.T, p relationtuple.MapperProvider, runner func(t *testing.T, m *relationtuple.Mapper)) {
t.Run("mapper=readwrite", func(t *testing.T) { runner(t, p.Mapper()) })
t.Run("mapper=readonly", func(t *testing.T) { runner(t, p.ReadOnlyMapper()) })
}
func TestMapper(t *testing.T) {
ctx := context.Background()
reg := driver.NewSqliteTestRegistry(t)
nspace := namespace.Namespace{
Name: "test",
}
nspace2 := namespace.Namespace{
Name: "test2",
}
require.NoError(t, reg.Config(ctx).Set(config.KeyNamespaces, []*namespace.Namespace{&nspace, &nspace2}))
t.Run("items=relationships", func(t *testing.T) {
for _, tc := range []struct {
name string
rts []*ketoapi.RelationTuple
err error
}{
{
name: "no relationships",
rts: []*ketoapi.RelationTuple{},
},
{
name: "one relation tuple",
rts: []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "object",
Relation: "relation",
SubjectID: new("subject"),
},
},
},
{
name: "relation tuple without subject",
rts: []*ketoapi.RelationTuple{
{
Namespace: nspace.Name,
Object: "object",
Relation: "relation",
},
},
err: ketoapi.ErrNilSubject(),
},
{
name: "many relationships",
rts: func() []*ketoapi.RelationTuple {
rts := make([]*ketoapi.RelationTuple, 10)
for i := range rts {
rts[i] = &ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: fmt.Sprintf("object %d", i),
Relation: "relation",
SubjectID: new("subject"),
}
}
return rts
}(),
},
{
name: "unknown namespace",
rts: []*ketoapi.RelationTuple{
{
Namespace: "unknown",
Object: "object",
Relation: "relation",
SubjectID: new("subject"),
},
},
err: herodot.ErrNotFound(),
},
} {
t.Run(tc.name, func(t *testing.T) {
runWithMappers(t, reg, func(t *testing.T, m *relationtuple.Mapper) {
mapped, err := m.FromTuple(ctx, tc.rts...)
require.ErrorIs(t, err, tc.err)
if tc.err != nil {
// the rest only makes sense if we have no error
return
}
assert.Len(t, mapped, len(tc.rts))
actual, err := m.ToTuple(ctx, mapped...)
require.NoError(t, err)
assert.ElementsMatch(t, tc.rts, actual)
})
})
}
})
t.Run("item=relation query", func(t *testing.T) {
for _, tc := range []struct {
name string
query *ketoapi.RelationQuery
err error
}{
{
name: "all fields nil",
query: &ketoapi.RelationQuery{},
},
{
name: "all fields set",
query: &ketoapi.RelationQuery{
Namespace: new(nspace.Name),
Object: new("object"),
Relation: new("relation"),
SubjectID: new("subject"),
},
},
{
name: "subject set",
query: &ketoapi.RelationQuery{
SubjectSet: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "object",
Relation: "relation",
},
},
},
{
name: "non-mapped fields",
query: &ketoapi.RelationQuery{
Relation: new("relation"),
SubjectSet: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Relation: "relation",
Object: "object",
},
},
},
{
name: "unknown namespace",
query: &ketoapi.RelationQuery{
Namespace: new("unknown"),
},
err: herodot.ErrNotFound(),
},
} {
t.Run(tc.name, func(t *testing.T) {
runWithMappers(t, reg, func(t *testing.T, m *relationtuple.Mapper) {
mapped, err := m.FromQuery(ctx, tc.query)
require.ErrorIs(t, err, tc.err)
if tc.err != nil {
// the rest only makes sense if we have no error
return
}
actual, err := m.ToQuery(ctx, mapped)
require.NoError(t, err)
assert.Equal(t, tc.query, actual)
})
})
}
})
t.Run("item=subject set", func(t *testing.T) {
for _, tc := range []struct {
name string
set *ketoapi.SubjectSet
err error
}{
{
name: "basic subject set",
set: &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "object",
Relation: "relation",
},
},
{
name: "unknown namespace",
set: &ketoapi.SubjectSet{
Namespace: "unknown",
Object: "object",
Relation: "relation",
},
err: herodot.ErrNotFound(),
},
} {
t.Run(tc.name, func(t *testing.T) {
runWithMappers(t, reg, func(t *testing.T, m *relationtuple.Mapper) {
mapped, err := reg.Mapper().FromSubjectSet(ctx, tc.set)
require.ErrorIs(t, err, tc.err)
if tc.err != nil {
// the rest only makes sense if we have no error
return
}
assert.NotZero(t, mapped.Object)
assert.Equal(t, nspace.Name, mapped.Namespace)
assert.Equal(t, tc.set.Relation, mapped.Relation)
})
})
}
})
t.Run("item=expand tree", func(t *testing.T) {
strs := make([]string, 3)
for i := range strs {
strs[i] = fmt.Sprintf("foo %d", i)
}
uuids, err := reg.MappingManager().MapStringsToUUIDs(ctx, strs...)
require.NoError(t, err)
for _, tc := range []struct {
name string
tree *relationtuple.Tree
err error
}{
{
name: "basic tree",
tree: &relationtuple.Tree{
Type: ketoapi.TreeNodeLeaf,
Subject: &relationtuple.SubjectID{ID: uuids[0]},
},
},
{
name: "basic tree with children",
tree: &relationtuple.Tree{
Type: ketoapi.TreeNodeUnion,
Subject: &relationtuple.SubjectSet{
Namespace: nspace.Name,
Object: uuids[0],
Relation: "members",
},
Children: []*relationtuple.Tree{
{
Type: ketoapi.TreeNodeLeaf,
Subject: &relationtuple.SubjectID{ID: uuids[1]},
},
{
Type: ketoapi.TreeNodeLeaf,
Subject: &relationtuple.SubjectID{ID: uuids[2]},
},
},
},
},
{
name: "deeply nested tree",
tree: &relationtuple.Tree{
Type: ketoapi.TreeNodeUnion,
Subject: &relationtuple.SubjectSet{
Namespace: nspace.Name,
Object: uuids[0],
Relation: "members",
},
Children: []*relationtuple.Tree{
{
Type: ketoapi.TreeNodeUnion,
Subject: &relationtuple.SubjectSet{
Namespace: nspace.Name,
Object: uuids[1],
Relation: "members",
},
Children: []*relationtuple.Tree{
{
Type: ketoapi.TreeNodeLeaf,
Subject: &relationtuple.SubjectID{ID: uuids[2]},
},
},
},
},
},
},
{
name: "deeply nested tree with different namespaces",
tree: &relationtuple.Tree{
Type: ketoapi.TreeNodeUnion,
Subject: &relationtuple.SubjectSet{
Namespace: nspace.Name,
Object: uuids[0],
Relation: "members",
},
Children: []*relationtuple.Tree{
{
Type: ketoapi.TreeNodeUnion,
Subject: &relationtuple.SubjectSet{
Namespace: nspace2.Name,
Object: uuids[1],
Relation: "owners",
},
Children: []*relationtuple.Tree{
{
Type: ketoapi.TreeNodeLeaf,
Subject: &relationtuple.SubjectID{ID: uuids[2]},
},
},
},
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
mapped, err := reg.Mapper().ToTree(ctx, tc.tree)
require.ErrorIs(t, err, tc.err)
if tc.err != nil {
// the rest only makes sense if we have no error
return
}
var checkTree func(*ketoapi.Tree[*ketoapi.RelationTuple], *relationtuple.Tree)
checkTree = func(mapped *ketoapi.Tree[*ketoapi.RelationTuple], original *relationtuple.Tree) {
switch s := original.Subject.(type) {
case *relationtuple.SubjectID:
require.NotNil(t, mapped.Tuple.SubjectID)
assert.Nil(t, mapped.Tuple.SubjectSet)
assert.Equal(t, strs[slices.Index(uuids, s.ID)], *mapped.Tuple.SubjectID)
case *relationtuple.SubjectSet:
require.NotNil(t, mapped.Tuple.SubjectSet)
assert.Nil(t, mapped.Tuple.SubjectID)
assert.Equal(t, s.Namespace, mapped.Tuple.SubjectSet.Namespace)
assert.Equal(t, strs[slices.Index(uuids, s.Object)], mapped.Tuple.SubjectSet.Object)
assert.Equal(t, s.Relation, mapped.Tuple.SubjectSet.Relation)
default:
t.Fatalf("expected subject to be set: %+v", mapped)
}
assert.Equal(t, original.Type, mapped.Type)
require.Len(t, mapped.Children, len(original.Children))
for i := range original.Children {
checkTree(mapped.Children[i], original.Children[i])
}
}
checkTree(mapped, tc.tree)
})
}
})
t.Run("suite=ro and rw mappers", func(t *testing.T) {
roMapper := reg.ReadOnlyMapper()
rwMapper := reg.Mapper()
t.Run("case=ro manager does not insert into DB", func(t *testing.T) {
unmapped := &ketoapi.RelationQuery{Object: new("unmapped object")}
mapped, err := roMapper.FromQuery(ctx, unmapped)
require.NoError(t, err)
assert.NotNil(t, mapped.Object)
reUnmapped, err := roMapper.ToQuery(ctx, mapped)
require.NoError(t, err)
assert.Empty(t, reUnmapped.Object)
})
t.Run("case=rw manager inserts into DB", func(t *testing.T) {
unmapped := &ketoapi.RelationQuery{Object: new("another unmapped object")}
mapped, err := rwMapper.FromQuery(ctx, unmapped)
require.NoError(t, err)
assert.NotNil(t, mapped.Object)
reUnmapped, err := rwMapper.ToQuery(ctx, mapped)
require.NoError(t, err)
assert.NotEmpty(t, reUnmapped.Object)
})
})
}
func BenchmarkReadOnlyMapper(b *testing.B) {
ctx := context.Background()
reg := driver.NewSqliteTestRegistry(b, driver.WithNamespaces([]*namespace.Namespace{{Name: "test"}}))
m := reg.ReadOnlyMapper()
b.Run("FromTuple", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := m.FromTuple(ctx, &ketoapi.RelationTuple{
Namespace: "test",
Object: "object",
Relation: "relation",
SubjectSet: &ketoapi.SubjectSet{
Namespace: "test",
Object: "subject object",
Relation: "relation",
},
})
assert.NoError(b, err)
}
})
}