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:
34
ketoapi/cmd_output.go
Normal file
34
ketoapi/cmd_output.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
func (r *RelationTuple) Header() []string {
|
||||
return []string{
|
||||
"NAMESPACE",
|
||||
"OBJECT ID",
|
||||
"RELATION NAME",
|
||||
"SUBJECT",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelationTuple) Columns() []string {
|
||||
sub := "<!error no subject>"
|
||||
switch {
|
||||
case r.SubjectID != nil:
|
||||
sub = *r.SubjectID
|
||||
case r.SubjectSet != nil:
|
||||
sub = r.SubjectSet.String()
|
||||
}
|
||||
|
||||
return []string{
|
||||
r.Namespace,
|
||||
r.Object,
|
||||
r.Relation,
|
||||
sub,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelationTuple) Interface() interface{} {
|
||||
return r
|
||||
}
|
||||
189
ketoapi/enc_proto.go
Normal file
189
ketoapi/enc_proto.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
type (
|
||||
TupleData interface {
|
||||
GetSubject() *rts.Subject
|
||||
GetObject() string
|
||||
GetNamespace() string
|
||||
GetRelation() string
|
||||
}
|
||||
queryData interface {
|
||||
GetSubject() *rts.Subject
|
||||
GetObject() *string
|
||||
GetNamespace() *string
|
||||
GetRelation() *string
|
||||
}
|
||||
)
|
||||
|
||||
func (r *RelationTuple) FromDataProvider(d TupleData) (*RelationTuple, error) {
|
||||
switch s := d.GetSubject().GetRef().(type) {
|
||||
case nil:
|
||||
return nil, errors.WithStack(ErrNilSubject())
|
||||
case *rts.Subject_Set:
|
||||
r.SubjectSet = &SubjectSet{
|
||||
Namespace: s.Set.Namespace,
|
||||
Object: s.Set.Object,
|
||||
Relation: s.Set.Relation,
|
||||
}
|
||||
case *rts.Subject_Id:
|
||||
r.SubjectID = new(s.Id)
|
||||
}
|
||||
|
||||
r.Object = d.GetObject()
|
||||
r.Namespace = d.GetNamespace()
|
||||
r.Relation = d.GetRelation()
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *RelationTuple) ToProto() *rts.RelationTuple {
|
||||
res := &rts.RelationTuple{
|
||||
Namespace: r.Namespace,
|
||||
Object: r.Object,
|
||||
Relation: r.Relation,
|
||||
}
|
||||
if r.SubjectID != nil {
|
||||
res.Subject = rts.NewSubjectID(*r.SubjectID)
|
||||
} else {
|
||||
res.Subject = rts.NewSubjectSet(r.SubjectSet.Namespace, r.SubjectSet.Object, r.SubjectSet.Relation)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *RelationTuple) FromProto(proto *rts.RelationTuple) *RelationTuple {
|
||||
r = &RelationTuple{
|
||||
Namespace: proto.Namespace,
|
||||
Object: proto.Object,
|
||||
Relation: proto.Relation,
|
||||
}
|
||||
switch subject := proto.Subject.Ref.(type) {
|
||||
case *rts.Subject_Id:
|
||||
r.SubjectID = new(subject.Id)
|
||||
case *rts.Subject_Set:
|
||||
r.SubjectSet = &SubjectSet{
|
||||
Namespace: subject.Set.Namespace,
|
||||
Object: subject.Set.Object,
|
||||
Relation: subject.Set.Relation,
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (q *RelationQuery) FromDataProvider(d queryData) *RelationQuery {
|
||||
q.Namespace = d.GetNamespace()
|
||||
q.Object = d.GetObject()
|
||||
q.Relation = d.GetRelation()
|
||||
q.SubjectID = nil
|
||||
q.SubjectSet = nil
|
||||
|
||||
if s := d.GetSubject(); s != nil {
|
||||
switch sub := s.Ref.(type) {
|
||||
case *rts.Subject_Id:
|
||||
q.SubjectID = new(sub.Id)
|
||||
case *rts.Subject_Set:
|
||||
q.SubjectSet = &SubjectSet{
|
||||
Namespace: sub.Set.Namespace,
|
||||
Object: sub.Set.Object,
|
||||
Relation: sub.Set.Relation,
|
||||
}
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RelationQuery) ToProto() *rts.RelationQuery {
|
||||
res := &rts.RelationQuery{
|
||||
Namespace: q.Namespace,
|
||||
Object: q.Object,
|
||||
Relation: q.Relation,
|
||||
}
|
||||
if q.SubjectID != nil {
|
||||
res.Subject = rts.NewSubjectID(*q.SubjectID)
|
||||
} else if q.SubjectSet != nil {
|
||||
res.Subject = rts.NewSubjectSet(q.SubjectSet.Namespace, q.SubjectSet.Object, q.SubjectSet.Relation)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (t *Tree[NodeT]) ToProto() *rts.SubjectTree {
|
||||
res := &rts.SubjectTree{
|
||||
NodeType: t.Type.ToProto(),
|
||||
Children: make([]*rts.SubjectTree, len(t.Children)),
|
||||
}
|
||||
res.Tuple = t.Tuple.ToProto()
|
||||
//lint:ignore SA1019 backwards compatibility
|
||||
//nolint:staticcheck
|
||||
res.Subject = res.Tuple.Subject
|
||||
for i := range t.Children {
|
||||
res.Children[i] = t.Children[i].ToProto()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TreeFromProto[T tuple[T]](pt *rts.SubjectTree) *Tree[T] {
|
||||
t := new(Tree[T])
|
||||
t.Type = TreeNodeType("").FromProto(pt.NodeType)
|
||||
|
||||
var tuple T
|
||||
if pt.Tuple == nil {
|
||||
// legacy case: fetch from deprecated fields
|
||||
//lint:ignore SA1019 backwards compatibility
|
||||
//nolint:staticcheck
|
||||
switch sub := pt.Subject.Ref.(type) {
|
||||
case *rts.Subject_Id:
|
||||
pt.Tuple.Subject = rts.NewSubjectID(sub.Id)
|
||||
case *rts.Subject_Set:
|
||||
pt.Tuple.Subject = rts.NewSubjectSet(
|
||||
sub.Set.Namespace,
|
||||
sub.Set.Object,
|
||||
sub.Set.Relation,
|
||||
)
|
||||
}
|
||||
}
|
||||
t.Tuple = tuple.FromProto(pt.Tuple)
|
||||
|
||||
t.Children = make([]*Tree[T], len(pt.Children))
|
||||
for i := range pt.Children {
|
||||
t.Children[i] = TreeFromProto[T](pt.Children[i])
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (t TreeNodeType) ToProto() rts.NodeType {
|
||||
switch t {
|
||||
case TreeNodeLeaf:
|
||||
return rts.NodeType_NODE_TYPE_LEAF
|
||||
case TreeNodeUnion:
|
||||
return rts.NodeType_NODE_TYPE_UNION
|
||||
case TreeNodeExclusion:
|
||||
return rts.NodeType_NODE_TYPE_EXCLUSION
|
||||
case TreeNodeIntersection:
|
||||
return rts.NodeType_NODE_TYPE_INTERSECTION
|
||||
}
|
||||
return rts.NodeType_NODE_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (TreeNodeType) FromProto(pt rts.NodeType) TreeNodeType {
|
||||
switch pt {
|
||||
case rts.NodeType_NODE_TYPE_LEAF:
|
||||
return TreeNodeLeaf
|
||||
case rts.NodeType_NODE_TYPE_UNION:
|
||||
return TreeNodeUnion
|
||||
case rts.NodeType_NODE_TYPE_EXCLUSION:
|
||||
return TreeNodeExclusion
|
||||
case rts.NodeType_NODE_TYPE_INTERSECTION:
|
||||
return TreeNodeIntersection
|
||||
}
|
||||
return TreeNodeUnspecified
|
||||
}
|
||||
153
ketoapi/enc_string.go
Normal file
153
ketoapi/enc_string.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func ErrMalformedInput() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError("malformed string input")
|
||||
}
|
||||
|
||||
func (r *RelationTuple) String() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString(r.Namespace)
|
||||
sb.WriteRune(':')
|
||||
sb.WriteString(r.Object)
|
||||
sb.WriteRune('#')
|
||||
sb.WriteString(r.Relation)
|
||||
sb.WriteRune('@')
|
||||
|
||||
if r.SubjectID != nil {
|
||||
sb.WriteString(*r.SubjectID)
|
||||
} else if r.SubjectSet != nil {
|
||||
sb.WriteString(r.SubjectSet.String())
|
||||
} else {
|
||||
sb.WriteString("<ERROR: no subject>")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *RelationTuple) FromString(s string) (*RelationTuple, error) {
|
||||
var (
|
||||
objectAndRelationAndSubject string
|
||||
relationAndSubject string
|
||||
subject string
|
||||
ok bool
|
||||
)
|
||||
if r.Namespace, objectAndRelationAndSubject, ok = strings.Cut(s, ":"); !ok {
|
||||
return nil, errors.WithStack(ErrMalformedInput().WithDebug("expected input to contain ':'"))
|
||||
}
|
||||
|
||||
if r.Object, relationAndSubject, ok = strings.Cut(objectAndRelationAndSubject, "#"); !ok {
|
||||
return nil, errors.WithStack(ErrMalformedInput().WithDebug("expected input to contain '#'"))
|
||||
}
|
||||
|
||||
if r.Relation, subject, ok = strings.Cut(relationAndSubject, "@"); !ok {
|
||||
return nil, errors.WithStack(ErrMalformedInput().WithDebug("expected input to contain '@'"))
|
||||
}
|
||||
|
||||
// remove optional brackets around the subject set
|
||||
subject = strings.Trim(subject, "()")
|
||||
if strings.Contains(subject, ":") {
|
||||
subSet, err := (&SubjectSet{}).FromString(subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.SubjectSet = subSet
|
||||
} else {
|
||||
r.SubjectID = &subject
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *SubjectSet) String() string {
|
||||
if s.Relation == "" {
|
||||
return fmt.Sprintf("%s:%s", s.Namespace, s.Object)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s#%s", s.Namespace, s.Object, s.Relation)
|
||||
}
|
||||
|
||||
func (s *SubjectSet) FromString(str string) (*SubjectSet, error) {
|
||||
// If there is no '#' we have a subject set without a relation, such as
|
||||
// Users:Bob, which just means that the relation is empty.
|
||||
namespaceAndObject, relation, _ := strings.Cut(str, "#")
|
||||
|
||||
namespace, object, ok := strings.Cut(namespaceAndObject, ":")
|
||||
if !ok {
|
||||
return nil, errors.WithStack(ErrMalformedInput().WithDebug("expected subject set to contain ':'"))
|
||||
}
|
||||
|
||||
return &SubjectSet{
|
||||
Namespace: namespace,
|
||||
Object: object,
|
||||
Relation: relation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t TreeNodeType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
func (t *Tree[NodeT]) Label() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.Tuple.String()
|
||||
}
|
||||
|
||||
func (t *Tree[NodeT]) String() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
nodeLabel := t.Label()
|
||||
|
||||
if t.Type == TreeNodeLeaf {
|
||||
return fmt.Sprintf("∋ %s️", nodeLabel)
|
||||
}
|
||||
|
||||
children := make([]string, len(t.Children))
|
||||
for i, c := range t.Children {
|
||||
var indent string
|
||||
if i == len(t.Children)-1 {
|
||||
indent = " "
|
||||
} else {
|
||||
indent = "│ "
|
||||
}
|
||||
children[i] = strings.Join(strings.Split(c.String(), "\n"), "\n"+indent)
|
||||
}
|
||||
|
||||
setOperation := ""
|
||||
switch t.Type {
|
||||
case TreeNodeIntersection:
|
||||
setOperation = "and"
|
||||
case TreeNodeUnion:
|
||||
setOperation = "or"
|
||||
case TreeNodeExclusion:
|
||||
setOperation = `\`
|
||||
case TreeNodeNot:
|
||||
setOperation = `not`
|
||||
case TreeNodeTupleToSubjectSet:
|
||||
setOperation = "┐ tuple to userset"
|
||||
case TreeNodeComputedSubjectSet:
|
||||
setOperation = "┐ computed userset"
|
||||
}
|
||||
|
||||
boxSymbol := "├"
|
||||
if len(children) == 1 {
|
||||
boxSymbol = "└"
|
||||
}
|
||||
return fmt.Sprintf("%s %s\n%s──%s", setOperation, nodeLabel, boxSymbol, strings.Join(children, "\n└──"))
|
||||
}
|
||||
413
ketoapi/enc_test.go
Normal file
413
ketoapi/enc_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
func TestRelationTuple(t *testing.T) {
|
||||
t.Run("method=string encoding", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
tuple *RelationTuple
|
||||
expected string
|
||||
}{
|
||||
{ // full tuple
|
||||
tuple: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
expected: "n:o#r@s",
|
||||
},
|
||||
{ // skip '#' in subject set when relation is empty
|
||||
tuple: &RelationTuple{
|
||||
Namespace: "groups",
|
||||
Object: "dev",
|
||||
Relation: "member",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "users",
|
||||
Object: "user",
|
||||
},
|
||||
},
|
||||
expected: "groups:dev#member@users:user",
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.expected, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.tuple.String())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=string decoding", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
enc string
|
||||
err error
|
||||
expected *RelationTuple
|
||||
}{
|
||||
{
|
||||
enc: "n:o#r@s",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "n:o#r@n:o#r",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "n:o#r@n:o#...",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "...",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "n:o#r@n:o#",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "n:o#r@n:o",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "n:o#r@(n:o#r)",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "#dev:@ory#:working:@projects:keto#awesome",
|
||||
expected: &RelationTuple{
|
||||
Namespace: "#dev",
|
||||
Object: "@ory",
|
||||
Relation: ":working:",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "projects",
|
||||
Object: "keto",
|
||||
Relation: "awesome",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enc: "no-colon#in@this",
|
||||
err: ErrMalformedInput(),
|
||||
},
|
||||
{
|
||||
enc: "no:hash-in@this",
|
||||
err: ErrMalformedInput(),
|
||||
},
|
||||
{
|
||||
enc: "no:at#in-this",
|
||||
err: ErrMalformedInput(),
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("string=%s", tc.enc), func(t *testing.T) {
|
||||
actual, err := (&RelationTuple{}).FromString(tc.enc)
|
||||
assert.True(t, errors.Is(err, tc.err), "%+v", err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=url encoding-decoding", func(t *testing.T) {
|
||||
for i, r := range []*RelationTuple{
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
},
|
||||
},
|
||||
{
|
||||
SubjectID: new(""),
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
vals := r.ToURLQuery()
|
||||
res, err := (&RelationTuple{}).FromURLQuery(vals)
|
||||
require.NoError(t, err, "raw: %+v, enc: %+v", r, vals)
|
||||
assert.Equal(t, r, res)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=url decoding-encoding", func(t *testing.T) {
|
||||
for i, v := range []url.Values{
|
||||
{
|
||||
"namespace": []string{"n"},
|
||||
"object": []string{"o"},
|
||||
"relation": []string{"r"},
|
||||
"subject_id": []string{"foo"},
|
||||
},
|
||||
{
|
||||
"namespace": []string{"n"},
|
||||
"object": []string{"o"},
|
||||
"relation": []string{"r"},
|
||||
"subject_set.namespace": []string{"sn"},
|
||||
"subject_set.object": []string{"so"},
|
||||
"subject_set.relation": []string{"sr"},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
rt, err := (&RelationTuple{}).FromURLQuery(v)
|
||||
require.NoError(t, err)
|
||||
q := rt.ToURLQuery()
|
||||
assert.Equal(t, v, q)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=proto decoding", func(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
proto TupleData
|
||||
expected *RelationTuple
|
||||
err error
|
||||
}{
|
||||
{
|
||||
proto: &rts.RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
Subject: nil,
|
||||
},
|
||||
err: ErrNilSubject(),
|
||||
},
|
||||
{
|
||||
proto: &rts.RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
Subject: &rts.Subject{
|
||||
Ref: &rts.Subject_Set{
|
||||
Set: &rts.SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
proto: &rts.RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
Subject: &rts.Subject{
|
||||
Ref: &rts.Subject_Id{
|
||||
Id: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("user"),
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
actual, err := (&RelationTuple{}).FromDataProvider(tc.proto)
|
||||
require.ErrorIs(t, err, tc.err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("format=JSON", func(t *testing.T) {
|
||||
t.Run("direction=encoding-decoding", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rt *RelationTuple
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with subject ID",
|
||||
rt: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
expected: `
|
||||
{
|
||||
"namespace": "n",
|
||||
"object": "o",
|
||||
"relation": "r",
|
||||
"subject_id": "s"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "with subject set",
|
||||
rt: &RelationTuple{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
},
|
||||
},
|
||||
expected: `
|
||||
{
|
||||
"namespace": "n",
|
||||
"object": "o",
|
||||
"relation": "r",
|
||||
"subject_set": {
|
||||
"namespace": "sn",
|
||||
"object": "so",
|
||||
"relation": "sr"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(tc.rt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tc.expected, string(raw))
|
||||
|
||||
var dec RelationTuple
|
||||
require.NoError(t, json.Unmarshal(raw, &dec))
|
||||
assert.Equal(t, tc.rt, &dec)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelationQuery(t *testing.T) {
|
||||
t.Run("case=url encoding-decoding-encoding", func(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
v url.Values
|
||||
r *RelationQuery
|
||||
}{
|
||||
{
|
||||
v: url.Values{
|
||||
"namespace": []string{"n"},
|
||||
"object": []string{"o"},
|
||||
"relation": []string{"r"},
|
||||
"subject_id": []string{"foo"},
|
||||
},
|
||||
r: &RelationQuery{
|
||||
Namespace: new("n"),
|
||||
Object: new("o"),
|
||||
Relation: new("r"),
|
||||
SubjectID: new("foo"),
|
||||
},
|
||||
},
|
||||
{
|
||||
v: url.Values{
|
||||
"namespace": []string{"n"},
|
||||
"object": []string{"o"},
|
||||
"relation": []string{"r"},
|
||||
"subject_set.namespace": []string{"sn"},
|
||||
"subject_set.object": []string{"so"},
|
||||
"subject_set.relation": []string{"sr"},
|
||||
},
|
||||
r: &RelationQuery{
|
||||
Namespace: new("n"),
|
||||
Object: new("o"),
|
||||
Relation: new("r"),
|
||||
SubjectSet: &SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: url.Values{
|
||||
"namespace": []string{"n"},
|
||||
"relation": []string{"r"},
|
||||
},
|
||||
r: &RelationQuery{
|
||||
Namespace: new("n"),
|
||||
Relation: new("r"),
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
enc := tc.r.ToURLQuery()
|
||||
assert.Equal(t, tc.v, enc)
|
||||
|
||||
dec, err := (&RelationQuery{}).FromURLQuery(tc.v)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.r, dec)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
127
ketoapi/enc_url_query.go
Normal file
127
ketoapi/enc_url_query.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (q *RelationQuery) FromURLQuery(query url.Values) (*RelationQuery, error) {
|
||||
if q == nil {
|
||||
q = &RelationQuery{}
|
||||
}
|
||||
|
||||
if query.Has("subject") {
|
||||
return nil, ErrDroppedSubjectKey()
|
||||
}
|
||||
|
||||
// reset subject
|
||||
q.SubjectID = nil
|
||||
q.SubjectSet = nil
|
||||
|
||||
switch {
|
||||
case !query.Has(SubjectIDKey) && !query.Has(SubjectSetNamespaceKey) && !query.Has(SubjectSetObjectKey) && !query.Has(SubjectSetRelationKey):
|
||||
// was not queried for the subject
|
||||
case query.Has(SubjectIDKey) && (query.Has(SubjectSetNamespaceKey) || query.Has(SubjectSetObjectKey) || query.Has(SubjectSetRelationKey)):
|
||||
return nil, ErrDuplicateSubject().WithDebugf("please provide either %s or all of %s, %s, and %s", SubjectIDKey, SubjectSetNamespaceKey, SubjectSetObjectKey, SubjectSetRelationKey)
|
||||
case query.Has(SubjectIDKey):
|
||||
q.SubjectID = new(query.Get(SubjectIDKey))
|
||||
case query.Has(SubjectSetNamespaceKey) && query.Has(SubjectSetObjectKey) && query.Has(SubjectSetRelationKey):
|
||||
q.SubjectSet = &SubjectSet{
|
||||
Namespace: query.Get(SubjectSetNamespaceKey),
|
||||
Object: query.Get(SubjectSetObjectKey),
|
||||
Relation: query.Get(SubjectSetRelationKey),
|
||||
}
|
||||
default:
|
||||
return nil, ErrIncompleteSubject()
|
||||
}
|
||||
|
||||
if query.Has(NamespaceKey) {
|
||||
q.Namespace = new(query.Get(NamespaceKey))
|
||||
}
|
||||
if query.Has(ObjectKey) {
|
||||
q.Object = new(query.Get(ObjectKey))
|
||||
}
|
||||
if query.Has(RelationKey) {
|
||||
q.Relation = new(query.Get(RelationKey))
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (q *RelationQuery) ToURLQuery() url.Values {
|
||||
v := make(url.Values, 7)
|
||||
|
||||
if q.Namespace != nil {
|
||||
v.Add(NamespaceKey, *q.Namespace)
|
||||
}
|
||||
if q.Relation != nil {
|
||||
v.Add(RelationKey, *q.Relation)
|
||||
}
|
||||
if q.Object != nil {
|
||||
v.Add(ObjectKey, *q.Object)
|
||||
}
|
||||
if q.SubjectID != nil {
|
||||
v.Add(SubjectIDKey, *q.SubjectID)
|
||||
} else if q.SubjectSet != nil {
|
||||
v.Add(SubjectSetNamespaceKey, q.SubjectSet.Namespace)
|
||||
v.Add(SubjectSetObjectKey, q.SubjectSet.Object)
|
||||
v.Add(SubjectSetRelationKey, q.SubjectSet.Relation)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *RelationTuple) FromURLQuery(query url.Values) (*RelationTuple, error) {
|
||||
q, err := (&RelationQuery{}).FromURLQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.SubjectID == nil && q.SubjectSet == nil {
|
||||
return nil, errors.WithStack(ErrNilSubject())
|
||||
}
|
||||
if q.Namespace == nil || q.Object == nil || q.Relation == nil {
|
||||
return nil, errors.WithStack(ErrIncompleteTuple())
|
||||
}
|
||||
|
||||
r.Namespace = *q.Namespace
|
||||
r.Object = *q.Object
|
||||
r.Relation = *q.Relation
|
||||
r.SubjectID = q.SubjectID
|
||||
r.SubjectSet = q.SubjectSet
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *RelationTuple) ToURLQuery() url.Values {
|
||||
return (&RelationQuery{
|
||||
Namespace: &r.Namespace,
|
||||
Object: &r.Object,
|
||||
Relation: &r.Relation,
|
||||
SubjectID: r.SubjectID,
|
||||
SubjectSet: r.SubjectSet,
|
||||
}).ToURLQuery()
|
||||
}
|
||||
|
||||
func (s *SubjectSet) FromURLQuery(values url.Values) *SubjectSet {
|
||||
if s == nil {
|
||||
s = &SubjectSet{}
|
||||
}
|
||||
|
||||
s.Namespace = values.Get(NamespaceKey)
|
||||
s.Relation = values.Get(RelationKey)
|
||||
s.Object = values.Get(ObjectKey)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SubjectSet) ToURLQuery() url.Values {
|
||||
return url.Values{
|
||||
NamespaceKey: []string{s.Namespace},
|
||||
ObjectKey: []string{s.Object},
|
||||
RelationKey: []string{s.Relation},
|
||||
}
|
||||
}
|
||||
282
ketoapi/public_api_definitions.go
Normal file
282
ketoapi/public_api_definitions.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ketoapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
var ErrUnknownNodeType = errors.New("unknown node type")
|
||||
|
||||
func ErrDroppedSubjectKey() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithDebug(`provide "subject_id" or "subject_set.*"; support for "subject" was dropped`)
|
||||
}
|
||||
|
||||
func ErrDuplicateSubject() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError("exactly one of subject_set or subject_id has to be provided")
|
||||
}
|
||||
|
||||
func ErrIncompleteSubject() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError(`incomplete subject, provide "subject_id" or a complete "subject_set.*"`)
|
||||
}
|
||||
|
||||
func ErrNilSubject() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError("subject is not allowed to be nil").WithDebug("Please provide a subject.")
|
||||
}
|
||||
|
||||
func ErrIncompleteTuple() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError(`incomplete tuple, provide "namespace", "object", "relation", and a subject`)
|
||||
}
|
||||
|
||||
// swagger:model namespace
|
||||
type Namespace struct {
|
||||
// Name of the namespace.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Relationship
|
||||
//
|
||||
// swagger:model relationship
|
||||
type RelationTuple struct {
|
||||
// Namespace of the Relation Tuple
|
||||
//
|
||||
// required: true
|
||||
Namespace string `json:"namespace"`
|
||||
|
||||
// Object of the Relation Tuple
|
||||
//
|
||||
// required: true
|
||||
Object string `json:"object"`
|
||||
|
||||
// Relation of the Relation Tuple
|
||||
//
|
||||
// required: true
|
||||
Relation string `json:"relation"`
|
||||
|
||||
// SubjectID of the Relation Tuple
|
||||
//
|
||||
// Either SubjectSet or SubjectID can be provided.
|
||||
SubjectID *string `json:"subject_id,omitempty"`
|
||||
|
||||
// SubjectSet of the Relation Tuple
|
||||
//
|
||||
// Either SubjectSet or SubjectID can be provided.
|
||||
//
|
||||
// swagger:allOf
|
||||
SubjectSet *SubjectSet `json:"subject_set,omitempty"`
|
||||
}
|
||||
|
||||
// swagger:model subjectSet
|
||||
type SubjectSet struct {
|
||||
// Namespace of the Subject Set
|
||||
//
|
||||
// required: true
|
||||
Namespace string `json:"namespace"`
|
||||
|
||||
// Object of the Subject Set
|
||||
//
|
||||
// required: true
|
||||
Object string `json:"object"`
|
||||
|
||||
// Relation of the Subject Set
|
||||
//
|
||||
// required: true
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
|
||||
// Relation Query
|
||||
//
|
||||
// swagger:model relationQuery
|
||||
type RelationQuery struct {
|
||||
// Namespace to query
|
||||
Namespace *string `json:"namespace"`
|
||||
|
||||
// Object to query
|
||||
Object *string `json:"object"`
|
||||
|
||||
// Relation to query
|
||||
Relation *string `json:"relation"`
|
||||
|
||||
// SubjectID to query
|
||||
//
|
||||
// Either SubjectSet or SubjectID can be provided.
|
||||
SubjectID *string `json:"subject_id,omitempty"`
|
||||
// SubjectSet to query
|
||||
//
|
||||
// Either SubjectSet or SubjectID can be provided.
|
||||
//
|
||||
// swagger:allOf
|
||||
SubjectSet *SubjectSet `json:"subject_set,omitempty"`
|
||||
}
|
||||
|
||||
// Payload for patching a relationship
|
||||
//
|
||||
// swagger:model relationshipPatch
|
||||
type PatchDelta struct {
|
||||
Action PatchAction `json:"action"`
|
||||
RelationTuple *RelationTuple `json:"relation_tuple"`
|
||||
}
|
||||
|
||||
// swagger:enum PatchAction
|
||||
type PatchAction string
|
||||
|
||||
const (
|
||||
ActionInsert PatchAction = "insert"
|
||||
ActionDelete PatchAction = "delete"
|
||||
)
|
||||
|
||||
const (
|
||||
NamespaceKey = "namespace"
|
||||
ObjectKey = "object"
|
||||
RelationKey = "relation"
|
||||
SubjectIDKey = "subject_id"
|
||||
SubjectSetNamespaceKey = "subject_set.namespace"
|
||||
SubjectSetObjectKey = "subject_set.object"
|
||||
SubjectSetRelationKey = "subject_set.relation"
|
||||
)
|
||||
|
||||
var RelationQueryKeys = []string{
|
||||
NamespaceKey,
|
||||
ObjectKey,
|
||||
RelationKey,
|
||||
SubjectIDKey,
|
||||
SubjectSetNamespaceKey,
|
||||
SubjectSetObjectKey,
|
||||
SubjectSetRelationKey,
|
||||
"subject", // We have a more specific error message for this key.
|
||||
}
|
||||
|
||||
// Paginated Relationship List
|
||||
//
|
||||
// swagger:model relationships
|
||||
type GetResponse struct {
|
||||
RelationTuples []*RelationTuple `json:"relation_tuples"`
|
||||
// The opaque token to provide in a subsequent request
|
||||
// to get the next page. It is the empty string iff this is
|
||||
// the last page.
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
// Relationship Namespace List
|
||||
//
|
||||
// swagger:model relationshipNamespaces
|
||||
type GetNamespacesResponse struct {
|
||||
Namespaces []Namespace `json:"namespaces"`
|
||||
}
|
||||
|
||||
func (r *RelationTuple) ToLoggerFields() logrus.Fields {
|
||||
fields := make(logrus.Fields, 7)
|
||||
q := r.ToURLQuery()
|
||||
for k := range q {
|
||||
fields[k] = q.Get(k)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (r *RelationTuple) Validate() error {
|
||||
if r.SubjectSet == nil && r.SubjectID == nil {
|
||||
return errors.WithStack(ErrNilSubject())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// swagger:enum ExpandNodeType
|
||||
type ExpandNodeType TreeNodeType
|
||||
|
||||
// swagger:enum TreeNodeType
|
||||
type TreeNodeType string
|
||||
|
||||
const (
|
||||
TreeNodeUnion TreeNodeType = "union"
|
||||
TreeNodeExclusion TreeNodeType = "exclusion"
|
||||
TreeNodeIntersection TreeNodeType = "intersection"
|
||||
TreeNodeLeaf TreeNodeType = "leaf"
|
||||
TreeNodeTupleToSubjectSet TreeNodeType = "tuple_to_subject_set"
|
||||
TreeNodeComputedSubjectSet TreeNodeType = "computed_subject_set"
|
||||
TreeNodeNot TreeNodeType = "not"
|
||||
TreeNodeUnspecified TreeNodeType = "unspecified"
|
||||
)
|
||||
|
||||
func (t *TreeNodeType) UnmarshalJSON(v []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
switch nt := TreeNodeType(s); nt {
|
||||
case TreeNodeUnion, TreeNodeExclusion, TreeNodeIntersection, TreeNodeLeaf, TreeNodeTupleToSubjectSet, TreeNodeComputedSubjectSet, TreeNodeNot, TreeNodeUnspecified:
|
||||
*t = nt
|
||||
default:
|
||||
return ErrUnknownNodeType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tuple[T any] interface {
|
||||
fmt.Stringer
|
||||
ToProto() *rts.RelationTuple
|
||||
FromProto(*rts.RelationTuple) T
|
||||
}
|
||||
|
||||
// Tree is a generic tree of either internal relationships (with UUIDs for
|
||||
// objects, etc.) or API relationships (with strings for objects, etc.).
|
||||
type Tree[T tuple[T]] struct {
|
||||
// Propagate all struct changes to `swaggerOnlyExpandTree` as well.
|
||||
// The type of the node.
|
||||
//
|
||||
// required: true
|
||||
Type TreeNodeType `json:"type"`
|
||||
|
||||
// The children of the node, possibly none.
|
||||
Children []*Tree[T] `json:"children,omitempty"`
|
||||
|
||||
// The relation tuple the node represents.
|
||||
Tuple T `json:"tuple"`
|
||||
}
|
||||
|
||||
// IMPORTANT: We need a manual instantiation of the generic Tree[T] for the
|
||||
// OpenAPI spec, since go-swagger does not understand generics :(.
|
||||
// This can be fixed by using grpc-gateway.
|
||||
|
||||
// swagger:model expandedPermissionTree
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type swaggerOnlyExpandTree struct {
|
||||
// The type of the node.
|
||||
//
|
||||
// required: true
|
||||
Type TreeNodeType `json:"type"`
|
||||
|
||||
// The children of the node, possibly none.
|
||||
Children []*swaggerOnlyExpandTree `json:"children,omitempty"`
|
||||
|
||||
// The relation tuple the node represents.
|
||||
Tuple *RelationTuple `json:"tuple"`
|
||||
}
|
||||
|
||||
type ParseError struct {
|
||||
Message string `json:"message"`
|
||||
Start SourcePosition `json:"start"`
|
||||
End SourcePosition `json:"end"`
|
||||
}
|
||||
type SourcePosition struct {
|
||||
Line int `json:"Line"`
|
||||
Col int `json:"column"`
|
||||
}
|
||||
|
||||
// CheckOPLSyntaxResponse represents the response for an OPL syntax check request.
|
||||
//
|
||||
// swagger:model checkOplSyntaxResult
|
||||
type CheckOPLSyntaxResponse struct {
|
||||
// The list of syntax errors
|
||||
//
|
||||
// required: false
|
||||
Errors []*ParseError `json:"errors,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user