diff --git a/check/engine.go b/check/engine.go new file mode 100644 index 000000000..1b6c07c52 --- /dev/null +++ b/check/engine.go @@ -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) +} diff --git a/check/engine_test.go b/check/engine_test.go new file mode 100644 index 000000000..23bf174cb --- /dev/null +++ b/check/engine_test.go @@ -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) + }) +} diff --git a/check/handler.go b/check/handler.go new file mode 100644 index 000000000..e9fe7c3b3 --- /dev/null +++ b/check/handler.go @@ -0,0 +1,54 @@ +package check + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + + "github.com/ory/keto/models" + "github.com/ory/keto/x" +) + +type ( + handlerDependencies interface { + EngineProvider + x.LoggerProvider + x.WriterProvider + } + handler struct { + d handlerDependencies + } +) + +const routeBase = "/check" + +func NewHandler(d handlerDependencies) *handler { + return &handler{d: d} +} + +func (h *handler) RegisterPublicRoutes(router *httprouter.Router) { + router.GET(routeBase, h.getCheck) +} + +func (h *handler) getCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + subjectID := r.URL.Query().Get("subject-id") + objectID := r.URL.Query().Get("object-id") + relationName := r.URL.Query().Get("relation-name") + + res, err := h.d.PermissionEngine().SubjectIsAllowed(r.Context(), &models.InternalRelationTuple{ + Relation: relationName, + Object: (&models.Object{}).FromString(objectID), + Subject: models.SubjectFromString(subjectID), + }) + if err != nil { + h.d.Writer().WriteError(w, r, err) + return + } + + if res { + h.d.Writer().WriteCode(w, r, http.StatusOK, "allowed") + return + } + + h.d.Writer().WriteCode(w, r, http.StatusForbidden, "rejected") +} diff --git a/cmd/serve.go b/cmd/serve.go index d354ff8f9..fe900e407 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -21,6 +21,8 @@ import ( "os" "sync" + "github.com/ory/keto/check" + "github.com/julienschmidt/httprouter" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -77,8 +79,10 @@ on configuration options, open the configuration documentation: defer wg.Done() router := httprouter.New() - h := relationtuple.NewHandler(reg) - h.RegisterPublicRoutes(router) + rh := relationtuple.NewHandler(reg) + rh.RegisterPublicRoutes(router) + ch := check.NewHandler(reg) + ch.RegisterPublicRoutes(router) server := graceful.WithDefaults(&http.Server{ Addr: ":4466", diff --git a/driver/registry_default.go b/driver/registry_default.go index 167241f31..4549e6695 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -4,6 +4,8 @@ import ( "github.com/ory/herodot" "github.com/ory/x/logrusx" + "github.com/ory/keto/check" + "github.com/ory/keto/persistence/memory" "github.com/ory/keto/relationtuple" "github.com/ory/keto/x" @@ -17,6 +19,7 @@ type RegistryDefault struct { p *memory.Persister l *logrusx.Logger w herodot.Writer + e *check.Engine } func (r *RegistryDefault) Logger() *logrusx.Logger { @@ -39,3 +42,10 @@ func (r *RegistryDefault) RelationTupleManager() relationtuple.Manager { } return r.p } + +func (r *RegistryDefault) PermissionEngine() *check.Engine { + if r.e == nil { + r.e = check.NewEngine(r) + } + return r.e +} diff --git a/go.mod b/go.mod index 62eb64b71..04149c94a 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.0 // indirect github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518 + github.com/stretchr/testify v1.5.1 github.com/tidwall/gjson v1.6.0 github.com/tidwall/sjson v1.1.1 // indirect github.com/urfave/negroni v1.0.0 diff --git a/models/relation.go b/models/relation.go index e1efb339e..868b86e3d 100644 --- a/models/relation.go +++ b/models/relation.go @@ -21,6 +21,7 @@ type ( Subject interface { String() string FromString(string) Subject + Equals(interface{}) bool } UserID struct { ID string @@ -54,6 +55,14 @@ func (o *Object) String() string { return fmt.Sprintf("%s:%s", o.Namespace, o.ID) } +func (o *Object) Equals(v interface{}) bool { + ov, ok := v.(*Object) + if !ok { + return false + } + return ov.ID == o.ID && ov.Namespace == o.Namespace +} + func (o *Object) UnmarshalJSON(raw []byte) error { o.FromString(string(raw)) return nil @@ -95,10 +104,34 @@ func (u *UserSet) FromString(s string) Subject { return u } +func (u *UserID) Equals(v interface{}) bool { + uv, ok := v.(*UserID) + if !ok { + return false + } + return uv.ID == u.ID +} + +func (u *UserSet) Equals(v interface{}) bool { + uv, ok := v.(*UserSet) + if !ok { + return false + } + return uv.Relation == u.Relation && uv.Object.Equals(u.Object) +} + func (r *InternalRelationTuple) String() string { return fmt.Sprintf("%s#%s@%s", r.Object, r.Relation, r.Subject) } +func (r *InternalRelationTuple) DeriveSubject() Subject { + return &UserSet{ + // TODO check if this should be copied + Object: r.Object, + Relation: r.Relation, + } +} + func (r *InternalRelationTuple) UnmarshalJSON(raw []byte) error { subject := gjson.GetBytes(raw, "subject").Str r.Subject = SubjectFromString(subject) @@ -196,7 +229,7 @@ func (rq *RelationQuery) FromGRPC(query *ReadRelationTuplesRequest_Query) *Relat func (r *InternalRelationTuple) Header() []string { return []string{ "RELATION NAME", - "USER ID", + "SUBJECT ID", "OBJECT ID", } } diff --git a/persistence/memory/relations.go b/persistence/memory/relations.go index 2b95db083..0a345df58 100644 --- a/persistence/memory/relations.go +++ b/persistence/memory/relations.go @@ -14,13 +14,14 @@ type ( var _ relationtuple.Manager = &Persister{} -func (p *Persister) paginateRelations(rels []*models.InternalRelationTuple, page, perPage int32) []*models.InternalRelationTuple { +func (p *Persister) paginateRelations(rels []*models.InternalRelationTuple, options ...relationtuple.PaginationOptionSetter) []*models.InternalRelationTuple { if len(rels) == 0 { return rels } - veryLast := int32(len(p.relations)) - start, end := page*perPage, (page+1)*perPage-1 + pagination := relationtuple.GetPaginationOptions(options...) + veryLast := len(rels) + start, end := pagination.Page*pagination.PerPage, (pagination.Page+1)*pagination.PerPage-1 if veryLast < end { end = veryLast } @@ -30,10 +31,9 @@ func (p *Persister) paginateRelations(rels []*models.InternalRelationTuple, page func buildRelationQueryFilter(query *models.RelationQuery) queryFilter { var filters []queryFilter - if query.Object.ID != "" && query.Object.Namespace != "" { + if query.Object != nil && query.Object.ID != "" && query.Object.Namespace != "" { filters = append(filters, func(r *models.InternalRelationTuple) bool { - return r.Object.ID == query.Object.ID && - r.Object.Namespace == query.Object.Namespace + return query.Object.Equals(r.Object) }) } @@ -44,23 +44,9 @@ func buildRelationQueryFilter(query *models.RelationQuery) queryFilter { } if query.Subject != nil { - switch s := query.Subject.(type) { - case *models.UserID: - filters = append(filters, func(r *models.InternalRelationTuple) bool { - rUserId, ok := r.Subject.(*models.UserID) - return ok && - r.Subject != nil && - rUserId.ID == s.ID - }) - case *models.UserSet: - filters = append(filters, func(r *models.InternalRelationTuple) bool { - rUserSet, ok := r.Subject.(*models.UserSet) - return ok && - rUserSet.Object.ID == s.Object.ID && - rUserSet.Object.Namespace == s.Object.Namespace && - rUserSet.Relation == s.Relation - }) - } + filters = append(filters, func(r *models.InternalRelationTuple) bool { + return query.Subject.Equals(r.Subject) + }) } // Create composite filter @@ -75,7 +61,7 @@ func buildRelationQueryFilter(query *models.RelationQuery) queryFilter { } } -func (p *Persister) GetRelationTuples(_ context.Context, queries []*models.RelationQuery, page, perPage int32) ([]*models.InternalRelationTuple, error) { +func (p *Persister) GetRelationTuples(_ context.Context, queries []*models.RelationQuery, options ...relationtuple.PaginationOptionSetter) ([]*models.InternalRelationTuple, error) { p.RLock() defer p.RUnlock() @@ -97,13 +83,13 @@ func (p *Persister) GetRelationTuples(_ context.Context, queries []*models.Relat } } - return p.paginateRelations(res, page, perPage), nil + return p.paginateRelations(res, options...), nil } -func (p *Persister) WriteRelationTuple(_ context.Context, r *models.InternalRelationTuple) error { +func (p *Persister) WriteRelationTuples(_ context.Context, rs ...*models.InternalRelationTuple) error { p.Lock() defer p.Unlock() - p.relations = append(p.relations, r) + p.relations = append(p.relations, rs...) return nil } diff --git a/relationtuple/definitions.go b/relationtuple/definitions.go index 0f6a60b30..4f347c624 100644 --- a/relationtuple/definitions.go +++ b/relationtuple/definitions.go @@ -11,7 +11,41 @@ type ( RelationTupleManager() Manager } Manager interface { - GetRelationTuples(ctx context.Context, queries []*models.RelationQuery, page, perPage int32) ([]*models.InternalRelationTuple, error) - WriteRelationTuple(ctx context.Context, r *models.InternalRelationTuple) error + GetRelationTuples(ctx context.Context, queries []*models.RelationQuery, options ...PaginationOptionSetter) ([]*models.InternalRelationTuple, error) + WriteRelationTuples(ctx context.Context, rs ...*models.InternalRelationTuple) error + } + paginationOptions struct { + Page, PerPage int + } + PaginationOptionSetter func(*paginationOptions) *paginationOptions + PaginatedRelations interface { + Relations() []*models.InternalRelationTuple + HasNext() bool + Next() PaginatedRelations } ) + +func WithPage(page int) PaginationOptionSetter { + return func(opts *paginationOptions) *paginationOptions { + opts.Page = page + return opts + } +} + +func WithPerPage(perPage int) PaginationOptionSetter { + return func(opts *paginationOptions) *paginationOptions { + opts.PerPage = perPage + return opts + } +} + +func GetPaginationOptions(modifiers ...PaginationOptionSetter) *paginationOptions { + opts := &paginationOptions{ + Page: 0, + PerPage: 100, + } + for _, f := range modifiers { + opts = f(opts) + } + return opts +} diff --git a/relationtuple/grpc_server.go b/relationtuple/grpc_server.go index c2d3398c7..82dbea94f 100644 --- a/relationtuple/grpc_server.go +++ b/relationtuple/grpc_server.go @@ -19,23 +19,23 @@ type ( } ) -func (s *Server) WriteRelationTuple(ctx context.Context, r *models.WriteRelationTupleRequest) (*models.WriteRelationTupleResponse, error) { - return &models.WriteRelationTupleResponse{}, s.d.RelationTupleManager().WriteRelationTuple(ctx, (&models.InternalRelationTuple{}).FromGRPC(r.Tuple)) -} - func NewServer(d serverDependencies) *Server { return &Server{ d: d, } } +func (s *Server) WriteRelationTuple(ctx context.Context, r *models.WriteRelationTupleRequest) (*models.WriteRelationTupleResponse, error) { + return &models.WriteRelationTupleResponse{}, s.d.RelationTupleManager().WriteRelationTuples(ctx, (&models.InternalRelationTuple{}).FromGRPC(r.Tuple)) +} + func (s *Server) ReadRelationTuples(ctx context.Context, req *models.ReadRelationTuplesRequest) (*models.ReadRelationTuplesResponse, error) { queries := make([]*models.RelationQuery, len(req.TupleSets)) for i, tupleset := range req.TupleSets { queries[i] = (&models.RelationQuery{}).FromGRPC(tupleset) } - normalRels, _ := s.d.RelationTupleManager().GetRelationTuples(ctx, queries, req.Page, req.PerPage) + normalRels, _ := s.d.RelationTupleManager().GetRelationTuples(ctx, queries, WithPage(int(req.Page)), WithPerPage(int(req.PerPage))) rpcRels := make([]*models.RelationTuple, len(normalRels)) for i, tupleset := range normalRels { diff --git a/relationtuple/handler.go b/relationtuple/handler.go index bd6728267..d58b716c9 100644 --- a/relationtuple/handler.go +++ b/relationtuple/handler.go @@ -47,7 +47,7 @@ func (h *handler) getRelations(w http.ResponseWriter, r *http.Request, _ httprou Object: (&models.Object{}).FromString(params.Get("object")), Subject: models.SubjectFromString(params.Get("subject")), }, - }, 0, 100) + }) if err != nil { h.d.Writer().WriteError(w, r, err) @@ -65,7 +65,7 @@ func (h *handler) createRelation(w http.ResponseWriter, r *http.Request, _ httpr return } - if err := h.d.RelationTupleManager().WriteRelationTuple(r.Context(), &rel); err != nil { + if err := h.d.RelationTupleManager().WriteRelationTuples(r.Context(), &rel); err != nil { h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError)) return }