diff --git a/cmd/check/root.go b/cmd/check/root.go index 5221fbffb..ab61cc033 100644 --- a/cmd/check/root.go +++ b/cmd/check/root.go @@ -22,6 +22,8 @@ func (o *checkOutput) String() string { return "Denied\n" } +const FlagMaxDepth = "max-depth" + func newCheckCmd() *cobra.Command { cmd := &cobra.Command{ Use: "check ", @@ -35,6 +37,11 @@ func newCheckCmd() *cobra.Command { } defer conn.Close() + maxDepth, err := cmd.Flags().GetInt32(FlagMaxDepth) + if err != nil { + return err + } + cl := acl.NewCheckServiceClient(conn) resp, err := cl.Check(cmd.Context(), &acl.CheckRequest{ Subject: &acl.Subject{ @@ -43,6 +50,7 @@ func newCheckCmd() *cobra.Command { Relation: args[1], Namespace: args[2], Object: args[3], + MaxDepth: maxDepth, }) if err != nil { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err) @@ -56,6 +64,7 @@ func newCheckCmd() *cobra.Command { client.RegisterRemoteURLFlags(cmd.Flags()) cmdx.RegisterFormatFlags(cmd.Flags()) + cmd.Flags().Int32P(FlagMaxDepth, "d", 0, "Maximum depth of the search tree. If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead.") return cmd } diff --git a/cmd/expand/root.go b/cmd/expand/root.go index 5ceacafba..e014647da 100644 --- a/cmd/expand/root.go +++ b/cmd/expand/root.go @@ -72,7 +72,7 @@ func NewExpandCmd() *cobra.Command { client.RegisterRemoteURLFlags(cmd.Flags()) cmdx.RegisterJSONFormatFlags(cmd.Flags()) cmdx.RegisterNoiseFlags(cmd.Flags()) - cmd.Flags().Int32P(FlagMaxDepth, "d", 100, "maximum depth of the tree") + cmd.Flags().Int32P(FlagMaxDepth, "d", 0, "Maximum depth of the tree to be returned. If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead.") return cmd } diff --git a/docs/docs/cli/keto-check.md b/docs/docs/cli/keto-check.md index 01eaf616e..3270419de 100644 --- a/docs/docs/cli/keto-check.md +++ b/docs/docs/cli/keto-check.md @@ -28,6 +28,7 @@ keto check <subject> <relation> <namespace> <object> [fl ``` -f, --format string Set the output format. One of table, json, and json-pretty. (default "default") -h, --help help for check + -d, --max-depth int32 Maximum depth of the search tree. If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead. -q, --quiet Be quiet with output printing. --read-remote string Remote URL of the read API endpoint. (default "127.0.0.1:4466") --write-remote string Remote URL of the write API endpoint. (default "127.0.0.1:4467") diff --git a/docs/docs/cli/keto-expand.md b/docs/docs/cli/keto-expand.md index 8f9ac06c1..904f92079 100644 --- a/docs/docs/cli/keto-expand.md +++ b/docs/docs/cli/keto-expand.md @@ -27,7 +27,7 @@ keto expand <relation> <namespace> <object> [flags] ``` -f, --format string Set the output format. One of default, json, and json-pretty. (default "default") -h, --help help for expand - -d, --max-depth int32 maximum depth of the tree (default 100) + -d, --max-depth int32 Maximum depth of the tree to be returned. If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead. -q, --quiet Be quiet with output printing. --read-remote string Remote URL of the read API endpoint. (default "127.0.0.1:4466") --write-remote string Remote URL of the write API endpoint. (default "127.0.0.1:4467") diff --git a/docs/docs/concepts/api-overview.mdx b/docs/docs/concepts/api-overview.mdx index 8b1267fb1..58029f3c9 100644 --- a/docs/docs/concepts/api-overview.mdx +++ b/docs/docs/concepts/api-overview.mdx @@ -41,6 +41,12 @@ This API resolves [subject sets](./subjects.mdx#subject-sets) and This API is primarily used to [check permissions to restrict actions](../guides/simple-access-check-guide.mdx). +A check-request can include the maximum depth of the search tree. If the value +is less than 1 or greater than the global max-depth then the global max-depth +will be used instead. This is to ensure low latency and limit the resource usage +per request. To find out more about Ory Keto's performance, head over to the +[performance considerations](../performance.mdx). + For more details, head over to the [gRPC API reference](../reference/proto-api.mdx#checkservice) or [REST API reference](../reference/rest-api.mdx#check-a-relation-tuple). @@ -56,10 +62,11 @@ tuples including the operands as defined in the - determine why someone has access to an object - audit permissions in the system -An expand-request has to include the maximum depth of the tree to be returned. -This is required to ensure low latency and limit the resource usage per request. -To find out more about Ory Keto's performance, head over to the -[performance considerations](../performance.mdx). +An expand-request can include the maximum depth of the tree to be returned. If +the value is less than 1 or greater than the global max-depth then the global +max-depth will be used instead. This is required to ensure low latency and limit +the resource usage per request. To find out more about Ory Keto's performance, +head over to the [performance considerations](../performance.mdx). For more details, head over to the [gRPC API reference](../reference/proto-api.mdx#expandservice) or diff --git a/docs/docs/reference/proto-api.mdx b/docs/docs/reference/proto-api.mdx index 756295f7a..ebab28625 100644 --- a/docs/docs/reference/proto-api.mdx +++ b/docs/docs/reference/proto-api.mdx @@ -67,6 +67,7 @@ related to an object. | subject | [Subject](#ory.keto.acl.v1alpha1.Subject) | | The related subject in this check. | | latest | [bool](#bool) | | This field is not implemented yet and has no effect. | | snaptoken | [string](#string) | | This field is not implemented yet and has no effect. | +| max_depth | [int32](#int32) | | The maximum depth to search for a relation.
If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead. | ### CheckResponse @@ -97,7 +98,7 @@ The request for an ExpandService.Expand RPC. Expands the given subject set. | Field | Type | Label | Description | | --------- | ----------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | subject | [Subject](#ory.keto.acl.v1alpha1.Subject) | | The subject to expand. | -| max_depth | [int32](#int32) | | The maximum depth of tree to build. It is important to set this parameter to a meaningful value. Ponder how deep you really want to display this. | +| max_depth | [int32](#int32) | | The maximum depth of tree to build.
If the value is less than 1 or greater than the global max-depth then the global max-depth will be used instead.
It is important to set this parameter to a meaningful value. Ponder how deep you really want to display this. | | snaptoken | [string](#string) | | This field is not implemented yet and has no effect. | ### ExpandResponse diff --git a/internal/check/engine.go b/internal/check/engine.go index 27ca49526..26d197d04 100644 --- a/internal/check/engine.go +++ b/internal/check/engine.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/ory/keto/internal/driver/config" "github.com/ory/keto/internal/x/graph" "github.com/ory/herodot" @@ -21,6 +22,8 @@ type ( } EngineDependencies interface { relationtuple.ManagerProvider + config.Provider + x.LoggerProvider } ) @@ -30,7 +33,12 @@ func NewEngine(d EngineDependencies) *Engine { } } -func (e *Engine) subjectIsAllowed(ctx context.Context, requested *relationtuple.InternalRelationTuple, rels []*relationtuple.InternalRelationTuple) (bool, error) { +func (e *Engine) subjectIsAllowed( + ctx context.Context, + requested *relationtuple.InternalRelationTuple, + rels []*relationtuple.InternalRelationTuple, + restDepth int, +) (bool, error) { // This is the same as the graph problem "can requested.Subject be reached from requested.Object through the first outgoing edge requested.Relation" // // We implement recursive depth-first search here. @@ -54,7 +62,12 @@ func (e *Engine) subjectIsAllowed(ctx context.Context, requested *relationtuple. } // expand the set by one indirection; paginated - allowed, err := e.checkOneIndirectionFurther(ctx, requested, &relationtuple.RelationQuery{Object: sub.Object, Relation: sub.Relation, Namespace: sub.Namespace}) + allowed, err := e.checkOneIndirectionFurther( + ctx, + requested, + &relationtuple.RelationQuery{Object: sub.Object, Relation: sub.Relation, Namespace: sub.Namespace}, + restDepth-1, + ) if err != nil { return false, err } @@ -66,7 +79,17 @@ func (e *Engine) subjectIsAllowed(ctx context.Context, requested *relationtuple. return false, nil } -func (e *Engine) checkOneIndirectionFurther(ctx context.Context, requested *relationtuple.InternalRelationTuple, expandQuery *relationtuple.RelationQuery) (bool, error) { +func (e *Engine) checkOneIndirectionFurther( + ctx context.Context, + requested *relationtuple.InternalRelationTuple, + expandQuery *relationtuple.RelationQuery, + restDepth int, +) (bool, error) { + if restDepth <= 0 { + e.d.Logger().WithFields(requested.ToLoggerFields()).Debug("reached max-depth, therefore this query will not be further expanded") + return false, nil + } + // an empty page token denotes the first page (as tokens are opaque) var prevPage string @@ -79,7 +102,7 @@ func (e *Engine) checkOneIndirectionFurther(ctx context.Context, requested *rela return false, err } - allowed, err := e.subjectIsAllowed(ctx, requested, nextRels) + allowed, err := e.subjectIsAllowed(ctx, requested, nextRels, restDepth) // loop through pages until either allowed, end of pages, or an error occurred if allowed || nextPage == "" || err != nil { @@ -90,6 +113,11 @@ func (e *Engine) checkOneIndirectionFurther(ctx context.Context, requested *rela } } -func (e *Engine) SubjectIsAllowed(ctx context.Context, r *relationtuple.InternalRelationTuple) (bool, error) { - return e.checkOneIndirectionFurther(ctx, r, &relationtuple.RelationQuery{Object: r.Object, Relation: r.Relation, Namespace: r.Namespace}) +func (e *Engine) SubjectIsAllowed(ctx context.Context, r *relationtuple.InternalRelationTuple, restDepth int) (bool, error) { + // global max-depth takes precedence when it is the lesser or if the request max-depth is less than or equal to 0 + if globalMaxDepth := e.d.Config().ReadAPIMaxDepth(); restDepth <= 0 || globalMaxDepth < restDepth { + restDepth = globalMaxDepth + } + + return e.checkOneIndirectionFurther(ctx, r, &relationtuple.RelationQuery{Object: r.Object, Relation: r.Relation, Namespace: r.Namespace}, restDepth) } diff --git a/internal/check/engine_test.go b/internal/check/engine_test.go index 378a78943..3e34975cf 100644 --- a/internal/check/engine_test.go +++ b/internal/check/engine_test.go @@ -20,13 +20,104 @@ import ( "github.com/ory/keto/internal/driver" ) -func newDepsProvider(t *testing.T, namespaces []*namespace.Namespace, pageOpts ...x.PaginationOptionSetter) *relationtuple.ManagerWrapper { +type configProvider = config.Provider +type loggerProvider = x.LoggerProvider + +// deps is defined to capture engine dependencies in a single struct +type deps struct { + *relationtuple.ManagerWrapper // managerProvider + configProvider + loggerProvider +} + +func newDepsProvider(t *testing.T, namespaces []*namespace.Namespace, pageOpts ...x.PaginationOptionSetter) *deps { reg := driver.NewSqliteTestRegistry(t, false) require.NoError(t, reg.Config().Set(config.KeyNamespaces, namespaces)) - return relationtuple.NewManagerWrapper(t, reg, pageOpts...) + mr := relationtuple.NewManagerWrapper(t, reg, pageOpts...) + + return &deps{ + ManagerWrapper: mr, + configProvider: reg, + loggerProvider: reg, + } } func TestEngine(t *testing.T) { + t.Run("respects max depth", func(t *testing.T) { + // "user" has relation "access" through being an "owner" through being an "admin" + // which requires at least 2 units of depth. If max-depth is 2 then we hit max-depth + ns := "test" + user := &relationtuple.SubjectID{ID: "user"} + object := "object" + + adminRel := relationtuple.InternalRelationTuple{ + Relation: "admin", + Object: object, + Namespace: ns, + Subject: user, + } + + adminIsOwnerRel := relationtuple.InternalRelationTuple{ + Relation: "owner", + Object: object, + Namespace: ns, + Subject: &relationtuple.SubjectSet{ + Relation: "admin", + Object: object, + Namespace: ns, + }, + } + + accessRel := relationtuple.InternalRelationTuple{ + Relation: "access", + Object: object, + Namespace: ns, + Subject: &relationtuple.SubjectSet{ + Relation: "owner", + Object: object, + Namespace: ns, + }, + } + reg := newDepsProvider(t, []*namespace.Namespace{ + {Name: ns, ID: 1}, + }) + require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &adminRel, &adminIsOwnerRel, &accessRel)) + + e := check.NewEngine(reg) + + userHasAccess := &relationtuple.InternalRelationTuple{ + Relation: "access", + Object: object, + Namespace: ns, + Subject: user, + } + + // global max-depth defaults to 5 + assert.Equal(t, reg.Config().ReadAPIMaxDepth(), 5) + + // req max-depth takes precedence, max-depth=2 is not enough + res, err := e.SubjectIsAllowed(context.Background(), userHasAccess, 2) + require.NoError(t, err) + assert.False(t, res) + + // req max-depth takes precedence, max-depth=3 is enough + res, err = e.SubjectIsAllowed(context.Background(), userHasAccess, 3) + require.NoError(t, err) + assert.True(t, res) + + // global max-depth takes precedence and max-depth=2 is not enough + require.NoError(t, reg.Config().Set(config.KeyReadMaxDepth, 2)) + res, err = e.SubjectIsAllowed(context.Background(), userHasAccess, 3) + require.NoError(t, err) + assert.False(t, res) + + // global max-depth takes precedence and max-depth=3 is enough + require.NoError(t, reg.Config().Set(config.KeyReadMaxDepth, 3)) + res, err = e.SubjectIsAllowed(context.Background(), userHasAccess, 0) + require.NoError(t, err) + assert.True(t, res) + }) + t.Run("direct inclusion", func(t *testing.T) { rel := relationtuple.InternalRelationTuple{ Relation: "access", @@ -42,7 +133,7 @@ func TestEngine(t *testing.T) { e := check.NewEngine(reg) - res, err := e.SubjectIsAllowed(context.Background(), &rel) + res, err := e.SubjectIsAllowed(context.Background(), &rel, 0) require.NoError(t, err) assert.True(t, res) }) @@ -83,7 +174,7 @@ func TestEngine(t *testing.T) { Object: dust, Subject: &mark, Namespace: sofaNamespace, - }) + }, 0) require.NoError(t, err) assert.True(t, res) }) @@ -111,7 +202,7 @@ func TestEngine(t *testing.T) { Object: rel.Object, Namespace: rel.Namespace, Subject: &relationtuple.SubjectID{ID: "not " + user.ID}, - }) + }, 0) require.NoError(t, err) assert.False(t, res) }) @@ -143,7 +234,7 @@ func TestEngine(t *testing.T) { Relation: access.Relation, Object: object, Subject: user.Subject, - }) + }, 0) require.NoError(t, err) assert.False(t, res) }) @@ -181,7 +272,7 @@ func TestEngine(t *testing.T) { Object: diaryEntry, Namespace: diaryNamespace, Subject: user.Subject, - }) + }, 0) require.NoError(t, err) assert.False(t, res) }) @@ -239,7 +330,7 @@ func TestEngine(t *testing.T) { Relation: writeRel.Relation, Object: object, Subject: &user, - }) + }, 0) require.NoError(t, err) assert.True(t, res) @@ -249,7 +340,7 @@ func TestEngine(t *testing.T) { Relation: orgMembers.Relation, Object: organization, Subject: &user, - }) + }, 0) require.NoError(t, err) assert.True(t, res) }) @@ -289,7 +380,7 @@ func TestEngine(t *testing.T) { Relation: directoryAccess.Relation, Object: file, Subject: &user, - }) + }, 0) require.NoError(t, err) assert.False(t, res) }) @@ -333,7 +424,7 @@ func TestEngine(t *testing.T) { Object: obj, Relation: ownerRel, Subject: &relationtuple.SubjectID{ID: directOwner}, - }) + }, 0) require.NoError(t, err) assert.True(t, res) @@ -342,7 +433,7 @@ func TestEngine(t *testing.T) { Object: obj, Relation: ownerRel, Subject: &relationtuple.SubjectID{ID: indirectOwner}, - }) + }, 0) require.NoError(t, err) assert.True(t, res) }) @@ -375,7 +466,7 @@ func TestEngine(t *testing.T) { Object: obj, Relation: access, Subject: &relationtuple.SubjectID{ID: user}, - }) + }, 0) require.NoError(t, err) assert.True(t, allowed) @@ -429,7 +520,7 @@ func TestEngine(t *testing.T) { Relation: access, Subject: &relationtuple.SubjectID{ID: user}, } - allowed, err := e.SubjectIsAllowed(context.Background(), req) + allowed, err := e.SubjectIsAllowed(context.Background(), req, 0) require.NoError(t, err) assert.True(t, allowed, req.String()) } @@ -483,7 +574,7 @@ func TestEngine(t *testing.T) { Subject: &relationtuple.SubjectID{ ID: stations[2], }, - }) + }, 0) require.NoError(t, err) assert.False(t, res) }) diff --git a/internal/check/handler.go b/internal/check/handler.go index ece7eb1b4..f37376f83 100644 --- a/internal/check/handler.go +++ b/internal/check/handler.go @@ -63,6 +63,13 @@ type RESTResponse struct { Allowed bool `json:"allowed"` } +// swagger:parameters getCheck postCheck +// nolint:deadcode,unused +type getCheckRequest struct { + // in:query + MaxDepth int `json:"max-depth"` +} + // swagger:route GET /check read getCheck // // Check a relation tuple @@ -83,6 +90,12 @@ type RESTResponse struct { // 403: getCheckResponse // 500: genericError func (h *Handler) getCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + maxDepth, err := x.GetMaxDepthFromQuery(r.URL.Query()) + if err != nil { + h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithError(err.Error())) + return + } + tuple, err := (&relationtuple.InternalRelationTuple{}).FromURLQuery(r.URL.Query()) if errors.Is(err, relationtuple.ErrNilSubject) { h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason("Subject has to be specified.")) @@ -92,7 +105,7 @@ func (h *Handler) getCheck(w http.ResponseWriter, r *http.Request, _ httprouter. return } - allowed, err := h.d.PermissionEngine().SubjectIsAllowed(r.Context(), tuple) + allowed, err := h.d.PermissionEngine().SubjectIsAllowed(r.Context(), tuple, maxDepth) if err != nil { h.d.Writer().WriteError(w, r, err) return @@ -126,12 +139,19 @@ func (h *Handler) getCheck(w http.ResponseWriter, r *http.Request, _ httprouter. // 403: getCheckResponse // 500: genericError func (h *Handler) postCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + maxDepth, err := x.GetMaxDepthFromQuery(r.URL.Query()) + if err != nil { + h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithError(err.Error())) + return + } + var tuple relationtuple.InternalRelationTuple if err := json.NewDecoder(r.Body).Decode(&tuple); err != nil { h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Unable to decode JSON payload: %s", err))) + return } - allowed, err := h.d.PermissionEngine().SubjectIsAllowed(r.Context(), &tuple) + allowed, err := h.d.PermissionEngine().SubjectIsAllowed(r.Context(), &tuple, maxDepth) if err != nil { h.d.Writer().WriteError(w, r, err) return @@ -151,7 +171,7 @@ func (h *Handler) Check(ctx context.Context, req *acl.CheckRequest) (*acl.CheckR return nil, err } - allowed, err := h.d.PermissionEngine().SubjectIsAllowed(ctx, tuple) + allowed, err := h.d.PermissionEngine().SubjectIsAllowed(ctx, tuple, int(req.MaxDepth)) // TODO add content change handling if err != nil { return nil, err diff --git a/internal/check/handler_test.go b/internal/check/handler_test.go index 3612cae60..e2f50d71a 100644 --- a/internal/check/handler_test.go +++ b/internal/check/handler_test.go @@ -51,6 +51,16 @@ func TestRESTHandler(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() + t.Run("case=returns bad request on malformed int", func(t *testing.T) { + resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?max-depth=foo") + require.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "invalid syntax") + }) + t.Run("case=returns bad request on malformed input", func(t *testing.T) { resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?" + url.Values{ "subject": {"not#a valid userset rewrite"}, diff --git a/internal/driver/config/config.schema.json b/internal/driver/config/config.schema.json index 268fbe652..9d65d46bb 100644 --- a/internal/driver/config/config.schema.json +++ b/internal/driver/config/config.schema.json @@ -232,6 +232,14 @@ }, "tls": { "$ref": "#/definitions/tlsx" + }, + "max-depth": { + "type": "integer", + "default": 5, + "title": "Global maximum depth", + "description": "The global maximum depth on all read operations. This can be decreased for a request by a value specified on the request, this applies only if the request-specific value is greater than 1 and less than the global maximum depth.", + "minimum": 1, + "maximum": 65535 } } }, diff --git a/internal/driver/config/provider.go b/internal/driver/config/provider.go index 66cb5a105..b60896769 100644 --- a/internal/driver/config/provider.go +++ b/internal/driver/config/provider.go @@ -29,8 +29,9 @@ var Schema []byte const ( KeyDSN = "dsn" - KeyReadAPIHost = "serve.read.host" - KeyReadAPIPort = "serve.read.port" + KeyReadMaxDepth = "serve.read.max-depth" + KeyReadAPIHost = "serve.read.host" + KeyReadAPIPort = "serve.read.port" KeyWriteAPIHost = "serve.write.host" KeyWriteAPIPort = "serve.write.port" @@ -138,6 +139,10 @@ func (k *Config) ReadAPIListenOn() string { ) } +func (k *Config) ReadAPIMaxDepth() int { + return k.p.Int(KeyReadMaxDepth) +} + func (k *Config) WriteAPIListenOn() string { return fmt.Sprintf( "%s:%d", diff --git a/internal/e2e/Test b/internal/e2e/Test new file mode 100644 index 000000000..e69de29bb diff --git a/internal/expand/engine.go b/internal/expand/engine.go index 24c90fcac..f290884f3 100644 --- a/internal/expand/engine.go +++ b/internal/expand/engine.go @@ -3,6 +3,7 @@ package expand import ( "context" + "github.com/ory/keto/internal/driver/config" "github.com/ory/keto/internal/x" "github.com/ory/keto/internal/x/graph" @@ -12,6 +13,8 @@ import ( type ( EngineDependencies interface { relationtuple.ManagerProvider + config.Provider + x.LoggerProvider } Engine struct { d EngineDependencies @@ -28,8 +31,9 @@ func NewEngine(d EngineDependencies) *Engine { } func (e *Engine) BuildTree(ctx context.Context, subject relationtuple.Subject, restDepth int) (*Tree, error) { - if restDepth <= 0 { - return nil, nil + // global max-depth takes precedence when it is the lesser or if the request max-depth is less than or equal to 0 + if globalMaxDepth := e.d.Config().ReadAPIMaxDepth(); restDepth <= 0 || globalMaxDepth < restDepth { + restDepth = globalMaxDepth } if us, isUserSet := subject.(*relationtuple.SubjectSet); isUserSet { diff --git a/internal/expand/engine_test.go b/internal/expand/engine_test.go index 1610b9218..e1af365ae 100644 --- a/internal/expand/engine_test.go +++ b/internal/expand/engine_test.go @@ -20,11 +20,25 @@ import ( "github.com/ory/keto/internal/driver" ) +type configProvider = config.Provider +type loggerProvider = x.LoggerProvider + +// deps is defined to capture engine dependencies in a single struct +type deps struct { + *relationtuple.ManagerWrapper // managerProvider + configProvider + loggerProvider +} + func newTestEngine(t *testing.T, namespaces []*namespace.Namespace, paginationOpts ...x.PaginationOptionSetter) (*relationtuple.ManagerWrapper, *expand.Engine) { innerReg := driver.NewSqliteTestRegistry(t, false) require.NoError(t, innerReg.Config().Set(config.KeyNamespaces, namespaces)) reg := relationtuple.NewManagerWrapper(t, innerReg, paginationOpts...) - e := expand.NewEngine(reg) + e := expand.NewEngine(&deps{ + ManagerWrapper: reg, + configProvider: innerReg, + loggerProvider: innerReg, + }) return reg, e } diff --git a/internal/expand/handler.go b/internal/expand/handler.go index cb25143df..0ee6e2baf 100644 --- a/internal/expand/handler.go +++ b/internal/expand/handler.go @@ -3,7 +3,6 @@ package expand import ( "context" "net/http" - "strconv" "github.com/ory/herodot" @@ -53,7 +52,6 @@ func (h *handler) RegisterWriteGRPC(s *grpc.Server) {} // nolint:deadcode,unused type getExpandRequest struct { // in:query - // required: true MaxDepth int `json:"max-depth"` } @@ -77,17 +75,13 @@ type getExpandRequest struct { // 404: genericError // 500: genericError func (h *handler) getExpand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - if !r.URL.Query().Has("max-depth") { - h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithError("required query parameter 'max-depth' is missing")) - return - } - depth, err := strconv.ParseInt(r.URL.Query().Get("max-depth"), 0, 0) + maxDepth, err := x.GetMaxDepthFromQuery(r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithError(err.Error())) return } - res, err := h.d.ExpandEngine().BuildTree(r.Context(), (&relationtuple.SubjectSet{}).FromURLQuery(r.URL.Query()), int(depth)) + res, err := h.d.ExpandEngine().BuildTree(r.Context(), (&relationtuple.SubjectSet{}).FromURLQuery(r.URL.Query()), maxDepth) if err != nil { h.d.Writer().WriteError(w, r, err) return diff --git a/internal/expand/handler_test.go b/internal/expand/handler_test.go index dde5e5343..feed2f517 100644 --- a/internal/expand/handler_test.go +++ b/internal/expand/handler_test.go @@ -35,16 +35,6 @@ func TestRESTHandler(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() - t.Run("case=returns required query parameter max-depth is missing is missing", func(t *testing.T) { - resp, err := ts.Client().Get(ts.URL + expand.RouteBase) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Contains(t, string(body), "required query parameter 'max-depth'") - }) - t.Run("case=returns bad request on malformed int", func(t *testing.T) { resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?max-depth=foo") require.NoError(t, err) diff --git a/internal/x/max_depth.go b/internal/x/max_depth.go new file mode 100644 index 000000000..15f5b57bb --- /dev/null +++ b/internal/x/max_depth.go @@ -0,0 +1,20 @@ +package x + +import ( + "fmt" + "net/url" + "strconv" +) + +func GetMaxDepthFromQuery(q url.Values) (int, error) { + if !q.Has("max-depth") { + return 0, nil + } + + maxDepth, err := strconv.ParseInt(q.Get("max-depth"), 0, 0) + if err != nil { + return 0, fmt.Errorf("unable to parse 'max-depth' query parameter to int: %s", err) + } + + return int(maxDepth), err +} diff --git a/proto/README.md b/proto/README.md index 30dfcee90..5400bd4a2 100644 --- a/proto/README.md +++ b/proto/README.md @@ -2,3 +2,5 @@ This package provides the generated gRPC client for [Ory Keto](https://ory.sh/keto). Go to [the documentation](https://ory.sh/keto/docs) to learn more + +The protocol buffer compiler, `protoc`, is used to compile `.proto` files, which contain service and message definitions. Go to [the gRPC documentation](https://grpc.io/docs/protoc-installation/#install-pre-compiled-binaries-any-os) for installation instructions. diff --git a/proto/ory/keto/acl/v1alpha1/check_service.pb.go b/proto/ory/keto/acl/v1alpha1/check_service.pb.go index e28b9da80..bb30620e0 100644 --- a/proto/ory/keto/acl/v1alpha1/check_service.pb.go +++ b/proto/ory/keto/acl/v1alpha1/check_service.pb.go @@ -77,6 +77,11 @@ type CheckRequest struct { // ACLs had already been replicated to all availability zones. // --> Snaptoken string `protobuf:"bytes,6,opt,name=snaptoken,proto3" json:"snaptoken,omitempty"` + // The maximum depth to search for a relation. + // + // If the value is less than 1 or greater than the global + // max-depth then the global max-depth will be used instead. + MaxDepth int32 `protobuf:"varint,7,opt,name=max_depth,json=maxDepth,proto3" json:"max_depth,omitempty"` } func (x *CheckRequest) Reset() { @@ -153,6 +158,13 @@ func (x *CheckRequest) GetSnaptoken() string { return "" } +func (x *CheckRequest) GetMaxDepth() int32 { + if x != nil { + return x.MaxDepth + } + return 0 +} + // The response for a CheckService.Check rpc. type CheckResponse struct { state protoimpl.MessageState @@ -235,7 +247,7 @@ var file_ory_keto_acl_v1alpha1_check_service_proto_rawDesc = []byte{ 0x2e, 0x6b, 0x65, 0x74, 0x6f, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x1a, 0x1f, 0x6f, 0x72, 0x79, 0x2f, 0x6b, 0x65, 0x74, 0x6f, 0x2f, 0x61, 0x63, 0x6c, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x61, 0x63, 0x6c, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0xd0, 0x01, 0x0a, 0x0c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, + 0x6f, 0x74, 0x6f, 0x22, 0xed, 0x01, 0x0a, 0x0c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, @@ -248,28 +260,29 @@ var file_ory_keto_acl_v1alpha1_check_service_proto_rawDesc = []byte{ 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x6e, 0x61, 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x6e, 0x61, - 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x47, 0x0a, 0x0d, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, - 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, - 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x6e, 0x61, 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x6e, 0x61, 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32, - 0x62, 0x0a, 0x0c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x52, 0x0a, 0x05, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x23, 0x2e, 0x6f, 0x72, 0x79, 0x2e, 0x6b, - 0x65, 0x74, 0x6f, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, - 0x6f, 0x72, 0x79, 0x2e, 0x6b, 0x65, 0x74, 0x6f, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, - 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x42, 0x94, 0x01, 0x0a, 0x18, 0x73, 0x68, 0x2e, 0x6f, 0x72, 0x79, 0x2e, 0x6b, - 0x65, 0x74, 0x6f, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x42, 0x11, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6f, 0x72, 0x79, 0x2f, 0x6b, 0x65, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2f, 0x6f, 0x72, 0x79, 0x2f, 0x6b, 0x65, 0x74, 0x6f, 0x2f, 0x61, 0x63, 0x6c, 0x2f, 0x76, 0x31, - 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x61, 0x63, 0x6c, 0xaa, 0x02, 0x15, 0x4f, 0x72, 0x79, - 0x2e, 0x4b, 0x65, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x6c, 0x2e, 0x56, 0x31, 0x41, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0xca, 0x02, 0x15, 0x4f, 0x72, 0x79, 0x5c, 0x4b, 0x65, 0x74, 0x6f, 0x5c, 0x41, 0x63, - 0x6c, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x64, 0x65, + 0x70, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x44, 0x65, + 0x70, 0x74, 0x68, 0x22, 0x47, 0x0a, 0x0d, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x6e, 0x61, 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x73, 0x6e, 0x61, 0x70, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0x62, 0x0a, 0x0c, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x05, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x23, 0x2e, 0x6f, 0x72, 0x79, 0x2e, 0x6b, 0x65, 0x74, 0x6f, + 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6f, 0x72, 0x79, + 0x2e, 0x6b, 0x65, 0x74, 0x6f, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x42, 0x94, 0x01, 0x0a, 0x18, 0x73, 0x68, 0x2e, 0x6f, 0x72, 0x79, 0x2e, 0x6b, 0x65, 0x74, 0x6f, + 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x42, 0x11, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x50, 0x01, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, + 0x72, 0x79, 0x2f, 0x6b, 0x65, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, + 0x79, 0x2f, 0x6b, 0x65, 0x74, 0x6f, 0x2f, 0x61, 0x63, 0x6c, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x3b, 0x61, 0x63, 0x6c, 0xaa, 0x02, 0x15, 0x4f, 0x72, 0x79, 0x2e, 0x4b, 0x65, + 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x6c, 0x2e, 0x56, 0x31, 0x41, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xca, + 0x02, 0x15, 0x4f, 0x72, 0x79, 0x5c, 0x4b, 0x65, 0x74, 0x6f, 0x5c, 0x41, 0x63, 0x6c, 0x5c, 0x56, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/proto/ory/keto/acl/v1alpha1/check_service.proto b/proto/ory/keto/acl/v1alpha1/check_service.proto index 88135a966..e64ddde3a 100644 --- a/proto/ory/keto/acl/v1alpha1/check_service.proto +++ b/proto/ory/keto/acl/v1alpha1/check_service.proto @@ -73,6 +73,11 @@ message CheckRequest { // ACLs had already been replicated to all availability zones. // --> string snaptoken = 6; + // The maximum depth to search for a relation. + // + // If the value is less than 1 or greater than the global + // max-depth then the global max-depth will be used instead. + int32 max_depth = 7; } // The response for a CheckService.Check rpc. diff --git a/proto/ory/keto/acl/v1alpha1/check_service_pb.d.ts b/proto/ory/keto/acl/v1alpha1/check_service_pb.d.ts index 9ab9ecc87..2a2eac742 100644 --- a/proto/ory/keto/acl/v1alpha1/check_service_pb.d.ts +++ b/proto/ory/keto/acl/v1alpha1/check_service_pb.d.ts @@ -23,6 +23,8 @@ export class CheckRequest extends jspb.Message { setLatest(value: boolean): CheckRequest; getSnaptoken(): string; setSnaptoken(value: string): CheckRequest; + getMaxDepth(): number; + setMaxDepth(value: number): CheckRequest; serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): CheckRequest.AsObject; @@ -42,6 +44,7 @@ export namespace CheckRequest { subject?: ory_keto_acl_v1alpha1_acl_pb.Subject.AsObject, latest: boolean, snaptoken: string, + maxDepth: number, } } diff --git a/proto/ory/keto/acl/v1alpha1/check_service_pb.js b/proto/ory/keto/acl/v1alpha1/check_service_pb.js index c26177bd7..4297855c7 100644 --- a/proto/ory/keto/acl/v1alpha1/check_service_pb.js +++ b/proto/ory/keto/acl/v1alpha1/check_service_pb.js @@ -95,7 +95,8 @@ proto.ory.keto.acl.v1alpha1.CheckRequest.toObject = function(includeInstance, ms relation: jspb.Message.getFieldWithDefault(msg, 3, ""), subject: (f = msg.getSubject()) && ory_keto_acl_v1alpha1_acl_pb.Subject.toObject(includeInstance, f), latest: jspb.Message.getBooleanFieldWithDefault(msg, 5, false), - snaptoken: jspb.Message.getFieldWithDefault(msg, 6, "") + snaptoken: jspb.Message.getFieldWithDefault(msg, 6, ""), + maxDepth: jspb.Message.getFieldWithDefault(msg, 7, 0) }; if (includeInstance) { @@ -157,6 +158,10 @@ proto.ory.keto.acl.v1alpha1.CheckRequest.deserializeBinaryFromReader = function( var value = /** @type {string} */ (reader.readString()); msg.setSnaptoken(value); break; + case 7: + var value = /** @type {number} */ (reader.readInt32()); + msg.setMaxDepth(value); + break; default: reader.skipField(); break; @@ -229,6 +234,13 @@ proto.ory.keto.acl.v1alpha1.CheckRequest.serializeBinaryToWriter = function(mess f ); } + f = message.getMaxDepth(); + if (f !== 0) { + writer.writeInt32( + 7, + f + ); + } }; @@ -359,6 +371,24 @@ proto.ory.keto.acl.v1alpha1.CheckRequest.prototype.setSnaptoken = function(value }; +/** + * optional int32 max_depth = 7; + * @return {number} + */ +proto.ory.keto.acl.v1alpha1.CheckRequest.prototype.getMaxDepth = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 7, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.ory.keto.acl.v1alpha1.CheckRequest} returns this + */ +proto.ory.keto.acl.v1alpha1.CheckRequest.prototype.setMaxDepth = function(value) { + return jspb.Message.setProto3IntField(this, 7, value); +}; + + diff --git a/proto/ory/keto/acl/v1alpha1/expand_service.pb.go b/proto/ory/keto/acl/v1alpha1/expand_service.pb.go index 5adc07a8a..d716379b1 100644 --- a/proto/ory/keto/acl/v1alpha1/expand_service.pb.go +++ b/proto/ory/keto/acl/v1alpha1/expand_service.pb.go @@ -90,6 +90,10 @@ type ExpandRequest struct { // The subject to expand. Subject *Subject `protobuf:"bytes,1,opt,name=subject,proto3" json:"subject,omitempty"` // The maximum depth of tree to build. + // + // If the value is less than 1 or greater than the global + // max-depth then the global max-depth will be used instead. + // // It is important to set this parameter to a meaningful // value. Ponder how deep you really want to display this. MaxDepth int32 `protobuf:"varint,2,opt,name=max_depth,json=maxDepth,proto3" json:"max_depth,omitempty"` diff --git a/proto/ory/keto/acl/v1alpha1/expand_service.proto b/proto/ory/keto/acl/v1alpha1/expand_service.proto index 08fc7c5a9..0e89385f9 100644 --- a/proto/ory/keto/acl/v1alpha1/expand_service.proto +++ b/proto/ory/keto/acl/v1alpha1/expand_service.proto @@ -26,6 +26,10 @@ message ExpandRequest { // The subject to expand. Subject subject = 1; // The maximum depth of tree to build. + // + // If the value is less than 1 or greater than the global + // max-depth then the global max-depth will be used instead. + // // It is important to set this parameter to a meaningful // value. Ponder how deep you really want to display this. int32 max_depth = 2;