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
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:
274
internal/relationtuple/definitions.go
Normal file
274
internal/relationtuple/definitions.go
Normal 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
|
||||
}
|
||||
94
internal/relationtuple/definitions_test.go
Normal file
94
internal/relationtuple/definitions_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
98
internal/relationtuple/handler.go
Normal file
98
internal/relationtuple/handler.go
Normal 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,
|
||||
}
|
||||
}
|
||||
175
internal/relationtuple/read_server.go
Normal file
175
internal/relationtuple/read_server.go
Normal 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)
|
||||
}
|
||||
345
internal/relationtuple/read_server_test.go
Normal file
345
internal/relationtuple/read_server_test.go
Normal 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...)...))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
115
internal/relationtuple/swagger_definitions.go
Normal file
115
internal/relationtuple/swagger_definitions.go
Normal 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"`
|
||||
}
|
||||
803
internal/relationtuple/test/manager.go
Normal file
803
internal/relationtuple/test/manager.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
53
internal/relationtuple/test/uuid_mapping.go
Normal file
53
internal/relationtuple/test/uuid_mapping.go
Normal 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)
|
||||
})
|
||||
}
|
||||
320
internal/relationtuple/transact_server.go
Normal file
320
internal/relationtuple/transact_server.go
Normal 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)
|
||||
}
|
||||
486
internal/relationtuple/transact_server_test.go
Normal file
486
internal/relationtuple/transact_server_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
}
|
||||
397
internal/relationtuple/uuid_mapping.go
Normal file
397
internal/relationtuple/uuid_mapping.go
Normal 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
|
||||
}
|
||||
402
internal/relationtuple/uuid_mapping_test.go
Normal file
402
internal/relationtuple/uuid_mapping_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user