Skip to content

Commit

Permalink
feat: add check engine (#277)
Browse files Browse the repository at this point in the history
  • Loading branch information
zepatrik authored Nov 6, 2020
1 parent 1316077 commit 396c1ae
Show file tree
Hide file tree
Showing 11 changed files with 520 additions and 39 deletions.
91 changes: 91 additions & 0 deletions check/engine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package check

import (
"context"
"fmt"
"os"

"github.com/ory/keto/models"
"github.com/ory/keto/relationtuple"
)

type (
EngineProvider interface {
PermissionEngine() *Engine
}
Engine struct {
d engineDependencies
}
engineDependencies interface {
relationtuple.ManagerProvider
}
)

func NewEngine(d engineDependencies) *Engine {
return &Engine{
d: d,
}
}

func equalRelation(a, b *models.InternalRelationTuple) bool {
return a.Relation == b.Relation && a.Subject.Equals(b.Subject) && a.Object.Equals(b.Object)
}

func (e *Engine) subjectIsAllowed(ctx context.Context, requested *models.InternalRelationTuple, subjectRelations []*models.InternalRelationTuple) (bool, error) {
// This is the same as the graph problem "can requested.ObjectID be reached from requested.SubjectID through the incoming edge requested.Name"
//
// recursive breadth-first search
// TODO replace by more performant algorithm

var res bool
for _, sr := range subjectRelations {

// we don't have to check SubjectID here as we know that sr was reached from requested.SubjectID through 0...n indirections
if requested.Relation == sr.Relation && requested.Object.Equals(sr.Object) {
// found the requested relation
return true, nil
}

prevRelationsLen := len(subjectRelations)

// compute one indirection
indirect, err := e.d.RelationTupleManager().GetRelationTuples(ctx, []*models.RelationQuery{{Subject: sr.DeriveSubject()}})
if err != nil {
// TODO fix error handling
_, _ = fmt.Fprintf(os.Stderr, "%+v", err)
}

for _, maybeRel := range indirect {
var found bool
for _, knownRel := range subjectRelations {
if equalRelation(knownRel, maybeRel) {
found = true
break
}
}
if !found {
subjectRelations = append(subjectRelations, maybeRel)
}
}

if prevRelationsLen < len(subjectRelations) {
is, err := e.subjectIsAllowed(ctx, requested, subjectRelations)
if err != nil {
// TODO fix error handling
_, _ = fmt.Fprintf(os.Stderr, "%+v", err)
}
res = res || is
}
}

return res, nil
}

func (e *Engine) SubjectIsAllowed(ctx context.Context, r *models.InternalRelationTuple) (bool, error) {
subjectRelations, err := e.d.RelationTupleManager().GetRelationTuples(ctx, []*models.RelationQuery{{Subject: r.Subject}})
if err != nil {
return false, err
}

return e.subjectIsAllowed(ctx, r, subjectRelations)
}
268 changes: 268 additions & 0 deletions check/engine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package check_test

import (
"context"
"testing"

"github.com/ory/keto/check"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ory/keto/driver"
"github.com/ory/keto/models"
)

func TestEngine(t *testing.T) {
t.Run("direct inclusion", func(t *testing.T) {
rel := models.InternalRelationTuple{
Relation: "access",
Object: &models.Object{
ID: "object",
Namespace: "test",
},
Subject: &models.UserID{ID: "user"},
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &rel))

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &rel)
require.NoError(t, err)
assert.True(t, res)
})

t.Run("indirect inclusion level 1", func(t *testing.T) {
// the set of users that are produces of "dust" have to remove it
dust := models.Object{
ID: "dust",
Namespace: "under the sofa",
}
mark := models.UserID{
ID: "Mark",
}
cleaningRelation := models.InternalRelationTuple{
Relation: "have to remove",
Object: &dust,
Subject: &models.UserSet{
Relation: "producer",
Object: &dust,
},
}
markProducesDust := models.InternalRelationTuple{
Relation: "producer",
Object: &dust,
Subject: &mark,
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &cleaningRelation, &markProducesDust))

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: cleaningRelation.Relation,
Object: &dust,
Subject: &mark,
})
require.NoError(t, err)
assert.True(t, res)
})

