// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package check import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "connectrpc.com/connect" "github.com/pkg/errors" "google.golang.org/protobuf/reflect/protoreflect" "github.com/ory/herodot" "github.com/ory/x/httprouterx" "github.com/ory/x/httpx" "github.com/ory/x/logrusx" rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2" "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect" "github.com/ory/keto/internal/driver/config" "github.com/ory/keto/internal/relationtuple" "github.com/ory/keto/internal/x" "github.com/ory/keto/ketoapi" ) type ( handlerDependencies interface { EngineProvider relationtuple.ManagerProvider relationtuple.MapperProvider logrusx.Provider httpx.WriterProvider config.Provider x.HandlerOptionsProvider } Handler struct { relationtuplesconnect.UnimplementedCheckServiceHandler d handlerDependencies } ) var ( _ relationtuplesconnect.CheckServiceHandler = (*Handler)(nil) _ *checkPermission = nil ) func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} } const ( RouteBase = "/relation-tuples/check" OpenAPIRouteBase = RouteBase + "/openapi" BatchRoute = "/relation-tuples/batch/check" ) func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) { r.GET(RouteBase, h.getCheckMirrorStatus) r.GET(OpenAPIRouteBase, h.getCheckNoStatus) r.POST(RouteBase, h.postCheckMirrorStatus) r.POST(OpenAPIRouteBase, h.postCheckNoStatus) r.POST(BatchRoute, h.batchCheck) checkServiceMethods := rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto.Services().ByName("CheckService").Methods() checkHandler := connect.NewUnaryHandler( relationtuplesconnect.CheckServiceCheckProcedure, h.Check, connect.WithSchema(checkServiceMethods.ByName("Check")), connect.WithHandlerOptions(h.d.HandlerOptions()...), ) r.Handle(relationtuplesconnect.CheckServiceCheckProcedure, checkHandler) batchCheckHandler := connect.NewUnaryHandler( relationtuplesconnect.CheckServiceBatchCheckProcedure, h.BatchCheck, connect.WithSchema(checkServiceMethods.ByName("BatchCheck")), connect.WithHandlerOptions(h.d.HandlerOptions()...), ) r.Handle(relationtuplesconnect.CheckServiceBatchCheckProcedure, batchCheckHandler) } func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor { return []protoreflect.FileDescriptor{ rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto, rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto, } } // Check Permission Result // // The content of the allowed field is mirrored in the HTTP status code. // // swagger:model checkPermissionResult type CheckPermissionResult struct { // whether the relation tuple is allowed // // required: true Allowed bool `json:"allowed"` } // Check Permission Result With Error // // swagger:model checkPermissionResultWithError type CheckPermissionResultWithError struct { // whether the relation tuple is allowed // // required: true Allowed bool `json:"allowed"` // any error generated while checking the relation tuple // // required: false Error string `json:"error,omitempty"` } // Check Permission Request Parameters // // swagger:parameters checkPermission type checkPermission struct { // in: query MaxDepth int `json:"max-depth"` } // swagger:route GET /relation-tuples/check/openapi permission checkPermission // // # Check a permission // // To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview). // // Consumes: // - application/x-www-form-urlencoded // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: checkPermissionResult // 400: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: keto-public-high func (h *Handler) getCheckNoStatus(w http.ResponseWriter, r *http.Request) { allowed, err := h.getCheck(r.Context(), r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, err) return } h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed}) } // Check Permission Or Error Request Parameters // // swagger:parameters checkPermissionOrError // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type checkPermissionOrError struct { // in: query MaxDepth int `json:"max-depth"` } // swagger:route GET /relation-tuples/check permission checkPermissionOrError // // # Check a permission // // To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview). // // Consumes: // - application/x-www-form-urlencoded // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: checkPermissionResult // 400: errorGeneric // 403: checkPermissionResult // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: keto-public-high func (h *Handler) getCheckMirrorStatus(w http.ResponseWriter, r *http.Request) { allowed, err := h.getCheck(r.Context(), r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, err) return } if allowed { h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed}) return } h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed}) } func (h *Handler) getCheck(ctx context.Context, q url.Values) (bool, error) { maxDepth, err := x.GetMaxDepthFromQuery(q) if err != nil { return false, err } tuple, err := (&ketoapi.RelationTuple{}).FromURLQuery(q) if err != nil { return false, err } it, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple) // herodot.ErrNotFound occurs when the namespace is unknown if errors.Is(err, herodot.ErrNotFound()) { return false, nil } else if err != nil { return false, err } return h.d.PermissionEngine().CheckIsMember(ctx, it[0], maxDepth) } // Check Permission using Post Request Parameters // // swagger:parameters postCheckPermission // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type postCheckPermission struct { // in: query MaxDepth int `json:"max-depth"` // in: body Payload postCheckPermissionBody } // Check Permission using Post Request Body // // swagger:model postCheckPermissionBody // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type postCheckPermissionBody struct { ketoapi.RelationQuery } // swagger:route POST /relation-tuples/check/openapi permission postCheckPermission // // # Check a permission // // To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview). // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: checkPermissionResult // 400: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: keto-public-high func (h *Handler) postCheckNoStatus(w http.ResponseWriter, r *http.Request) { allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, err) return } h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed}) } // Post Check Permission Or Error Request Parameters // // swagger:parameters postCheckPermissionOrError // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type postCheckPermissionOrError struct { // in: query MaxDepth int `json:"max-depth"` // in: body Body postCheckPermissionOrErrorBody } // Post Check Permission Or Error Body // // swagger:model postCheckPermissionOrErrorBody // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type postCheckPermissionOrErrorBody struct { ketoapi.RelationQuery } // swagger:route POST /relation-tuples/check permission postCheckPermissionOrError // // # Check a permission // // To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview). // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: checkPermissionResult // 400: errorGeneric // 403: checkPermissionResult // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: keto-public-high func (h *Handler) postCheckMirrorStatus(w http.ResponseWriter, r *http.Request) { allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, err) return } if allowed { h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed}) return } h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed}) } func (h *Handler) postCheck(ctx context.Context, body io.Reader, query url.Values) (bool, error) { maxDepth, err := x.GetMaxDepthFromQuery(query) if err != nil { return false, err } var tuple ketoapi.RelationTuple if err := json.NewDecoder(body).Decode(&tuple); err != nil { return false, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error())) } t, err := h.d.ReadOnlyMapper().FromTuple(ctx, &tuple) // herodot.ErrNotFound occurs when the namespace is unknown if errors.Is(err, herodot.ErrNotFound()) { return false, nil } else if err != nil { return false, err } return h.d.PermissionEngine().CheckIsMember(ctx, t[0], maxDepth) } func (h *Handler) Check(ctx context.Context, req *connect.Request[rts.CheckRequest]) (*connect.Response[rts.CheckResponse], error) { var src ketoapi.TupleData if req.Msg.Tuple != nil { src = req.Msg.Tuple } else { src = req.Msg } tuple, err := (&ketoapi.RelationTuple{}).FromDataProvider(src) if err != nil { return nil, err } internalTuple, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple) if err != nil { return nil, err } allowed, err := h.d.PermissionEngine().CheckIsMember(ctx, internalTuple[0], int(req.Msg.MaxDepth)) // TODO add content change handling if err != nil { return nil, err } return connect.NewResponse(&rts.CheckResponse{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value. Allowed: allowed, Snaptoken: "not yet implemented", }), nil } // Batch Check Permission Request Parameters // // swagger:parameters batchCheckPermission // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type batchCheckPermission struct { // in: query MaxDepth int `json:"max-depth"` // in: body Body batchCheckPermissionBody } // Batch Check Permission Body // // swagger:model batchCheckPermissionBody // //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type batchCheckPermissionBody struct { Tuples []*ketoapi.RelationTuple `json:"tuples"` } // Batch Check Permission Result // // swagger:model batchCheckPermissionResult type BatchCheckPermissionResult struct { // An array of check results. The order aligns with the input order. // // required: true Results []*CheckPermissionResultWithError `json:"results"` } // swagger:route POST /relation-tuples/batch/check permission batchCheckPermission // // # Batch check permissions // // To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview). // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: batchCheckPermissionResult // 400: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: keto-public-high func (h *Handler) batchCheck(w http.ResponseWriter, r *http.Request) { results, err := h.doBatchCheck(r.Context(), r.Body, r.URL.Query()) if err != nil { h.d.Writer().WriteError(w, r, err) return } h.d.Writer().Write(w, r, &BatchCheckPermissionResult{Results: results}) } // doBatchCheck is the HTTP entry point for checking batches of tuples func (h *Handler) doBatchCheck(ctx context.Context, body io.Reader, query url.Values) ([]*CheckPermissionResultWithError, error) { maxDepth, err := x.GetMaxDepthFromQuery(query) if err != nil { return nil, err } var request batchCheckPermissionBody if err := json.NewDecoder(body).Decode(&request); err != nil { return nil, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error())) } if len(request.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() { return nil, herodot.ErrBadRequest().WithErrorf("batch exceeds max size of %v", h.d.Config(ctx).BatchCheckMaxBatchSize()) } results, err := h.d.PermissionEngine().BatchCheck(ctx, request.Tuples, maxDepth) if err != nil { return nil, err } strict := h.d.Config(ctx).StrictMode() responses := make([]*CheckPermissionResultWithError, len(request.Tuples)) for i, result := range results { responses[i] = &CheckPermissionResultWithError{ Allowed: result.Membership == IsMember, Error: batchResultErrMsg(result, strict), } } return responses, nil } // BatchCheck is the gRPC entry point for checking batches of tuples func (h *Handler) BatchCheck(ctx context.Context, req *connect.Request[rts.BatchCheckRequest]) (*connect.Response[rts.BatchCheckResponse], error) { if len(req.Msg.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf( "batch exceeds max size of %v", h.d.Config(ctx).BatchCheckMaxBatchSize())) } ketoTuples := make([]*ketoapi.RelationTuple, len(req.Msg.Tuples)) for i, tuple := range req.Msg.Tuples { ketoTuples[i] = (&ketoapi.RelationTuple{}).FromProto(tuple) } results, err := h.d.PermissionEngine().BatchCheck(ctx, ketoTuples, int(req.Msg.MaxDepth)) if err != nil { return nil, err } strict := h.d.Config(ctx).StrictMode() responses := make([]*rts.CheckResponseWithError, len(results)) for i, result := range results { responses[i] = &rts.CheckResponseWithError{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value. Allowed: result.Membership == IsMember, Error: batchResultErrMsg(result, strict), Snaptoken: "not yet implemented", } } return connect.NewResponse(&rts.BatchCheckResponse{ Results: responses, }), nil } // batchResultErrMsg returns an error string for a single batch check result. // In strict mode a limitation is surfaced so callers can distinguish // "definitely not a member" from "could not determine membership". func batchResultErrMsg(r Result, strict bool) string { if r.Err != nil { return r.Err.Error() } if r.Membership == MembershipUnknown && r.Limitation != "" && strict { return string(r.Limitation) } return "" }