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
416 lines
14 KiB
Go
416 lines
14 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package check_test
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/ory/x/httprouterx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/status"
|
|
|
|
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
|
"github.com/ory/keto/internal/check"
|
|
"github.com/ory/keto/internal/driver"
|
|
"github.com/ory/keto/internal/driver/config"
|
|
client "github.com/ory/keto/internal/httpclient"
|
|
"github.com/ory/keto/internal/namespace"
|
|
"github.com/ory/keto/internal/testhelpers"
|
|
"github.com/ory/keto/ketoapi"
|
|
)
|
|
|
|
func readBody(t *testing.T, resp *http.Response) []byte {
|
|
t.Helper()
|
|
defer func() { assert.NoError(t, resp.Body.Close()) }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
return body
|
|
}
|
|
|
|
func assertAllowed(t *testing.T, resp *http.Response) {
|
|
t.Helper()
|
|
body := readBody(t, resp)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
|
allowed := gjson.GetBytes(body, "allowed")
|
|
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
|
require.True(t, allowed.Bool())
|
|
}
|
|
|
|
type responseAssertion func(t *testing.T, resp *http.Response)
|
|
|
|
func baseAssertDenied(t *testing.T, resp *http.Response) {
|
|
t.Helper()
|
|
body := readBody(t, resp)
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s", body)
|
|
allowed := gjson.GetBytes(body, "allowed")
|
|
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
|
require.False(t, allowed.Bool())
|
|
}
|
|
|
|
// For OpenAPI clients, we want to always return a 200 status code even if the
|
|
// check returned "denied" to not cause exceptions etc. in the generated clients.
|
|
func openAPIAssertDenied(t *testing.T, resp *http.Response) {
|
|
t.Helper()
|
|
body := readBody(t, resp)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
|
allowed := gjson.GetBytes(body, "allowed")
|
|
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
|
require.False(t, allowed.Bool())
|
|
}
|
|
|
|
func newTestGRPCCheckClient(t *testing.T, h *check.Handler) rts.CheckServiceClient {
|
|
t.Helper()
|
|
router := httprouterx.NewRouterPublic()
|
|
h.RegisterReadRoutes(router)
|
|
ts := httptest.NewUnstartedServer(router)
|
|
ts.EnableHTTP2 = true
|
|
ts.StartTLS()
|
|
t.Cleanup(ts.Close)
|
|
|
|
rootCA := x509.NewCertPool()
|
|
rootCA.AddCert(ts.Certificate())
|
|
|
|
conn, err := grpc.NewClient(ts.Listener.Addr().String(),
|
|
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{RootCAs: rootCA})),
|
|
)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
|
return rts.NewCheckServiceClient(conn)
|
|
}
|
|
|
|
func buildBatchURL(baseURL, maxDepth string) string {
|
|
return fmt.Sprintf("%s%s?max-depth=%s", baseURL, check.BatchRoute, maxDepth)
|
|
}
|
|
|
|
func TestCheckRESTHandler(t *testing.T) {
|
|
nspaces := []*namespace.Namespace{{
|
|
Name: "ns",
|
|
}}
|
|
|
|
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
|
h := check.NewHandler(reg)
|
|
r := httprouterx.NewRouterPublic()
|
|
h.RegisterReadRoutes(r)
|
|
ts := httptest.NewServer(r)
|
|
t.Cleanup(ts.Close)
|
|
|
|
for _, suite := range []struct {
|
|
name string
|
|
base string
|
|
assertDenied responseAssertion
|
|
}{
|
|
{name: "base", base: check.RouteBase, assertDenied: baseAssertDenied},
|
|
{name: "openapi", base: check.OpenAPIRouteBase, assertDenied: openAPIAssertDenied},
|
|
} {
|
|
t.Run("suite="+suite.name, func(t *testing.T) {
|
|
assertDenied := suite.assertDenied
|
|
|
|
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
|
|
resp, err := ts.Client().Get(ts.URL + suite.base + "?max-depth=foo")
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
|
})
|
|
|
|
t.Run("case=returns bad request on malformed input", func(t *testing.T) {
|
|
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + url.Values{
|
|
"subject": {"not#a valid userset rewrite"},
|
|
}.Encode())
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("case=returns bad request on missing subject", func(t *testing.T) {
|
|
resp, err := ts.Client().Get(ts.URL + suite.base)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Contains(t, string(readBody(t, resp)), "subject is not allowed to be nil")
|
|
})
|
|
|
|
t.Run("case=returns denied on unknown namespace", func(t *testing.T) {
|
|
rt := testhelpers.APITupleFromString(t, "not:o#r@s")
|
|
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
|
require.NoError(t, err)
|
|
|
|
assertDenied(t, resp)
|
|
})
|
|
|
|
t.Run("case=returns allowed", func(t *testing.T) {
|
|
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
|
testhelpers.MapAndInsertTuples(t, reg, rt)
|
|
|
|
q := rt.ToURLQuery()
|
|
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + q.Encode())
|
|
require.NoError(t, err)
|
|
|
|
assertAllowed(t, resp)
|
|
})
|
|
|
|
t.Run("case=returns denied", func(t *testing.T) {
|
|
rt := testhelpers.APITupleFromString(t, "ns:foo#r@s")
|
|
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
|
require.NoError(t, err)
|
|
|
|
assertDenied(t, resp)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBatchCheckHandler(t *testing.T) {
|
|
nspaces := []*namespace.Namespace{{Name: "ns"}}
|
|
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
|
h := check.NewHandler(reg)
|
|
|
|
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
|
testhelpers.MapAndInsertTuples(t, reg, rt)
|
|
|
|
// REST setup.
|
|
router := httprouterx.NewRouterPublic()
|
|
h.RegisterReadRoutes(router)
|
|
ts := httptest.NewServer(router)
|
|
t.Cleanup(ts.Close)
|
|
|
|
// gRPC setup.
|
|
checkClient := newTestGRPCCheckClient(t, h)
|
|
|
|
t.Run("case=REST: validates request format", func(t *testing.T) {
|
|
t.Run("non-int max depth", func(t *testing.T) {
|
|
resp, err := ts.Client().Post(ts.URL+check.BatchRoute+"?max-depth=foo",
|
|
"application/json", nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
|
})
|
|
|
|
t.Run("invalid JSON body", func(t *testing.T) {
|
|
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"),
|
|
"application/json", strings.NewReader("not-json"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Contains(t, string(readBody(t, resp)), "could not unmarshal json")
|
|
})
|
|
})
|
|
|
|
t.Run("case=validates max batch size", func(t *testing.T) {
|
|
t.Run("protocol=REST", func(t *testing.T) {
|
|
tuples := make([]client.Relationship, 11)
|
|
for i := range tuples {
|
|
tuples[i] = relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s"))
|
|
}
|
|
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: tuples})
|
|
require.NoError(t, err)
|
|
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Contains(t, string(readBody(t, resp)), "batch exceeds max size of 10")
|
|
})
|
|
|
|
t.Run("protocol=gRPC", func(t *testing.T) {
|
|
tuples := make([]*rts.RelationTuple, 11)
|
|
for i := range tuples {
|
|
tuples[i] = testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto()
|
|
}
|
|
_, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: tuples, MaxDepth: 5})
|
|
st, ok := status.FromError(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, codes.InvalidArgument, st.Code())
|
|
require.Equal(t, "batch exceeds max size of 10", st.Message())
|
|
})
|
|
})
|
|
|
|
t.Run("case=returns correct results per tuple", func(t *testing.T) {
|
|
// Three tuples: allowed, not-allowed, unknown namespace.
|
|
restTuples := []client.Relationship{
|
|
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s")), // allowed
|
|
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o2#r@s")), // not allowed
|
|
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "n2:o#r@s")), // unknown namespace
|
|
}
|
|
grpcTuples := []*rts.RelationTuple{
|
|
testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto(), // allowed
|
|
testhelpers.APITupleFromString(t, "ns:o2#r@s").ToProto(), // not allowed
|
|
testhelpers.APITupleFromString(t, "n2:o#r@s").ToProto(), // unknown namespace
|
|
}
|
|
|
|
t.Run("protocol=REST", func(t *testing.T) {
|
|
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: restTuples})
|
|
require.NoError(t, err)
|
|
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result client.BatchCheckPermissionResult
|
|
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
|
require.Equal(t, client.BatchCheckPermissionResult{
|
|
Results: []client.CheckPermissionResultWithError{
|
|
{Allowed: true},
|
|
{Allowed: false},
|
|
{Allowed: false, Error: new("The requested resource could not be found")},
|
|
},
|
|
}, result)
|
|
})
|
|
|
|
t.Run("protocol=gRPC", func(t *testing.T) {
|
|
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: grpcTuples, MaxDepth: 5})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Results, 3)
|
|
require.True(t, resp.Results[0].Allowed)
|
|
require.Empty(t, resp.Results[0].Error)
|
|
require.False(t, resp.Results[1].Allowed)
|
|
require.Empty(t, resp.Results[1].Error)
|
|
require.False(t, resp.Results[2].Allowed)
|
|
require.Equal(t, "The requested resource could not be found", resp.Results[2].Error)
|
|
})
|
|
})
|
|
}
|
|
|
|
// relationshipFromAPITuple converts a ketoapi.RelationTuple to a client.Relationship
|
|
// for use in REST batch check requests.
|
|
func relationshipFromAPITuple(tuple *ketoapi.RelationTuple) client.Relationship {
|
|
rel := client.Relationship{
|
|
Namespace: tuple.Namespace,
|
|
Object: tuple.Object,
|
|
Relation: tuple.Relation,
|
|
}
|
|
if tuple.SubjectID != nil {
|
|
rel.SubjectId = tuple.SubjectID
|
|
} else if tuple.SubjectSet != nil {
|
|
rel.SubjectSet = &client.SubjectSet{
|
|
Namespace: tuple.SubjectSet.Namespace,
|
|
Object: tuple.SubjectSet.Object,
|
|
Relation: tuple.SubjectSet.Relation,
|
|
}
|
|
}
|
|
return rel
|
|
}
|
|
|
|
func TestCheckStrictModeLimit(t *testing.T) {
|
|
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
|
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadDepth, 2))
|
|
h := check.NewHandler(reg)
|
|
|
|
// This tuple has a depth of 3, which exceeds the configured limit of 2, causing to hit the limitation.
|
|
tupleQuery := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice").ToURLQuery()
|
|
|
|
t.Run("case=HTTP: returns 422 with limitation kind", func(t *testing.T) {
|
|
router := httprouterx.NewRouterPublic()
|
|
h.RegisterReadRoutes(router)
|
|
ts := httptest.NewServer(router)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?max-depth=2&" + tupleQuery.Encode())
|
|
require.NoError(t, err)
|
|
body := readBody(t, resp)
|
|
|
|
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "%s", body)
|
|
require.Equal(t, "The check could not be completed", gjson.GetBytes(body, "error.message").String())
|
|
require.Contains(t, gjson.GetBytes(body, "error.reason").String(), string(check.LimitationMaxDepthExceeded))
|
|
})
|
|
|
|
t.Run("case=gRPC: returns FailedPrecondition with limitation kind", func(t *testing.T) {
|
|
checkClient := newTestGRPCCheckClient(t, h)
|
|
tuple := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice")
|
|
_, err := checkClient.Check(t.Context(), &rts.CheckRequest{
|
|
Tuple: tuple.ToProto(),
|
|
MaxDepth: 2,
|
|
})
|
|
require.Error(t, err)
|
|
|
|
st, ok := status.FromError(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, codes.FailedPrecondition, st.Code())
|
|
require.Equal(t, "The check could not be completed", st.Message())
|
|
|
|
var ei *errdetails.ErrorInfo
|
|
for _, d := range st.Details() {
|
|
if info, ok := d.(*errdetails.ErrorInfo); ok {
|
|
ei = info
|
|
break
|
|
}
|
|
}
|
|
require.NotNilf(t, ei, "%+v", st.Details())
|
|
require.Contains(t, ei.Reason, string(check.LimitationMaxDepthExceeded))
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestBatchCheckLimitHandler(t *testing.T) {
|
|
for _, scenario := range []testhelpers.Scenario{
|
|
widthLimitScenario,
|
|
widthLimitScenario.WithStrict(true),
|
|
} {
|
|
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadWidth, 1))
|
|
h := check.NewHandler(reg)
|
|
strict := scenario.Strict
|
|
|
|
// Bob is only in the truncated branch → MembershipUnknown at width=1.
|
|
tuple := testhelpers.APITupleFromString(t, "Resource:doc#readers@User:Bob")
|
|
|
|
t.Run("case=REST: strict mode surfaces limitation in error field", func(t *testing.T) {
|
|
router := httprouterx.NewRouterPublic()
|
|
h.RegisterReadRoutes(router)
|
|
ts := httptest.NewServer(router)
|
|
t.Cleanup(ts.Close)
|
|
|
|
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{
|
|
Tuples: []client.Relationship{relationshipFromAPITuple(tuple)},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
resp, err := ts.Client().Post(
|
|
fmt.Sprintf("%s%s?max-depth=10", ts.URL, check.BatchRoute),
|
|
"application/json", bytes.NewReader(bodyBytes))
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result client.BatchCheckPermissionResult
|
|
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
|
require.Len(t, result.Results, 1)
|
|
require.False(t, result.Results[0].Allowed)
|
|
if strict {
|
|
require.NotNil(t, result.Results[0].Error)
|
|
require.Contains(t, *result.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
|
} else {
|
|
require.Empty(t, result.Results[0].Error)
|
|
}
|
|
})
|
|
|
|
t.Run("case=gRPC: strict mode surfaces limitation in error field", func(t *testing.T) {
|
|
checkClient := newTestGRPCCheckClient(t, h)
|
|
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{
|
|
Tuples: []*rts.RelationTuple{tuple.ToProto()},
|
|
MaxDepth: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Results, 1)
|
|
require.False(t, resp.Results[0].Allowed)
|
|
if strict {
|
|
require.Contains(t, resp.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
|
} else {
|
|
require.Empty(t, resp.Results[0].Error)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|