t.Run("direct exclusion", func(t *testing.T) {
user := &models.UserID{
ID: "user-id",
}
rel := models.InternalRelationTuple{
Relation: "relation",
Object: &models.Object{
ID: "object-id",
Namespace: "object-namespace",
},
Subject: user,
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &rel))

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: rel.Relation,
Object: rel.Object,
Subject: &models.UserID{ID: "not " + user.ID},
})
require.NoError(t, err)
assert.False(t, res)
})

t.Run("wrong object ID", func(t *testing.T) {
object := models.Object{
ID: "object",
}
access := models.InternalRelationTuple{
Relation: "access",
Object: &object,
Subject: &models.UserSet{
Relation: "owner",
Object: &object,
},
}
user := models.InternalRelationTuple{
Relation: "owner",
Object: &models.Object{ID: "not " + object.ID},
Subject: &models.UserID{ID: "user"},
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &access, &user))

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: access.Relation,
Object: &object,
Subject: user.Subject,
})
require.NoError(t, err)
assert.False(t, res)
})

t.Run("wrong relation name", func(t *testing.T) {
diaryEntry := &models.Object{
ID: "entry for 6. Nov 2020",
Namespace: "diary",
}
// this would be a userset rewrite
readDiary := models.InternalRelationTuple{
Relation: "read",
Object: diaryEntry,
Subject: &models.UserSet{
Relation: "author",
Object: diaryEntry,
},
}
user := models.InternalRelationTuple{
Relation: "not author",
Object: diaryEntry,
Subject: &models.UserID{ID: "your mother"},
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &readDiary, &user))

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: readDiary.Relation,
Object: diaryEntry,
Subject: user.Subject,
})
require.NoError(t, err)
assert.False(t, res)
})

t.Run("indirect inclusion level 2", func(t *testing.T) {
object := models.Object{
ID: "some object",
Namespace: "some namespace",
}
user := models.UserID{
ID: "some user",
}
organization := models.Object{
ID: "some organization",
Namespace: "all organizations",
}

ownerUserSet := models.UserSet{
Relation: "owner",
Object: &object,
}
orgMembers := models.UserSet{
Relation: "member",
Object: &organization,
}

writeRel := models.InternalRelationTuple{
Relation: "write",
Object: &object,
Subject: &ownerUserSet,
}
orgOwnerRel := models.InternalRelationTuple{
Relation: ownerUserSet.Relation,
Object: &object,
Subject: &orgMembers,
}
userMembershipRel := models.InternalRelationTuple{
Relation: orgMembers.Relation,
Object: orgMembers.Object,
Subject: &user,
}

reg := &driver.RegistryDefault{}
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &writeRel, &orgOwnerRel, &userMembershipRel))

e := check.NewEngine(reg)

// user can write object
res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: writeRel.Relation,
Object: &object,
Subject: &user,
})
require.NoError(t, err)
assert.True(t, res)

// user is member of the organization
res, err = e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: orgMembers.Relation,
Object: &organization,
Subject: &user,
})
require.NoError(t, err)
assert.True(t, res)
})

t.Run("rejects transitive relation", func(t *testing.T) {
// (file) <--parent-- (directory) <--access-- [user]
//
// note the missing access relation from "users who have access to directory also have access to files inside of the directory"
// as we don't know how to interpret the "parent" relation, there would have to be a userset rewrite to allow access
// to files when you have access to the parent

file := models.Object{ID: "file"}
directory := models.Object{ID: "directory"}
user := models.UserID{ID: "user"}

parent := models.InternalRelationTuple{
Relation: "parent",
Object: &file,
Subject: &models.UserSet{ // <- this is only an object, but this is allowed as a userset can have the "..." relation which means any relation
Object: &directory,
},
}
directoryAccess := models.InternalRelationTuple{
Relation: "access",
Object: &directory,
Subject: &user,
}

reg := &driver.RegistryDefault{}
for _, r := range []*models.InternalRelationTuple{&parent, &directoryAccess} {
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), r))
}

e := check.NewEngine(reg)

res, err := e.SubjectIsAllowed(context.Background(), &models.InternalRelationTuple{
Relation: directoryAccess.Relation,
Object: &file,
Subject: &user,
})
require.NoError(t, err)
assert.False(t, res)
})
}
Loading

0 comments on commit 396c1ae

Please sign in to comment.