snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
92
cmd/check/root.go
Normal file
92
cmd/check/root.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/cmd/helpers"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
type checkOutput struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
func (o *checkOutput) String() string {
|
||||
if o.Allowed {
|
||||
return "Allowed\n"
|
||||
}
|
||||
return "Denied\n"
|
||||
}
|
||||
|
||||
const FlagMaxDepth = "max-depth"
|
||||
|
||||
func NewCheckCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "check <subject_namespace>:<subject_id> <relation> <object_namespace>:<object_id>",
|
||||
Short: "Check whether a subject has a relation on an object",
|
||||
Long: "Check whether a subject has a relation on an object.\n\n" +
|
||||
"Example:\n" +
|
||||
" keto check User:Alice view Doc:readme\n\n" +
|
||||
"Legacy format 'keto check <subject_namespace>:<subject_id> <relation> <object_namespace> <object_id>' is deprecated.",
|
||||
Args: cobra.RangeArgs(3, 4),
|
||||
RunE: check,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func check(cmd *cobra.Command, args []string) error {
|
||||
namespace, object, err := helpers.ParseNamespaceObject(cmd, args[2:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub, err := helpers.ParseSubject(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse subject %q: %w", args[0], err)
|
||||
}
|
||||
conn, err := client.GetReadConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
maxDepth, err := cmd.Flags().GetInt32(FlagMaxDepth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cl := rts.NewCheckServiceClient(conn)
|
||||
resp, err := cl.Check(cmd.Context(), &rts.CheckRequest{
|
||||
Tuple: &rts.RelationTuple{
|
||||
Namespace: namespace,
|
||||
Object: object,
|
||||
Relation: args[1],
|
||||
Subject: sub,
|
||||
},
|
||||
MaxDepth: maxDepth,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
cmdx.PrintJSONAble(cmd, &checkOutput{Allowed: resp.Allowed})
|
||||
return nil
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command) {
|
||||
parent.AddCommand(NewCheckCmd())
|
||||
}
|
||||
92
cmd/check/root_test.go
Normal file
92
cmd/check/root_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/cmd/relationtuple"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
)
|
||||
|
||||
func TestCheckCommand(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
relationtuple.RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser}, newCmd)
|
||||
|
||||
tuple1 := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
tuple2 := clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
ts.Cmd.ExecNoErr(t, "relation-tuple", "create", tuple2.SubjectSet.String(), tuple2.Relation, tuple2.Namespace+":"+tuple2.Object)
|
||||
ts.Cmd.ExecNoErr(t, "relation-tuple", "create", *tuple1.SubjectID, tuple1.Relation, tuple1.Namespace+":"+tuple1.Object)
|
||||
|
||||
t.Run("case=SubjectSet", func(t *testing.T) {
|
||||
subject := tuple2.SubjectSet.String()
|
||||
rel := tuple2.Relation
|
||||
nsObj := tuple2.Namespace + ":" + tuple2.Object
|
||||
|
||||
noPermSubject := tuple2.SubjectSet.Namespace + ":no-perm-subject"
|
||||
|
||||
t.Run("case=allowed when tuple exists", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "check", subject, rel, nsObj)
|
||||
require.Equal(t, "Allowed\n", stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=denied for unrelated subject", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "check", noPermSubject, rel, nsObj)
|
||||
require.Equal(t, "Denied\n", stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=4-arg format still works with deprecation warning", func(t *testing.T) {
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(nil, "check", subject, rel, tuple2.Namespace, tuple2.Object)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Allowed\n", stdOut)
|
||||
require.Contains(t, stdErr, "deprecated")
|
||||
})
|
||||
|
||||
t.Run("case=errors on invalid namespace:object format", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "check", subject, rel, "no-colon-here")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=SubjectID", func(t *testing.T) {
|
||||
subject := *tuple1.SubjectID
|
||||
rel := tuple1.Relation
|
||||
nsObj := tuple1.Namespace + ":" + tuple1.Object
|
||||
|
||||
noPermSubject := "no-perm-subject"
|
||||
|
||||
t.Run("case=SubjectID, allowed when tuple exists", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "check", subject, rel, nsObj)
|
||||
require.Equal(t, "Allowed\n", stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=denied for unrelated object", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "check", noPermSubject, rel, nspace.Name+":nope")
|
||||
require.Equal(t, "Denied\n", stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=4-arg format still works with deprecation warning", func(t *testing.T) {
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(nil, "check", subject, rel, tuple1.Namespace, tuple1.Object)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Allowed\n", stdOut)
|
||||
require.Contains(t, stdErr, "deprecated")
|
||||
})
|
||||
})
|
||||
}
|
||||
21
cmd/clidoc/main.go
Normal file
21
cmd/clidoc/main.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ory/keto/cmd"
|
||||
|
||||
"github.com/ory/x/clidoc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := clidoc.Generate(cmd.NewRootCmd(), os.Args[1:]); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%+v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("All files have been generated and updated.")
|
||||
}
|
||||
31
cmd/client/clienttest/fixtures.go
Normal file
31
cmd/client/clienttest/fixtures.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package clienttest
|
||||
|
||||
import (
|
||||
"github.com/ory/x/randx"
|
||||
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func RandomTupleWithSubjectSet(ns1, ns2 string) *ketoapi.RelationTuple {
|
||||
return &ketoapi.RelationTuple{
|
||||
Namespace: ns1,
|
||||
Object: randx.MustString(8, randx.AlphaNum),
|
||||
Relation: randx.MustString(8, randx.AlphaNum),
|
||||
SubjectSet: &ketoapi.SubjectSet{
|
||||
Namespace: ns2,
|
||||
Object: randx.MustString(8, randx.AlphaNum),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RandomTupleWithSubjectID(ns1 string) *ketoapi.RelationTuple {
|
||||
return &ketoapi.RelationTuple{
|
||||
Namespace: ns1,
|
||||
Object: randx.MustString(8, randx.AlphaNum),
|
||||
Relation: randx.MustString(8, randx.AlphaNum),
|
||||
SubjectID: new(randx.MustString(8, randx.AlphaNum)),
|
||||
}
|
||||
}
|
||||
96
cmd/client/clienttest/test_helpers.go
Normal file
96
cmd/client/clienttest/test_helpers.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package clienttest provides an in-process Keto server for testing the CLI
|
||||
// commands. It lives outside of package client because it depends on the whole
|
||||
// Keto server: anything importing package client would otherwise link the
|
||||
// server, the test framework, and their dependencies into its binary.
|
||||
package clienttest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
)
|
||||
|
||||
type (
|
||||
TestServer struct {
|
||||
Reg driver.Registry
|
||||
Cmd *cmdx.CommandExecuter
|
||||
}
|
||||
ServerType string
|
||||
)
|
||||
|
||||
const (
|
||||
WriteServer ServerType = "write"
|
||||
ReadServer ServerType = "read"
|
||||
OplServer ServerType = "opl"
|
||||
)
|
||||
|
||||
func (st ServerType) FlagName() string {
|
||||
switch st {
|
||||
case WriteServer:
|
||||
return client.FlagWriteRemote
|
||||
case ReadServer:
|
||||
return client.FlagReadRemote
|
||||
case OplServer:
|
||||
return client.FlagOplRemote
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown ServerType %s", st))
|
||||
}
|
||||
}
|
||||
|
||||
func NewTestServer(t *testing.T, nspaces []*namespace.Namespace, newCmd func() *cobra.Command, registryOpts ...driver.TestRegistryOption) *TestServer {
|
||||
reg := driver.NewSqliteTestRegistry(t, append(registryOpts, driver.WithSelfSignedTransportCredentials())...)
|
||||
|
||||
require.NoError(t, reg.Config(t.Context()).Set(config.KeyNamespaces, nspaces))
|
||||
|
||||
ts := &TestServer{
|
||||
Reg: reg,
|
||||
Cmd: &cmdx.CommandExecuter{
|
||||
New: newCmd,
|
||||
PersistentArgs: []string{"--insecure-skip-hostname-verification=true"},
|
||||
Ctx: t.Context(),
|
||||
},
|
||||
}
|
||||
ts.serve(t)
|
||||
return ts
|
||||
}
|
||||
|
||||
func (ts *TestServer) serve(t *testing.T, serverTypes ...ServerType) {
|
||||
if len(serverTypes) == 0 {
|
||||
serverTypes = []ServerType{WriteServer, ReadServer}
|
||||
}
|
||||
|
||||
for _, st := range serverTypes {
|
||||
s := httptest.NewUnstartedServer(ts.NewHandler(t, st))
|
||||
s.EnableHTTP2 = true
|
||||
s.StartTLS()
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
ts.Cmd.PersistentArgs = append(ts.Cmd.PersistentArgs, "--"+st.FlagName(), s.Listener.Addr().String())
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TestServer) NewHandler(t *testing.T, st ServerType) http.Handler {
|
||||
switch st {
|
||||
case ReadServer:
|
||||
return ts.Reg.ReadRouter(t.Context())
|
||||
case WriteServer:
|
||||
return ts.Reg.WriteRouter(t.Context())
|
||||
case OplServer:
|
||||
return ts.Reg.OPLSyntaxRouter(t.Context())
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown ServerType %s", st))
|
||||
}
|
||||
}
|
||||
159
cmd/client/grpc_client.go
Normal file
159
cmd/client/grpc_client.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/ory/x/flagx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/credentials/oauth"
|
||||
)
|
||||
|
||||
type contextKeys string
|
||||
|
||||
const (
|
||||
FlagReadRemote = "read-remote"
|
||||
FlagWriteRemote = "write-remote"
|
||||
FlagOplRemote = "syntax-remote"
|
||||
|
||||
FlagInsecureNoTransportSecurity = "insecure-disable-transport-security"
|
||||
FlagInsecureSkipHostVerification = "insecure-skip-hostname-verification"
|
||||
FlagAuthority = "authority"
|
||||
FlagBlock = "block"
|
||||
|
||||
EnvReadRemote = "KETO_READ_REMOTE"
|
||||
EnvWriteRemote = "KETO_WRITE_REMOTE"
|
||||
EnvAuthToken = "KETO_BEARER_TOKEN" //nolint:gosec // just the key, not the value
|
||||
EnvAuthority = "KETO_AUTHORITY"
|
||||
|
||||
ContextKeyTimeout contextKeys = "timeout"
|
||||
ContextKeyDialFunc contextKeys = "dial"
|
||||
)
|
||||
|
||||
type connectionDetails struct {
|
||||
token, authority string
|
||||
skipHostVerification bool
|
||||
noTransportSecurity bool
|
||||
block bool
|
||||
}
|
||||
|
||||
func (d *connectionDetails) dialOptions() (opts []grpc.DialOption) {
|
||||
if d.token != "" {
|
||||
opts = append(opts,
|
||||
grpc.WithPerRPCCredentials(
|
||||
oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: d.token})},
|
||||
))
|
||||
}
|
||||
if d.authority != "" {
|
||||
opts = append(opts, grpc.WithAuthority(d.authority))
|
||||
}
|
||||
|
||||
// TLS settings
|
||||
switch {
|
||||
case d.noTransportSecurity:
|
||||
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
case d.skipHostVerification:
|
||||
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
|
||||
//nolint:gosec // explicity set through scary flag
|
||||
InsecureSkipVerify: true,
|
||||
})))
|
||||
default:
|
||||
// Defaults to the default host root CA bundle
|
||||
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(nil)))
|
||||
}
|
||||
if d.block {
|
||||
opts = append(opts, grpc.WithBlock()) //nolint:staticcheck // SA1019 grpc.WithBlock is deprecated, but we actually want to block here until the connection is up.
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func getRemote(cmd *cobra.Command, flagRemote, envRemote string) (remote string) {
|
||||
defer (func() {
|
||||
if strings.HasPrefix(remote, "http://") || strings.HasPrefix(remote, "https://") {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "remote %q seems to be an http URL instead of a remote address\n", remote)
|
||||
}
|
||||
})()
|
||||
|
||||
if cmd.Flags().Changed(flagRemote) {
|
||||
return flagx.MustGetString(cmd, flagRemote)
|
||||
} else if remote, isSet := os.LookupEnv(envRemote); isSet {
|
||||
return remote
|
||||
}
|
||||
|
||||
// no value is set, use fallback from the flag and warn about that
|
||||
remote = flagx.MustGetString(cmd, flagRemote)
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "neither flag --%s nor env var %s are set, falling back to %s\n", flagRemote, envRemote, remote)
|
||||
return remote
|
||||
}
|
||||
|
||||
func getAuthority(cmd *cobra.Command) string {
|
||||
if cmd.Flags().Changed(FlagAuthority) {
|
||||
return flagx.MustGetString(cmd, FlagAuthority)
|
||||
}
|
||||
return os.Getenv(EnvAuthority)
|
||||
}
|
||||
|
||||
func getConnectionDetails(cmd *cobra.Command) connectionDetails {
|
||||
return connectionDetails{
|
||||
token: os.Getenv(EnvAuthToken),
|
||||
authority: getAuthority(cmd),
|
||||
skipHostVerification: flagx.MustGetBool(cmd, FlagInsecureSkipHostVerification),
|
||||
noTransportSecurity: flagx.MustGetBool(cmd, FlagInsecureNoTransportSecurity),
|
||||
block: flagx.MustGetBool(cmd, "block"),
|
||||
}
|
||||
}
|
||||
|
||||
func GetReadConn(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
||||
return Conn(cmd.Context(),
|
||||
getRemote(cmd, FlagReadRemote, EnvReadRemote),
|
||||
getConnectionDetails(cmd),
|
||||
)
|
||||
}
|
||||
|
||||
func GetWriteConn(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
||||
return Conn(cmd.Context(),
|
||||
getRemote(cmd, FlagWriteRemote, EnvWriteRemote),
|
||||
getConnectionDetails(cmd),
|
||||
)
|
||||
}
|
||||
|
||||
func Conn(ctx context.Context, remote string, details connectionDetails) (*grpc.ClientConn, error) {
|
||||
if d, ok := ctx.Value(ContextKeyTimeout).(time.Duration); ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, d)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if dial, ok := ctx.Value(ContextKeyDialFunc).(func(context.Context, string) (*grpc.ClientConn, error)); ok {
|
||||
return dial(ctx, remote)
|
||||
}
|
||||
|
||||
return grpc.DialContext( // nolint:staticcheck // SA1019 grpc.DialContext is deprecated, but we actually want to connect here right away.
|
||||
ctx,
|
||||
remote,
|
||||
details.dialOptions()...,
|
||||
)
|
||||
}
|
||||
|
||||
func RegisterRemoteURLFlags(flags *pflag.FlagSet) {
|
||||
flags.String(FlagReadRemote, "127.0.0.1:4466", "Remote address of the read API endpoint.")
|
||||
flags.String(FlagWriteRemote, "127.0.0.1:4467", "Remote address of the write API endpoint.")
|
||||
flags.String(FlagAuthority, "", "Set the authority header for the remote gRPC server.")
|
||||
flags.Bool(FlagInsecureNoTransportSecurity, false, "Disables transport security. Do not use this in production.")
|
||||
flags.Bool(FlagInsecureSkipHostVerification, false, "Disables hostname verification. Do not use this in production.")
|
||||
flags.Bool(FlagBlock, false, "Block until the connection is up.")
|
||||
}
|
||||
65
cmd/client/grpc_client_test.go
Normal file
65
cmd/client/grpc_client_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetRemote(t *testing.T) {
|
||||
setup := func() (cmd *cobra.Command, stdOut, stdErr *bytes.Buffer) {
|
||||
cmd = &cobra.Command{}
|
||||
stdOut, stdErr = &bytes.Buffer{}, &bytes.Buffer{}
|
||||
cmd.SetOut(stdOut)
|
||||
cmd.SetErr(stdErr)
|
||||
RegisterRemoteURLFlags(cmd.Flags())
|
||||
return
|
||||
}
|
||||
|
||||
setEnv := func(t *testing.T, env, val string) {
|
||||
require.NoError(t, os.Setenv(env, val))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Unsetenv(EnvReadRemote))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=prefers flag value", func(t *testing.T) {
|
||||
cmd, stdOut, stdErr := setup()
|
||||
|
||||
expectedRemote := "ketotest.oryapis.com"
|
||||
require.NoError(t, cmd.Flags().Set(FlagReadRemote, "ketotest.oryapis.com"))
|
||||
setEnv(t, EnvReadRemote, "not"+expectedRemote)
|
||||
|
||||
assert.Equal(t, expectedRemote, getRemote(cmd, FlagReadRemote, EnvReadRemote))
|
||||
assert.Equal(t, 0, stdOut.Len())
|
||||
assert.Equal(t, 0, stdErr.Len())
|
||||
})
|
||||
|
||||
t.Run("case=uses env value", func(t *testing.T) {
|
||||
cmd, stdOut, stdErr := setup()
|
||||
|
||||
expectedRemote := "ketotest.oryapis.com"
|
||||
setEnv(t, EnvReadRemote, expectedRemote)
|
||||
|
||||
assert.Equal(t, expectedRemote, getRemote(cmd, FlagReadRemote, EnvReadRemote))
|
||||
assert.Equal(t, 0, stdOut.Len())
|
||||
assert.Equal(t, 0, stdErr.Len())
|
||||
})
|
||||
|
||||
t.Run("case=falls back to flag default and prints warning", func(t *testing.T) {
|
||||
cmd, stdOut, stdErr := setup()
|
||||
|
||||
expectedRemote := cmd.Flags().Lookup(FlagReadRemote).DefValue
|
||||
|
||||
assert.Equal(t, expectedRemote, getRemote(cmd, FlagReadRemote, EnvReadRemote))
|
||||
assert.Equal(t, 0, stdOut.Len())
|
||||
assert.Contains(t, stdErr.String(), "falling back")
|
||||
})
|
||||
}
|
||||
87
cmd/expand/root.go
Normal file
87
cmd/expand/root.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package expand
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/cmd/helpers"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
const FlagMaxDepth = "max-depth"
|
||||
|
||||
func NewExpandCmd() *cobra.Command {
|
||||
var maxDepth int32
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "expand <relation> <object_namespace>:<object_id>",
|
||||
Short: "Expand all subjects that have the relation on the object",
|
||||
Long: "Expand all subjects that have the relation on the object, including how access is granted.\n\n" +
|
||||
"Example:\n" +
|
||||
" keto expand view Doc:readme\n\n",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
namespace, object, err := helpers.ParseNamespaceObject(cmd, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := client.GetReadConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cl := rts.NewExpandServiceClient(conn)
|
||||
resp, err := cl.Expand(cmd.Context(), &rts.ExpandRequest{
|
||||
Subject: rts.NewSubjectSet(namespace, object, args[0]),
|
||||
MaxDepth: maxDepth,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
var tree *ketoapi.Tree[*ketoapi.RelationTuple]
|
||||
if resp.Tree != nil {
|
||||
tree = ketoapi.TreeFromProto[*ketoapi.RelationTuple](resp.Tree)
|
||||
}
|
||||
|
||||
cmdx.PrintJSONAble(cmd, tree)
|
||||
format, err := cmd.Flags().GetString(cmdx.FlagFormat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quiet, err := cmd.Flags().GetBool(cmdx.FlagQuiet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch format {
|
||||
case string(cmdx.FormatDefault), "":
|
||||
if tree == nil && !quiet {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Got an empty tree. This probably means that the requested relation tuple is not present in Keto.")
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
client.RegisterRemoteURLFlags(cmd.Flags())
|
||||
cmdx.RegisterJSONFormatFlags(cmd.Flags())
|
||||
cmdx.RegisterNoiseFlags(cmd.Flags())
|
||||
cmd.Flags().Int32VarP(&maxDepth, 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
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command) {
|
||||
parent.AddCommand(NewExpandCmd())
|
||||
}
|
||||
76
cmd/expand/root_test.go
Normal file
76
cmd/expand/root_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package expand
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/cmd/relationtuple"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
)
|
||||
|
||||
func TestExpandCommand(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
relationtuple.RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser}, newCmd)
|
||||
|
||||
tuple := clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
|
||||
nsObj := tuple.Namespace + ":" + tuple.Object
|
||||
rel := tuple.Relation
|
||||
subjectSet := tuple.SubjectSet.String()
|
||||
|
||||
// create tuple with subjectSet
|
||||
ts.Cmd.ExecNoErr(t, "relation-tuple", "create", subjectSet, rel, nsObj)
|
||||
|
||||
t.Run("case=expands existing tuple as JSON", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "expand", rel, nsObj,
|
||||
"--"+cmdx.FlagFormat, string(cmdx.FormatJSON))
|
||||
assert.Contains(t, stdOut, tuple.SubjectSet.Object)
|
||||
})
|
||||
|
||||
t.Run("case=unknown tuple returns single node tree", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "expand", rel, nspace.Name+":unknown-obj",
|
||||
"--"+cmdx.FlagFormat, string(cmdx.FormatJSON))
|
||||
assert.Contains(t, stdOut, rel)
|
||||
assert.Contains(t, stdOut, nspace.Name)
|
||||
})
|
||||
|
||||
t.Run("case=unknown tuple prints empty tree in default format", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "expand", rel, nspace.Name+":unknown-obj")
|
||||
assert.Contains(t, stdOut, rel)
|
||||
assert.Contains(t, stdOut, nspace.Name)
|
||||
})
|
||||
|
||||
t.Run("case=3-arg format still works with deprecation warning", func(t *testing.T) {
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(nil, "expand", rel, nspace.Name, tuple.Object,
|
||||
"--"+cmdx.FlagFormat, string(cmdx.FormatJSON))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdOut, tuple.SubjectSet.Object)
|
||||
require.Contains(t, stdErr, "deprecated")
|
||||
})
|
||||
|
||||
t.Run("case=errors on invalid namespace:object format", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "expand", rel, "no-colon-here")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format")
|
||||
})
|
||||
}
|
||||
46
cmd/helpers/helpers.go
Normal file
46
cmd/helpers/helpers.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func ParseSubject(s string) (*rts.Subject, error) {
|
||||
if strings.Contains(s, ":") {
|
||||
su, err := (&ketoapi.SubjectSet{}).FromString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rts.NewSubjectSet(su.Namespace, su.Object, su.Relation), nil
|
||||
}
|
||||
return rts.NewSubjectID(s), nil
|
||||
}
|
||||
|
||||
// ParseNamespaceObject parses namespace and object from args that may be in the
|
||||
// combined format ["namespace:object"] or the legacy format ["namespace", "object"].
|
||||
// It writes a deprecation warning to cmd.ErrOrStderr if the legacy format is used.
|
||||
func ParseNamespaceObject(cmd *cobra.Command, args []string) (namespace, object string, err error) {
|
||||
switch len(args) {
|
||||
case 2:
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Warning: passing namespace and object as separate arguments is deprecated. Use <object_namespace>:<object_id> instead.")
|
||||
return args[0], args[1], nil
|
||||
case 1:
|
||||
namespace, object, ok := strings.Cut(args[0], ":")
|
||||
// empty ObjectID is allowed
|
||||
if !ok || namespace == "" {
|
||||
return "", "", fmt.Errorf("expected <object_namespace>:<object_id> format, got %q", args[0])
|
||||
}
|
||||
return namespace, object, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unexpected number of arguments for <object_namespace>:<object_id>: got %d arguments - %s", len(args), strings.Join(args, ","))
|
||||
}
|
||||
}
|
||||
39
cmd/helpers/helpers_test.go
Normal file
39
cmd/helpers/helpers_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
func TestParseSubject(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected *rts.Subject
|
||||
}{
|
||||
{
|
||||
input: "nspace:obj#rel",
|
||||
expected: rts.NewSubjectSet("nspace", "obj", "rel"),
|
||||
},
|
||||
{
|
||||
input: "someid",
|
||||
expected: rts.NewSubjectID("someid"),
|
||||
},
|
||||
{
|
||||
input: "nspace:id",
|
||||
expected: rts.NewSubjectSet("nspace", "id", ""),
|
||||
},
|
||||
} {
|
||||
t.Run("subject="+tc.input, func(t *testing.T) {
|
||||
actual, err := ParseSubject(tc.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
86
cmd/migrate/down.go
Normal file
86
cmd/migrate/down.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/flagx"
|
||||
"github.com/ory/x/popx"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func newDownCmd(opts []ketoctx.Option) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "down <steps>",
|
||||
Short: "Migrate the database down",
|
||||
Long: "Migrate the database down a specific amount of steps.\n" +
|
||||
"Pass 0 steps to fully migrate down.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
steps, err := strconv.ParseInt(args[0], 0, 0)
|
||||
if err != nil {
|
||||
// return this error so it gets printed along the usage
|
||||
return fmt.Errorf("malformed argument %s for <steps>: %+v", args[0], err)
|
||||
}
|
||||
|
||||
reg, err := driver.NewDefaultRegistry(cmd.Context(), cmd.Flags(), true, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := reg.PopConnection(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := popx.VerifyDialect(cmd.Context(), conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mb, err := reg.MigrationBox(cmd.Context(), popx.WithoutGoldenDatabase())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return BoxDown(cmd, mb, int(steps))
|
||||
},
|
||||
}
|
||||
|
||||
RegisterYesFlag(cmd.Flags())
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func BoxDown(cmd *cobra.Command, mb *popx.MigrationBox, steps int) error {
|
||||
s, err := mb.Status(cmd.Context())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
cmdx.PrintTable(cmd, s)
|
||||
|
||||
if !flagx.MustGetBool(cmd, FlagYes) && !cmdx.AskForConfirmation("Do you really want to migrate down? This will delete data.", cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Migration aborted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := mb.Down(cmd.Context(), steps); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could apply down migrations: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
s, err = mb.Status(cmd.Context())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
cmdx.PrintTable(cmd, s)
|
||||
return nil
|
||||
}
|
||||
181
cmd/migrate/migrate_test.go
Normal file
181
cmd/migrate/migrate_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/dbal"
|
||||
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/x/dbx"
|
||||
)
|
||||
|
||||
func assertAllApplied(t *testing.T, status string) {
|
||||
assert.NotContains(t, status, "Pending")
|
||||
assert.Contains(t, status, "Applied")
|
||||
}
|
||||
|
||||
func assertNoneApplied(t *testing.T, status string) {
|
||||
assert.Contains(t, status, "Pending")
|
||||
assert.NotContains(t, status, "Applied")
|
||||
}
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nspaces := []*namespace.Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
},
|
||||
}
|
||||
|
||||
newCmd := func(ctx context.Context, persistentArgs ...string) *cmdx.CommandExecuter {
|
||||
return &cmdx.CommandExecuter{
|
||||
New: func() *cobra.Command {
|
||||
cmd := NewMigrateCmd(nil)
|
||||
configx.RegisterFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
},
|
||||
Ctx: ctx,
|
||||
PersistentArgs: persistentArgs,
|
||||
}
|
||||
}
|
||||
|
||||
for _, dsn := range dbx.GetDSNs(t) {
|
||||
if dbal.IsMemorySQLite(dsn.Conn) {
|
||||
t.Run("dsn=memory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("case=auto migrates", func(t *testing.T) {
|
||||
cf := dbx.ConfigFile(t, map[string]interface{}{
|
||||
config.KeyDSN: dsn.Conn,
|
||||
config.KeyNamespaces: nspaces,
|
||||
"log.level": "debug",
|
||||
})
|
||||
|
||||
cmd := newCmd(t.Context(), "-c", cf)
|
||||
|
||||
out := cmd.ExecNoErr(t, "up", "--"+FlagYes)
|
||||
assert.Contains(t, out, "All migrations are already applied, there is nothing to do.")
|
||||
})
|
||||
})
|
||||
} else {
|
||||
t.Run("dsn="+dsn.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cf := dbx.ConfigFile(t, map[string]interface{}{
|
||||
config.KeyDSN: dsn.Conn,
|
||||
config.KeyNamespaces: nspaces,
|
||||
"log.level": "debug",
|
||||
})
|
||||
|
||||
cmd := newCmd(t.Context(), "-c", cf)
|
||||
|
||||
t.Run("case=shows status", func(t *testing.T) {
|
||||
stdOut := cmd.ExecNoErr(t, "status")
|
||||
assert.Contains(t, stdOut, "Pending")
|
||||
assert.NotContains(t, stdOut, "Applied")
|
||||
})
|
||||
|
||||
t.Run("case=status blocks until all are applied", func(t *testing.T) {
|
||||
cmd := *cmd
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd.Ctx = ctx
|
||||
|
||||
stdErr := &bytes.Buffer{}
|
||||
stdOut := &cmdx.CallbackWriter{
|
||||
Callbacks: map[string]func([]byte) error{
|
||||
"Waiting for migrations to finish...": func([]byte) error {
|
||||
cancel()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := cmd.ExecBackground(nil, stdOut, stdErr, "status", "--block").Wait()
|
||||
require.ErrorIs(t, err, cmdx.ErrNoPrintButFail)
|
||||
assert.Contains(t, stdOut.String(), "Waiting for migrations to finish...")
|
||||
assert.Contains(t, stdErr.String(), "Context was canceled, exiting...", stdOut.String())
|
||||
})
|
||||
|
||||
t.Run("case=aborts on no", func(t *testing.T) {
|
||||
stdOut, stdErr, err := cmd.Exec(strings.NewReader("n\n"), "up")
|
||||
require.NoError(t, err, "%s %s", stdOut, stdErr)
|
||||
|
||||
assert.Containsf(t, stdOut, "Pending", "%s %s", stdOut, stdErr)
|
||||
assert.NotContainsf(t, stdOut, "Applied", "%s %s", stdOut, stdErr)
|
||||
assert.Containsf(t, stdOut, "Aborting", "%s %s", stdOut, stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=applies on yes input", func(t *testing.T) {
|
||||
stdOut, stdErr, err := cmd.Exec(bytes.NewBufferString("y\n"), "up")
|
||||
require.NoErrorf(t, err, "%s %s", stdOut, stdErr)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// migrate all down
|
||||
t.Logf("cleanup:\n%s\n", cmd.ExecNoErr(t, "down", "0", "--"+FlagYes))
|
||||
})
|
||||
|
||||
parts := strings.Split(stdOut, "Are you sure that you want to apply this migration?")
|
||||
require.Len(t, parts, 2)
|
||||
|
||||
assertNoneApplied(t, parts[0])
|
||||
assertAllApplied(t, parts[1])
|
||||
})
|
||||
|
||||
t.Run("case=applies on yes flag", func(t *testing.T) {
|
||||
out := cmd.ExecNoErr(t, "up", "--"+FlagYes)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// migrate all down
|
||||
t.Logf("cleanup:\n%s\n", cmd.ExecNoErr(t, "down", "0", "--"+FlagYes))
|
||||
})
|
||||
|
||||
parts := strings.Split(out, "Applying migrations...")
|
||||
require.Len(t, parts, 2)
|
||||
|
||||
assertNoneApplied(t, parts[0])
|
||||
assertAllApplied(t, parts[1])
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpAndDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := NewMigrateCmd(nil)
|
||||
configx.RegisterFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
}
|
||||
for _, dsn := range dbx.GetDSNs(t) {
|
||||
t.Run("dsn="+dsn.Name, func(t *testing.T) {
|
||||
cf := dbx.ConfigFile(t, map[string]interface{}{
|
||||
config.KeyDSN: dsn.Conn,
|
||||
config.KeyNamespaces: []*namespace.Namespace{},
|
||||
})
|
||||
|
||||
t.Log(cmdx.ExecNoErr(t, newCmd(), "up", "-c", cf, "--"+FlagYes))
|
||||
t.Log(cmdx.ExecNoErr(t, newCmd(), "down", "0", "-c", cf, "--"+FlagYes))
|
||||
})
|
||||
}
|
||||
}
|
||||
29
cmd/migrate/root.go
Normal file
29
cmd/migrate/root.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func NewMigrateCmd(opts []ketoctx.Option) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Commands to migrate the database",
|
||||
Long: "Commands to migrate the database.\n" +
|
||||
"This does not affect namespaces. Use `keto namespace migrate` for migrating namespaces.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newStatusCmd(opts),
|
||||
newUpCmd(opts),
|
||||
newDownCmd(opts),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command, opts []ketoctx.Option) {
|
||||
parent.AddCommand(NewMigrateCmd(opts))
|
||||
}
|
||||
82
cmd/migrate/status.go
Normal file
82
cmd/migrate/status.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/popx"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func newStatusCmd(opts []ketoctx.Option) *cobra.Command {
|
||||
block := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get the current migration status",
|
||||
Long: "Get the current migration status.\n" +
|
||||
"This does not affect namespaces. Use `keto namespace migrate status` for migrating namespaces.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
reg, err := driver.NewDefaultRegistry(ctx, cmd.Flags(), true, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := reg.PopConnection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := popx.VerifyDialect(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mb, err := reg.MigrationBox(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := mb.Status(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
for block && s.HasPending() {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Waiting for migrations to finish...\n")
|
||||
for _, m := range s {
|
||||
if m.State == popx.Pending {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", m.Name)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Context was canceled, exiting...")
|
||||
return cmdx.FailSilently(cmd)
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
s, err = mb.Status(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
cmdx.PrintTable(cmd, s)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
cmd.Flags().BoolVar(&block, "block", false, "Block until all migrations have been applied")
|
||||
|
||||
return cmd
|
||||
}
|
||||
114
cmd/migrate/up.go
Normal file
114
cmd/migrate/up.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/flagx"
|
||||
"github.com/ory/x/popx"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagYes = "yes"
|
||||
)
|
||||
|
||||
func newUpCmd(opts []ketoctx.Option) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "up",
|
||||
Short: "Migrate the database up",
|
||||
Long: `Run this command on a fresh SQL installation and when you upgrade Ory Keto from version v0.7.x and later.
|
||||
|
||||
It is recommended to run this command close to the SQL instance (e.g. same subnet) instead of over the public internet.
|
||||
This decreases risk of failure and decreases time required.
|
||||
|
||||
### WARNING ###
|
||||
|
||||
Before running this command on an existing database, create a back up!
|
||||
`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
reg, err := driver.NewDefaultRegistry(ctx, cmd.Flags(), true, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := reg.PopConnection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := popx.VerifyDialect(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mb, err := reg.MigrationBox(ctx, popx.WithoutGoldenDatabase())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := BoxUp(cmd, mb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
RegisterYesFlag(cmd.Flags())
|
||||
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func RegisterYesFlag(flags *pflag.FlagSet) {
|
||||
flags.BoolP(FlagYes, "y", false, "yes to all questions, no user input required")
|
||||
}
|
||||
|
||||
func BoxUp(cmd *cobra.Command, mb *popx.MigrationBox) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Current status:")
|
||||
|
||||
s, err := mb.Status(cmd.Context())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
cmdx.PrintTable(cmd, s)
|
||||
|
||||
if !s.HasPending() {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "All migrations are already applied, there is nothing to do.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !flagx.MustGetBool(cmd, FlagYes) && !cmdx.AskForConfirmation("Are you sure that you want to apply this migration? Make sure to check the CHANGELOG.md for breaking changes beforehand.", cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborting")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Applying migrations...")
|
||||
|
||||
if err := mb.Up(cmd.Context()); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not apply migrations: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Successfully applied all migrations:")
|
||||
|
||||
s, err = mb.Status(cmd.Context())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not get migration status: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
cmdx.PrintTable(cmd, s)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'\n\n// Declare new namespaces as classes that implement `Namespace`"
|
||||
@@ -0,0 +1 @@
|
||||
"import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'\n\n// Declare new namespaces as classes that implement `Namespace`\nclass one implements Namespace {\n related: {\n // Define relations to other objects here.\n // Examples:\n //\n // parents: (File | Folder)[]\n // viewers: SubjectSet\u003cGroup, \"members\"\u003e[]\n }\n\n permits = {\n // Define permissions here. These can be derived from the relations above.\n // Examples:\n //\n // view: (ctx: Context): boolean =\u003e\n // this.related.viewers.includes(ctx.subject) ||\n // this.related.parents.traverse((p) =\u003e p.permits.view(ctx)),\n }\n}\n\nclass two implements Namespace {\n related: {\n // Define relations to other objects here.\n // Examples:\n //\n // parents: (File | Folder)[]\n // viewers: SubjectSet\u003cGroup, \"members\"\u003e[]\n }\n\n permits = {\n // Define permissions here. These can be derived from the relations above.\n // Examples:\n //\n // view: (ctx: Context): boolean =\u003e\n // this.related.viewers.includes(ctx.subject) ||\n // this.related.parents.traverse((p) =\u003e p.permits.view(ctx)),\n }\n}\n\nclass three implements Namespace {\n related: {\n // Define relations to other objects here.\n // Examples:\n //\n // parents: (File | Folder)[]\n // viewers: SubjectSet\u003cGroup, \"members\"\u003e[]\n }\n\n permits = {\n // Define permissions here. These can be derived from the relations above.\n // Examples:\n //\n // view: (ctx: Context): boolean =\u003e\n // this.related.viewers.includes(ctx.subject) ||\n // this.related.parents.traverse((p) =\u003e p.permits.view(ctx)),\n }\n}\n"
|
||||
@@ -0,0 +1 @@
|
||||
"import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'\n\n// Declare new namespaces as classes that implement `Namespace`\nclass one implements Namespace {\n related: {\n // Define relations to other objects here.\n // Examples:\n //\n // parents: (File | Folder)[]\n // viewers: SubjectSet\u003cGroup, \"members\"\u003e[]\n }\n\n permits = {\n // Define permissions here. These can be derived from the relations above.\n // Examples:\n //\n // view: (ctx: Context): boolean =\u003e\n // this.related.viewers.includes(ctx.subject) ||\n // this.related.parents.traverse((p) =\u003e p.permits.view(ctx)),\n }\n}\n"
|
||||
22
cmd/namespace/config_template/namespaces.ts.tmpl
Normal file
22
cmd/namespace/config_template/namespaces.ts.tmpl
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'
|
||||
|
||||
// Declare new namespaces as classes that implement `Namespace`{{ range .Namespaces }}
|
||||
class {{ . }} implements Namespace {
|
||||
related: {
|
||||
// Define relations to other objects here.
|
||||
// Examples:
|
||||
//
|
||||
// parents: (File | Folder)[]
|
||||
// viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
// Define permissions here. These can be derived from the relations above.
|
||||
// Examples:
|
||||
//
|
||||
// view: (ctx: Context): boolean =>
|
||||
// this.related.viewers.includes(ctx.subject) ||
|
||||
// this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||
}
|
||||
}
|
||||
{{ end }}
|
||||
28
cmd/namespace/opl_generate.go
Normal file
28
cmd/namespace/opl_generate.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"text/template"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
//go:embed config_template/*
|
||||
var configTemplate embed.FS
|
||||
|
||||
// GenerateOPLConfig derives an Ory Permission Language config from the
|
||||
// namespaces and writes it to out. The OPL config is functionally equivalent to
|
||||
// the list of namespaces.
|
||||
func GenerateOPLConfig(namespaces []string, out io.Writer) error {
|
||||
t, err := template.New("config_template").ParseFS(configTemplate, "config_template/*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return errors.WithStack(t.ExecuteTemplate(out,
|
||||
"namespaces.ts.tmpl",
|
||||
struct{ Namespaces []string }{Namespaces: namespaces}))
|
||||
}
|
||||
38
cmd/namespace/opl_generate_test.go
Normal file
38
cmd/namespace/opl_generate_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package namespace_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/snapshotx"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/namespace"
|
||||
)
|
||||
|
||||
func TestGenerateOPLConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
namespaces []string
|
||||
}{{
|
||||
name: "empty",
|
||||
namespaces: []string{},
|
||||
}, {
|
||||
name: "one",
|
||||
namespaces: []string{"one"},
|
||||
}, {
|
||||
name: "many",
|
||||
namespaces: []string{"one", "two", "three"},
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, namespace.GenerateOPLConfig(tc.namespaces, &out))
|
||||
snapshotx.SnapshotT(t, out.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
24
cmd/namespace/root.go
Normal file
24
cmd/namespace/root.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func NewNamespaceCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "namespace",
|
||||
Short: "Read and manipulate namespaces",
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command, _ []ketoctx.Option) {
|
||||
rootCmd := NewNamespaceCmd()
|
||||
rootCmd.AddCommand(NewValidateCmd())
|
||||
|
||||
parent.AddCommand(rootCmd)
|
||||
}
|
||||
206
cmd/namespace/validate.go
Normal file
206
cmd/namespace/validate.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/segmentio/objconv/yaml"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
"github.com/ory/keto/embedx"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
)
|
||||
|
||||
func NewValidateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Deprecated: "The legacy namespaces are deprecated. Please use the new Ory Permission Language instead.",
|
||||
Aliases: []string{"validate"},
|
||||
Use: "validate-legacy <namespace.yml> [<namespace2.yml> ...] | validate -c <config.yaml>",
|
||||
Short: "Validate legacy namespace definitions",
|
||||
Long: `validate-legacy
|
||||
Validates namespace definitions. Parses namespace yaml files or configuration
|
||||
files passed via the configuration flag. Returns human readable errors. Useful for
|
||||
debugging.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfFlag := cmd.Flag(configx.FlagConfig)
|
||||
if cfFlag.Changed {
|
||||
cfiles, err := cmd.Flags().GetStringSlice(configx.FlagConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Failed to read config command line flag\n%+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
for _, fn := range cfiles {
|
||||
err := validateConfigFile(cmd, fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// user passed a list of namespace files
|
||||
for _, fn := range args {
|
||||
_, err := validateNamespaceFile(cmd, fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Congrats, all files are valid!")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var configSchema *jsonschema.Schema
|
||||
|
||||
const schemaPath = "github.com/ory/keto/.schema/config.schema.json"
|
||||
|
||||
func getSchema(cmd *cobra.Command) (*jsonschema.Schema, error) {
|
||||
if configSchema == nil {
|
||||
c := jsonschema.NewCompiler()
|
||||
if err := c.AddResource(schemaPath, bytes.NewBuffer(embedx.ConfigSchema)); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not add the config schema file to the compiler. This is an internal error that should be reported. Thanks ;)\n%+v\n", err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
var err error
|
||||
configSchema, err = c.Compile(cmd.Context(), schemaPath+"#/definitions/namespace")
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not compile the config schema file. This is an internal error that should be reported. Thanks ;)\n%+v\n", err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
}
|
||||
return configSchema, nil
|
||||
}
|
||||
|
||||
func validateNamespaceFile(cmd *cobra.Command, fn string) (*namespace.Namespace, error) {
|
||||
cleanName := filepath.Clean(fn)
|
||||
fc, err := os.ReadFile(cleanName)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not read file %q: %+v\n", fn, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
parse, err := config.GetParser(cleanName)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unable to infer file type from %q: %+v\n", fn, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return validateNamespaceBytes(cmd, cleanName, fc, parse)
|
||||
}
|
||||
|
||||
func validateNamespaceBytes(cmd *cobra.Command, name string, b []byte, parser config.Parser) (*namespace.Namespace, error) {
|
||||
schema, err := getSchema(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var val map[string]any
|
||||
if err := parser(b, &val); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Encountered unmarshal error for %q: %+v\n", name, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
if err := schema.ValidateInterface(val); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "File %s was not a valid namespace file. Reasons:\n", name)
|
||||
jsonschemax.FormatValidationErrorForCLI(cmd.ErrOrStderr(), embedx.ConfigSchema, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
var n namespace.Namespace
|
||||
if err := parser(b, &n); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Encountered unmarshal error for %q: %+v\n", name, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func validateConfigFile(cmd *cobra.Command, fn string) error {
|
||||
cleanName := filepath.Clean(fn)
|
||||
fc, err := os.ReadFile(cleanName)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not read file %q: %+v\n", fn, err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
parse, err := config.GetParser(cleanName)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unable to infer file type from %q: %+v\n", fn, err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
var val map[string]any
|
||||
if err := parse(fc, &val); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Encountered parse error for %q: %+v\n", fn, err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
if ns, ok := val["namespaces"]; ok {
|
||||
// ns can either be a file url or a list of namespace objects
|
||||
switch t := ns.(type) {
|
||||
case string:
|
||||
logger := logrusx.New("cmd", "0")
|
||||
cw, err := config.NewNamespaceWatcher(cmd.Context(), logger, t)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Encountered error reading config: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
files := cw.NamespaceFiles()
|
||||
for _, file := range files {
|
||||
_, err := validateNamespaceBytes(cmd, file.Name, file.Contents, file.Parser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case []any:
|
||||
for i, obj := range t {
|
||||
fc, err := yaml.Marshal(obj)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Internal error. Failed to marshal yaml %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
_, err = validateNamespaceBytes(cmd, fmt.Sprintf("index: %d", i), fc, yaml.Unmarshal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case []map[string]any:
|
||||
for i, obj := range t {
|
||||
fc, err := yaml.Marshal(obj)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Internal error. Failed to marshal yaml %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
_, err = validateNamespaceBytes(cmd, fmt.Sprintf("index: %d", i), fc, yaml.Unmarshal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "unknown type %T for key 'namespaces' in config file: %v\n", t, t)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
158
cmd/namespace/validate_test.go
Normal file
158
cmd/namespace/validate_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const fileMode = 0644
|
||||
|
||||
const (
|
||||
configEmbeddedYaml = `
|
||||
dsn: memory
|
||||
namespaces:
|
||||
- name: testns0
|
||||
id: 0
|
||||
- name: testns1
|
||||
id: 1
|
||||
`
|
||||
|
||||
configEmbeddedJson = `{"dsn":"memory","namespaces":[{"name":"testns0","id":0}]}`
|
||||
|
||||
configEmbeddedToml = `
|
||||
[[namespaces]]
|
||||
name = "testns0"
|
||||
id = 0
|
||||
|
||||
[[namespaces]]
|
||||
name = "testns1"
|
||||
id = 1
|
||||
`
|
||||
)
|
||||
|
||||
const configReference = `
|
||||
dsn: memory
|
||||
namespaces: %s
|
||||
`
|
||||
|
||||
const nsyaml = "name: testns0\nid: 0"
|
||||
const nsjson = `{"name": "testns1", "id": 1}`
|
||||
|
||||
func TestValidateConfigNamespaces(t *testing.T) {
|
||||
cmd := cmdx.CommandExecuter{New: validateCommand}
|
||||
|
||||
t.Run("case=read valid config with embedded namespaces", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fnyaml := filepath.Join(dir, "keto.yaml")
|
||||
require.NoError(t, os.WriteFile(fnyaml, []byte(configEmbeddedYaml), fileMode))
|
||||
|
||||
fnjson := filepath.Join(dir, "keto.json")
|
||||
require.NoError(t, os.WriteFile(fnjson, []byte(configEmbeddedJson), fileMode))
|
||||
|
||||
fntoml := filepath.Join(dir, "keto.toml")
|
||||
require.NoError(t, os.WriteFile(fntoml, []byte(configEmbeddedToml), fileMode))
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, "validate", "-c", fnyaml+","+fnjson+","+fntoml)
|
||||
assert.Contains(t, stdOut, "Congrats, all files are valid!\n")
|
||||
})
|
||||
|
||||
t.Run("case=supports 3 namespace file formats", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nsfiles := map[string]string{
|
||||
filepath.Join(dir, "ns.yaml"): "name: testns0\nid: 0",
|
||||
filepath.Join(dir, "ns.json"): "{\"name\": \"testns0\",\"id\": 0}",
|
||||
filepath.Join(dir, "ns.toml"): "name = \"testns0\"\nid = 0",
|
||||
}
|
||||
for fn, contents := range nsfiles {
|
||||
require.NoError(t, os.WriteFile(fn, []byte(contents), fileMode))
|
||||
}
|
||||
|
||||
params := append([]string{"validate"}, keys(nsfiles)...)
|
||||
cmd.ExecNoErr(t, params...)
|
||||
})
|
||||
|
||||
t.Run("case=unknown namespace format gives error", func(t *testing.T) {
|
||||
fn := filepath.Join(t.TempDir(), "ns.txt")
|
||||
require.NoError(t, os.WriteFile(fn, []byte("name: ns\nid: 0"), fileMode))
|
||||
|
||||
_, stdErr, err := cmd.Exec(nil, "validate", fn)
|
||||
require.ErrorIs(t, err, cmdx.ErrNoPrintButFail)
|
||||
assert.Contains(t, stdErr, "Unable to infer file type")
|
||||
})
|
||||
|
||||
t.Run("case=config passed as varg fails", func(t *testing.T) {
|
||||
fn := filepath.Join(t.TempDir(), "keto.yaml")
|
||||
require.NoError(t, os.WriteFile(fn, []byte(configEmbeddedYaml), fileMode))
|
||||
|
||||
// interprets config file as namespace file when `-c` flag is not passed
|
||||
_, stdErr, err := cmd.Exec(nil, "validate", fn)
|
||||
require.ErrorIs(t, err, cmdx.ErrNoPrintButFail)
|
||||
assert.Regexp(t, "additionalProperties ((\"namespaces\", \"dsn\")|(\"dsn\", \"namespaces\")) not allowed", stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=read config with invalid embedded namespace", func(t *testing.T) {
|
||||
fn := filepath.Join(t.TempDir(), "keto.yaml")
|
||||
require.NoError(t, os.WriteFile(fn, []byte(`{"namespaces":[{"id":"x","name":"x"}]}`), fileMode))
|
||||
|
||||
_, stdErr, err := cmd.Exec(nil, "validate", "--config", fn)
|
||||
require.ErrorIs(t, err, cmdx.ErrNoPrintButFail)
|
||||
assert.Contains(t, stdErr, "not a valid namespace file")
|
||||
assert.Contains(t, stdErr, "id:")
|
||||
})
|
||||
|
||||
t.Run("case=read config with namespace as dir reference", func(t *testing.T) {
|
||||
nsdir := t.TempDir()
|
||||
fn := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(nsdir, "ns0.yaml"), []byte(nsyaml), fileMode))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(nsdir, "ns1.json"), []byte(nsjson), fileMode))
|
||||
require.NoError(t, os.WriteFile(fn, []byte(fmt.Sprintf(configReference, nsdir)), fileMode))
|
||||
|
||||
cmd.PersistentArgs = []string{}
|
||||
stdOut := cmd.ExecNoErr(t, "validate", "-c", fn)
|
||||
assert.Contains(t, stdOut, "Congrats, all files are valid!\n")
|
||||
|
||||
stdOut = cmd.ExecNoErr(t, "validate", "-c", fmt.Sprintf("%s,%s", fn, fn))
|
||||
assert.Contains(t, stdOut, "Congrats, all files are valid!\n")
|
||||
})
|
||||
|
||||
t.Run("case=read config with dir reference bad namespaces", func(t *testing.T) {
|
||||
nsdir := t.TempDir()
|
||||
fn := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(nsdir, "ns0.yaml"), []byte("name: badId\nid: foo"), fileMode))
|
||||
require.NoError(t, os.WriteFile(fn, []byte(fmt.Sprintf(configReference, nsdir)), fileMode))
|
||||
|
||||
cmd.PersistentArgs = []string{}
|
||||
_, stdErr, err := cmd.Exec(nil, "validate", "-c", fn)
|
||||
require.ErrorIs(t, err, cmdx.ErrNoPrintButFail)
|
||||
assert.Contains(t, stdErr, "contains values or keys which are invalid")
|
||||
})
|
||||
}
|
||||
|
||||
func validateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
Short: "Global and consistent permission and authorization server",
|
||||
}
|
||||
configx.RegisterConfigFlag(cmd.PersistentFlags(), []string{})
|
||||
cmd.AddCommand(NewValidateCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
rv := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
rv = append(rv, k)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
37
cmd/relationtuple/create.go
Normal file
37
cmd/relationtuple/create.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
const FlagFile = "file"
|
||||
|
||||
func NewCreateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <subject_namespace>:<subject_id> <relation> <object_namespace>:<object_id>",
|
||||
Short: "Create relationship tuples from inline arguments or JSON files and folders",
|
||||
Long: `Create relationship tuples from inline arguments or JSON files and folders.
|
||||
Inline example:
|
||||
keto relation-tuple create User:alice owner Doc:readme
|
||||
|
||||
From file or folder:
|
||||
keto relation-tuple create -f relationships1.json -f relationships2.json
|
||||
keto relation-tuple create -f relationships-dir1 -f relationships-dir2
|
||||
|
||||
If a directory is provided, all JSON files inside it are processed.
|
||||
Use '-' as filename to read from STD_IN:
|
||||
keto relation-tuple create -f -`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: transactTuples(rts.RelationTupleDelta_ACTION_INSERT),
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
registerPackageFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
168
cmd/relationtuple/create_test.go
Normal file
168
cmd/relationtuple/create_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/randx"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func renderTable(table cmdx.Table) string {
|
||||
var buf bytes.Buffer
|
||||
cmdx.PrintTablef(&buf, table)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestCreateCmd(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
otherNspace := &namespace.Namespace{Name: "other"}
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser, otherNspace}, newCmd)
|
||||
|
||||
createFile := func(t *testing.T, tuple any) string {
|
||||
dir := t.TempDir()
|
||||
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := randx.MustString(10, randx.AlphaLowerNum) + ".json"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), data, 0o600))
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
|
||||
type getTuple func() *ketoapi.RelationTuple
|
||||
|
||||
fns := map[string]getTuple{
|
||||
"subjectID": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
},
|
||||
"subjectSet": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
},
|
||||
}
|
||||
|
||||
for name, createTuple := range fns {
|
||||
t.Run("case="+name, func(t *testing.T) {
|
||||
t.Run("case=creates single tuple from files", func(t *testing.T) {
|
||||
tuple1 := createTuple()
|
||||
tuple2 := createTuple()
|
||||
|
||||
tmpFile := createFile(t, tuple1)
|
||||
tmpFile2 := createFile(t, []*ketoapi.RelationTuple{tuple2})
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f", tmpFile, "-f", tmpFile2)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
|
||||
// assert that we can `get` it
|
||||
res1 := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple1.Namespace, "--object", tuple1.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection([]*ketoapi.RelationTuple{tuple1}),
|
||||
IsLastPage: true,
|
||||
}), res1)
|
||||
|
||||
// assert that we can `get` it
|
||||
res2 := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple2.Namespace, "--object", tuple2.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection([]*ketoapi.RelationTuple{tuple2}),
|
||||
IsLastPage: true,
|
||||
}), res2)
|
||||
})
|
||||
|
||||
t.Run("case=creates tuples from files and stdin", func(t *testing.T) {
|
||||
tuple1 := createTuple()
|
||||
tupleStdin := createTuple()
|
||||
|
||||
tupleFile := createFile(t, tuple1)
|
||||
data, err := json.Marshal(tupleStdin)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(bytes.NewReader(data), "relation-tuple", "create", "-f", "-", "-f", tupleFile)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stdErr)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tupleStdin, tuple1})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=creates tuples from directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tuple1 := createTuple()
|
||||
tuple2 := createTuple()
|
||||
|
||||
data1, err := json.Marshal(tuple1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple1.json"), data1, 0o600))
|
||||
|
||||
data2, err := json.Marshal(tuple2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple2.json"), data2, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f="+dir)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=creates inline tuple", func(t *testing.T) {
|
||||
tuple := createTuple()
|
||||
|
||||
subject := ""
|
||||
if tuple.SubjectID != nil {
|
||||
subject = *tuple.SubjectID
|
||||
} else {
|
||||
subject = tuple.SubjectSet.String()
|
||||
}
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", subject, tuple.Relation, tuple.Namespace+":"+tuple.Object)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=fails on invalid JSON", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "invalid.json")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("not valid json"), 0o600))
|
||||
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "-f="+tmpFile)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "could not decode")
|
||||
})
|
||||
|
||||
t.Run("case=fails on nonexistent file", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "-f=/nonexistent/file.json")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "error getting stats")
|
||||
})
|
||||
|
||||
t.Run("case=fails on json file arguments without -f", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "file1.json", "file2.json")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected inline arguments or JSON files")
|
||||
})
|
||||
|
||||
t.Run("case=missing colon between ns and obj", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "sns:so1", "rel", "nsobj")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format, got \"nsobj\"")
|
||||
})
|
||||
}
|
||||
282
cmd/relationtuple/delete.go
Normal file
282
cmd/relationtuple/delete.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/cmd/helpers"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxFileSizeBytes = 10 * 1024 * 1024 // 10 MB
|
||||
MaxDirDepth = 3
|
||||
)
|
||||
|
||||
func NewDeleteCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <subject_namespace>:<subject_id> <relation> <object_namespace>:<object_id>",
|
||||
Short: "Delete relationship tuples from inline arguments or JSON files and folders",
|
||||
Long: `Delete relationship tuples from inline arguments or JSON files and folders.
|
||||
Inline example:
|
||||
keto relation-tuple delete User:alice owner Doc:readme
|
||||
|
||||
From file or folder:
|
||||
keto relation-tuple delete -f relationships1.json -f relationships2.json
|
||||
keto relation-tuple delete -f relationships-dir1 -f relationships-dir2
|
||||
|
||||
If a directory is provided, all JSON files inside it are processed.
|
||||
Use '-' as filename to read from STD_IN:
|
||||
keto relation-tuple delete -f -`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: transactTuples(rts.RelationTupleDelta_ACTION_DELETE),
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
registerPackageFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func transactTuples(action rts.RelationTupleDelta_Action) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
files, err := cmd.Flags().GetStringSlice(FlagFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) > 0 {
|
||||
return transactTuplesFromFiles(cmd, action, files)
|
||||
}
|
||||
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("expected inline arguments or JSON files and folders")
|
||||
}
|
||||
|
||||
return transactTupleFromInlineArgs(cmd, action, args)
|
||||
}
|
||||
}
|
||||
|
||||
func transactTupleFromInlineArgs(cmd *cobra.Command, action rts.RelationTupleDelta_Action, args []string) error {
|
||||
namespace, object, err := helpers.ParseNamespaceObject(cmd, args[2:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub, err := helpers.ParseSubject(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse subject %q: %w", args[0], err)
|
||||
}
|
||||
tuple := &rts.RelationTuple{
|
||||
Namespace: namespace,
|
||||
Object: object,
|
||||
Relation: args[1],
|
||||
Subject: sub,
|
||||
}
|
||||
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.TransactRelationTuples(cmd.Context(), &rts.TransactRelationTuplesRequest{
|
||||
RelationTupleDeltas: []*rts.RelationTupleDelta{{
|
||||
Action: action,
|
||||
RelationTuple: tuple,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
c, err := NewProtoCollection([]*rts.RelationTuple{tuple})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "error printing the response: %s\n", err)
|
||||
}
|
||||
cmdx.PrintTable(cmd, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func transactTuplesFromFiles(cmd *cobra.Command, action rts.RelationTupleDelta_Action, files []string) error {
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
var deltas []*rts.RelationTupleDelta
|
||||
for _, fn := range files {
|
||||
parsed, err := readTuplesFromPath(cmd, fn, 0, decodeTuples)
|
||||
if err != nil {
|
||||
// silencing the usage message as from here on, there can't be any syntax error in the command usage,
|
||||
// but rather an error with the content of the files
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
for _, t := range parsed {
|
||||
tuples = append(tuples, t)
|
||||
deltas = append(deltas, &rts.RelationTupleDelta{
|
||||
Action: action,
|
||||
RelationTuple: t.ToProto(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.TransactRelationTuples(cmd.Context(), &rts.TransactRelationTuplesRequest{
|
||||
RelationTupleDeltas: deltas,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
cmdx.PrintTable(cmd, NewAPICollection(tuples))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TupleDecoder reads relation tuples from a reader. Implementations handle
|
||||
// format-specific decoding (e.g. JSON, human-readable).
|
||||
type TupleDecoder func(r io.Reader) ([]*ketoapi.RelationTuple, error)
|
||||
|
||||
// readTuplesFromPath reads relation tuples from a file path, directory, or
|
||||
// stdin (when path is "-"). The depth parameter tracks directory nesting to
|
||||
// enforce MaxDirDepth. The decode function handles format-specific parsing.
|
||||
func readTuplesFromPath(cmd *cobra.Command, path string, depth int, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
if path == "-" {
|
||||
tuples, err := decode(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin: %w", err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting stats for %s: %w", path, err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if depth >= MaxDirDepth {
|
||||
return nil, fmt.Errorf("maximum directory depth of %d exceeded at %s", MaxDirDepth, path)
|
||||
}
|
||||
tuples, err := readTuplesFromDir(cleanPath, decode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
return readTuplesFromFile(cleanPath, decode)
|
||||
}
|
||||
|
||||
func readTuplesFromDir(dir string, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
|
||||
fsys := os.DirFS(dir)
|
||||
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return fmt.Errorf("walking %s: %w", path, walkErr)
|
||||
}
|
||||
|
||||
depth := 0
|
||||
if path != "." {
|
||||
depth = strings.Count(path, "/") + 1
|
||||
}
|
||||
|
||||
if depth > MaxDirDepth {
|
||||
return fmt.Errorf("maximum directory depth of %d exceeded at %s", MaxDirDepth, path)
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, filepath.FromSlash(path))
|
||||
|
||||
t, err := readTuplesFromFile(fullPath, decode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tuples = append(tuples, t...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
// readTuplesFromFile opens a single file and decodes its content using the
|
||||
// provided decoder.
|
||||
func readTuplesFromFile(path string, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening file %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
tuples, err := decode(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
// decodeTuples decodes JSON from the reader as either a single RelationTuple
|
||||
// or an array of RelationTuples. Input is limited to MaxFileSizeBytes.
|
||||
func decodeTuples(r io.Reader) ([]*ketoapi.RelationTuple, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, MaxFileSizeBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
if len(data) > MaxFileSizeBytes {
|
||||
return nil, fmt.Errorf("input exceeds maximum size of %d bytes", MaxFileSizeBytes)
|
||||
}
|
||||
|
||||
// Trim UTF-8 BOM first, then leading/trailing whitespace
|
||||
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
trimmed = bytes.TrimSpace(trimmed)
|
||||
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("empty input after trimming whitespace")
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if trimmed[0] == '[' {
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
if err := decoder.Decode(&tuples); err != nil {
|
||||
return nil, fmt.Errorf("could not decode: %w", err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
var tuple ketoapi.RelationTuple
|
||||
if err := decoder.Decode(&tuple); err != nil {
|
||||
return nil, fmt.Errorf("could not decode: %w", err)
|
||||
}
|
||||
return []*ketoapi.RelationTuple{&tuple}, nil
|
||||
}
|
||||
67
cmd/relationtuple/delete_all.go
Normal file
67
cmd/relationtuple/delete_all.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagForce = "force"
|
||||
)
|
||||
|
||||
func NewDeleteAllCmd() *cobra.Command {
|
||||
var force bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete-all",
|
||||
Short: "Delete ALL relationships matching the relation query.",
|
||||
Long: "Delete all relationships matching the relation query.\n" +
|
||||
"It is recommended to first run the command without the `--force` flag to verify that the operation is safe.",
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !force {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "WARNING: This operation is not reversible. Please use the `--%s` flag to proceed.\nThese tuples would be deleted:\n\n", FlagForce)
|
||||
return getTuples(new(int32(100)), new(""))(cmd, args)
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed(FlagSubject) {
|
||||
return fmt.Errorf("usage of --%s is not supported anymore, use --%s or --%s respectively", FlagSubject, FlagSubjectID, FlagSubjectSet)
|
||||
}
|
||||
|
||||
query, err := readQueryFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.DeleteRelationTuples(cmd.Context(), &rts.DeleteRelationTuplesRequest{
|
||||
RelationQuery: query,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registerPackageFlags(cmd.Flags())
|
||||
registerRelationTupleFlags(cmd.Flags())
|
||||
cmd.Flags().BoolVar(&force, FlagForce, false, "Force the deletion of relationships")
|
||||
|
||||
return cmd
|
||||
}
|
||||
25
cmd/relationtuple/delete_all_test.go
Normal file
25
cmd/relationtuple/delete_all_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteAllCmd(t *testing.T) {
|
||||
t.Run("executes get instead of delete when run without force", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := NewDeleteAllCmd()
|
||||
// we will get an error here because there is no server running, but we really only care about the execution path
|
||||
stdout, _, _ := cmdx.ExecCtx(ctx, cmd, nil, "--insecure-skip-hostname-verification=true")
|
||||
assert.Contains(t, stdout, "WARNING: This operation is not reversible.")
|
||||
})
|
||||
}
|
||||
236
cmd/relationtuple/delete_test.go
Normal file
236
cmd/relationtuple/delete_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func TestDeleteCmd(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
otherNspace := &namespace.Namespace{Name: "other"}
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser, otherNspace}, newCmd)
|
||||
|
||||
// createTuple is a helper that creates a tuple via the create command so we can then delete it.
|
||||
createTuple := func(t *testing.T, tuple *ketoapi.RelationTuple) {
|
||||
t.Helper()
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(tmpFile, data, 0o600))
|
||||
ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f="+tmpFile)
|
||||
}
|
||||
|
||||
type getTuple func() *ketoapi.RelationTuple
|
||||
|
||||
fns := map[string]getTuple{
|
||||
"subjectID": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
},
|
||||
"subjectSet": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
},
|
||||
}
|
||||
|
||||
for name, makeTuple := range fns {
|
||||
t.Run("case="+name, func(t *testing.T) {
|
||||
t.Run("case=deletes single tuple from file", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(tmpFile, data, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
|
||||
// assert that the tuple is gone
|
||||
res := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple.Namespace, "--object", tuple.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection(nil),
|
||||
IsLastPage: true,
|
||||
}), res)
|
||||
})
|
||||
|
||||
t.Run("case=deletes tuples from stdin", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(bytes.NewReader(data), "relation-tuple", "delete", "-f=-")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stdErr)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=deletes tuples from directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tuple1 := makeTuple()
|
||||
tuple2 := makeTuple()
|
||||
createTuple(t, tuple1)
|
||||
createTuple(t, tuple2)
|
||||
|
||||
data1, err := json.Marshal(tuple1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple1.json"), data1, 0o600))
|
||||
|
||||
data2, err := json.Marshal(tuple2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple2.json"), data2, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+dir)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=deletes inline tuple", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
subject := ""
|
||||
if tuple.SubjectID != nil {
|
||||
subject = *tuple.SubjectID
|
||||
} else {
|
||||
subject = tuple.SubjectSet.String()
|
||||
}
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", subject, tuple.Relation, tuple.Namespace+":"+tuple.Object)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=fails on invalid JSON", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "invalid.json")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("not valid json"), 0o600))
|
||||
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "could not decode")
|
||||
})
|
||||
|
||||
t.Run("case=fails on nonexistent file", func(t *testing.T) {
|
||||
stdErr := ts.Cmd.ExecExpectedErr(t, "relation-tuple", "delete", "-f=/nonexistent/file.json")
|
||||
require.Contains(t, stdErr, "error getting stats")
|
||||
})
|
||||
|
||||
t.Run("case=fails on json file arguments without -f", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "file1.json", "file2.json")
|
||||
require.Error(t, err)
|
||||
require.NotEmpty(t, stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=missing colon between ns and obj", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "sns:so1", "rel", "nsobj")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format, got \"nsobj\"")
|
||||
})
|
||||
|
||||
t.Run("case=handles JSON with leading whitespace and BOM", func(t *testing.T) {
|
||||
// Test with various prefixes: whitespace, BOM, and combination
|
||||
prefixes := [][]byte{
|
||||
{'\n', '\n', ' ', ' ', '\t'},
|
||||
{0xEF, 0xBB, 0xBF},
|
||||
{0xEF, 0xBB, 0xBF, '\n', ' ', ' '},
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
// Test single tuple
|
||||
tuple := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
createTuple(t, tuple)
|
||||
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
dataWithPrefix := append(prefix, data...)
|
||||
require.NoError(t, os.WriteFile(tmpFile, dataWithPrefix, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
|
||||
// Test array
|
||||
tuple1 := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
tuple2 := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
createTuple(t, tuple1)
|
||||
createTuple(t, tuple2)
|
||||
|
||||
tmpFile2 := filepath.Join(t.TempDir(), "tuples.json")
|
||||
data2, err := json.Marshal([]*ketoapi.RelationTuple{tuple1, tuple2})
|
||||
require.NoError(t, err)
|
||||
dataWithPrefix2 := append(prefix, data2...)
|
||||
require.NoError(t, os.WriteFile(tmpFile2, dataWithPrefix2, 0o600))
|
||||
|
||||
stdOut2 := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile2)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadTuplesFromDir(t *testing.T) {
|
||||
writeTuple := func(t *testing.T, dir string, name string) *ketoapi.RelationTuple {
|
||||
t.Helper()
|
||||
tuple := clienttest.RandomTupleWithSubjectID(t.Name())
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
|
||||
return tuple
|
||||
}
|
||||
|
||||
mkdirs := func(t *testing.T, root string, depth int) string {
|
||||
t.Helper()
|
||||
dir := root
|
||||
for i := range depth {
|
||||
dir = filepath.Join(dir, fmt.Sprintf("d%d", i))
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(dir, 0o750))
|
||||
return dir
|
||||
}
|
||||
|
||||
t.Run("case=succeeds at MaxDirDepth nested directories", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
deepest := mkdirs(t, root, MaxDirDepth)
|
||||
|
||||
// Place a file inside the second-to-last directory so its
|
||||
// path-component depth equals MaxDirDepth.
|
||||
tuple := writeTuple(t, filepath.Dir(deepest), "tuple.json")
|
||||
|
||||
tuples, err := readTuplesFromDir(root, decodeTuples)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []*ketoapi.RelationTuple{tuple}, tuples)
|
||||
})
|
||||
|
||||
t.Run("case=fails at MaxDirDepth+1 nested directories", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirs(t, root, MaxDirDepth+1)
|
||||
|
||||
_, err := readTuplesFromDir(root, decodeTuples)
|
||||
require.ErrorContains(t, err, "maximum directory depth")
|
||||
})
|
||||
}
|
||||
157
cmd/relationtuple/get.go
Normal file
157
cmd/relationtuple/get.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagNamespace = "namespace"
|
||||
FlagSubject = "subject"
|
||||
FlagSubjectID = "subject-id"
|
||||
FlagSubjectSet = "subject-set"
|
||||
FlagRelation = "relation"
|
||||
FlagObject = "object"
|
||||
FlagPageSize = "page-size"
|
||||
FlagPageToken = "page-token"
|
||||
)
|
||||
|
||||
func registerRelationTupleFlags(flags *pflag.FlagSet) {
|
||||
flags.String(FlagNamespace, "", "Set the requested namespace")
|
||||
flags.String(FlagSubjectID, "", "Set the requested subject ID")
|
||||
flags.String(FlagSubjectSet, "", `Set the requested subject set; format: "namespace:object#relation"`)
|
||||
flags.String(FlagRelation, "", "Set the requested relation")
|
||||
flags.String(FlagObject, "", "Set the requested object")
|
||||
|
||||
flags.String(FlagSubject, "", "")
|
||||
if err := flags.MarkHidden(FlagSubject); err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func readQueryFromFlags(cmd *cobra.Command) (*rts.RelationQuery, error) {
|
||||
getStringPtr := func(flagName string) *string {
|
||||
if f := cmd.Flags().Lookup(flagName); f.Changed {
|
||||
return new(f.Value.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
query := &ketoapi.RelationQuery{
|
||||
Namespace: getStringPtr(FlagNamespace),
|
||||
Object: getStringPtr(FlagObject),
|
||||
Relation: getStringPtr(FlagRelation),
|
||||
SubjectID: getStringPtr(FlagSubjectID),
|
||||
}
|
||||
if f := cmd.Flags().Lookup(FlagSubjectSet); f.Changed {
|
||||
s, err := (&ketoapi.SubjectSet{}).FromString(f.Value.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.SubjectSet = s
|
||||
}
|
||||
|
||||
return query.ToProto(), nil
|
||||
}
|
||||
|
||||
func NewGetCmd() *cobra.Command {
|
||||
var (
|
||||
pageSize int32
|
||||
pageToken string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get relationships",
|
||||
Long: "Get relationships matching the given partial tuple.\n" +
|
||||
"Returns paginated results.",
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: getTuples(&pageSize, &pageToken),
|
||||
}
|
||||
|
||||
registerPackageFlags(cmd.Flags())
|
||||
registerRelationTupleFlags(cmd.Flags())
|
||||
|
||||
cmd.Flags().StringVar(&pageToken, FlagPageToken, "", "page token acquired from a previous response")
|
||||
cmd.Flags().Int32Var(&pageSize, FlagPageSize, 100, "maximum number of items to return")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getTuples(pageSize *int32, pageToken *string) func(cmd *cobra.Command, _ []string) error {
|
||||
return func(cmd *cobra.Command, _ []string) error {
|
||||
if cmd.Flags().Changed(FlagSubject) {
|
||||
return fmt.Errorf("usage of --%s is not supported anymore, use --%s or --%s respectively", FlagSubject, FlagSubjectID, FlagSubjectSet)
|
||||
}
|
||||
|
||||
conn, err := client.GetReadConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cl := rts.NewReadServiceClient(conn)
|
||||
query, err := readQueryFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := cl.ListRelationTuples(cmd.Context(), &rts.ListRelationTuplesRequest{
|
||||
RelationQuery: query,
|
||||
PageSize: *pageSize,
|
||||
PageToken: *pageToken,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
relationTuples, err := NewProtoCollection(resp.RelationTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmdx.PrintTable(cmd, &responseOutput{
|
||||
RelationTuples: relationTuples,
|
||||
IsLastPage: resp.NextPageToken == "",
|
||||
NextPageToken: resp.NextPageToken,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type responseOutput struct {
|
||||
RelationTuples *Collection `json:"relation_tuples"`
|
||||
IsLastPage bool `json:"is_last_page"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
func (r *responseOutput) Header() []string {
|
||||
return r.RelationTuples.Header()
|
||||
}
|
||||
|
||||
func (r *responseOutput) Table() [][]string {
|
||||
return append(
|
||||
r.RelationTuples.Table(),
|
||||
[]string{},
|
||||
[]string{"NEXT PAGE TOKEN", r.NextPageToken},
|
||||
[]string{"IS LAST PAGE", strconv.FormatBool(r.IsLastPage)},
|
||||
)
|
||||
}
|
||||
|
||||
func (r *responseOutput) Interface() any { return r }
|
||||
func (r *responseOutput) Len() int { return r.RelationTuples.Len() + 3 }
|
||||
func (r *responseOutput) IDs() []string { return r.RelationTuples.IDs() }
|
||||
|
||||
var _ cmdx.Table = (*responseOutput)(nil)
|
||||
114
cmd/relationtuple/output.go
Normal file
114
cmd/relationtuple/output.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
Collection struct {
|
||||
apiRelations []*ketoapi.RelationTuple
|
||||
}
|
||||
OutputTuple struct {
|
||||
*ketoapi.RelationTuple
|
||||
}
|
||||
)
|
||||
|
||||
func NewProtoCollection(rels []*rts.RelationTuple) (*Collection, error) {
|
||||
r := &Collection{apiRelations: make([]*ketoapi.RelationTuple, len(rels))}
|
||||
for i, rel := range rels {
|
||||
var err error
|
||||
r.apiRelations[i], err = (&ketoapi.RelationTuple{}).FromDataProvider(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func MustNewProtoCollection(rels []*rts.RelationTuple) *Collection {
|
||||
c, err := NewProtoCollection(rels)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func NewAPICollection(rels []*ketoapi.RelationTuple) *Collection {
|
||||
return &Collection{apiRelations: rels}
|
||||
}
|
||||
|
||||
func (r *Collection) Header() []string {
|
||||
return []string{
|
||||
"NAMESPACE",
|
||||
"OBJECT",
|
||||
"RELATION NAME",
|
||||
"SUBJECT",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Collection) Table() [][]string {
|
||||
ir := r.apiRelations
|
||||
|
||||
data := make([][]string, len(ir))
|
||||
for i, rel := range ir {
|
||||
var sub string
|
||||
if rel.SubjectID != nil {
|
||||
sub = *rel.SubjectID
|
||||
} else {
|
||||
sub = rel.SubjectSet.String()
|
||||
}
|
||||
|
||||
data[i] = []string{rel.Namespace, rel.Object, rel.Relation, sub}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (r *Collection) Interface() any { return r.apiRelations }
|
||||
func (r *Collection) UnmarshalJSON(raw []byte) error { return json.Unmarshal(raw, &r.apiRelations) }
|
||||
func (r *Collection) Len() int { return len(r.apiRelations) }
|
||||
|
||||
func (r *Collection) MarshalJSON() ([]byte, error) {
|
||||
ir := r.apiRelations
|
||||
return json.Marshal(ir)
|
||||
}
|
||||
|
||||
func (r *Collection) IDs() []string {
|
||||
ts := r.apiRelations
|
||||
ids := make([]string, len(ts))
|
||||
for i, rt := range ts {
|
||||
ids[i] = rt.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *OutputTuple) Header() []string {
|
||||
return []string{
|
||||
"NAMESPACE",
|
||||
"OBJECT ID",
|
||||
"RELATION NAME",
|
||||
"SUBJECT",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OutputTuple) Columns() []string {
|
||||
return []string{
|
||||
r.Namespace,
|
||||
r.Object,
|
||||
r.Relation,
|
||||
outputSubject(r.RelationTuple),
|
||||
}
|
||||
}
|
||||
|
||||
func outputSubject(r *ketoapi.RelationTuple) string {
|
||||
if r.SubjectID != nil {
|
||||
return *r.SubjectID
|
||||
}
|
||||
return r.SubjectSet.String()
|
||||
}
|
||||
118
cmd/relationtuple/output_test.go
Normal file
118
cmd/relationtuple/output_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func TestRelationCollection(t *testing.T) {
|
||||
t.Run("case=prints all", func(t *testing.T) {
|
||||
expected := make([]*ketoapi.RelationTuple, 3)
|
||||
for i := range expected {
|
||||
expected[i] = &ketoapi.RelationTuple{
|
||||
Namespace: "n" + strconv.Itoa(i),
|
||||
Object: "o" + strconv.Itoa(i),
|
||||
Relation: "r" + strconv.Itoa(i),
|
||||
SubjectID: new("s" + strconv.Itoa(i)),
|
||||
}
|
||||
}
|
||||
expected[2].SubjectSet = &ketoapi.SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
}
|
||||
expected[2].SubjectID = nil
|
||||
|
||||
proto := make([]*rts.RelationTuple, 3)
|
||||
for i := range expected {
|
||||
proto[i] = &rts.RelationTuple{
|
||||
Namespace: "n" + strconv.Itoa(i),
|
||||
Object: "o" + strconv.Itoa(i),
|
||||
Relation: "r" + strconv.Itoa(i),
|
||||
Subject: rts.NewSubjectID("s" + strconv.Itoa(i)),
|
||||
}
|
||||
}
|
||||
proto[2].Subject = rts.NewSubjectSet("sn", "so", "sr")
|
||||
|
||||
for i, c := range []*Collection{
|
||||
NewAPICollection(expected),
|
||||
MustNewProtoCollection(proto),
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
var vals []string
|
||||
for _, row := range c.Table() {
|
||||
vals = append(vals, row...)
|
||||
}
|
||||
|
||||
ev := reflect.ValueOf(expected)
|
||||
for el := 0; el < ev.Len(); el++ {
|
||||
et := reflect.TypeOf(expected).Elem().Elem()
|
||||
|
||||
for f := 0; f < et.NumField(); f++ {
|
||||
v := ev.Index(el).Elem().Field(f)
|
||||
// private field or nil
|
||||
if !v.CanSet() || (v.Kind() == reflect.Pointer && v.IsNil()) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch k := v.Kind(); {
|
||||
case k == reflect.String:
|
||||
assert.Contains(t, vals, v.String())
|
||||
case k == reflect.Pointer && v.Elem().Kind() == reflect.String:
|
||||
assert.Contains(t, vals, v.Elem().String())
|
||||
default:
|
||||
t.Logf("unhandled kind %s %T", v.Kind(), v.Interface())
|
||||
str := v.MethodByName("String").Call(nil)[0].String()
|
||||
assert.Contains(t, vals, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("func=NewProtoCollection", func(t *testing.T) {
|
||||
_, err := NewProtoCollection([]*rts.RelationTuple{{ /*subject is nil*/ }})
|
||||
require.ErrorIs(t, err, ketoapi.ErrNilSubject())
|
||||
})
|
||||
|
||||
t.Run("func=json", func(t *testing.T) {
|
||||
ts := []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &ketoapi.SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
},
|
||||
},
|
||||
}
|
||||
expected, err := json.Marshal(ts)
|
||||
require.NoError(t, err)
|
||||
|
||||
collection := NewAPICollection(ts)
|
||||
actual, err := json.Marshal(collection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(expected), string(actual))
|
||||
})
|
||||
}
|
||||
93
cmd/relationtuple/parse.go
Normal file
93
cmd/relationtuple/parse.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func NewParseCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "parse",
|
||||
Short: "Parse human readable relationships",
|
||||
Long: "Parse human readable relationships as used in the documentation.\n" +
|
||||
"Supports various output formats. Especially useful for piping into other commands by using `--format json`.\n" +
|
||||
"Ignores comments (lines starting with `//`) and blank lines.\n\n" +
|
||||
"From file or folder:\n" +
|
||||
"\tketo relation-tuple parse -f tuples1.txt -f tuples2.txt\n" +
|
||||
"\tketo relation-tuple parse -f tuples-dir\n\n" +
|
||||
"Use '-' as filename to read from STD_IN:\n" +
|
||||
"\tketo relation-tuple parse -f -",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
files, err := cmd.Flags().GetStringSlice(FlagFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("at least one file must be specified with -f")
|
||||
}
|
||||
|
||||
var rts []*ketoapi.RelationTuple
|
||||
for _, fn := range files {
|
||||
rtss, err := readTuplesFromPath(cmd, fn, 0, parseHumanReadable)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
rts = append(rts, rtss...)
|
||||
}
|
||||
|
||||
if len(rts) == 1 {
|
||||
cmdx.PrintRow(cmd, rts[0])
|
||||
return nil
|
||||
}
|
||||
cmdx.PrintTable(cmd, NewAPICollection(rts))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseHumanReadable reads human-readable relation tuples from the reader.
|
||||
// Input is limited to MaxFileSizeBytes.
|
||||
func parseHumanReadable(r io.Reader) ([]*ketoapi.RelationTuple, error) {
|
||||
fc, err := io.ReadAll(io.LimitReader(r, int64(MaxFileSizeBytes)+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
if len(fc) > MaxFileSizeBytes {
|
||||
return nil, fmt.Errorf("input exceeds maximum size of %d bytes", MaxFileSizeBytes)
|
||||
}
|
||||
|
||||
parts := strings.Split(string(fc), "\n")
|
||||
rts := make([]*ketoapi.RelationTuple, 0, len(parts))
|
||||
for i, row := range parts {
|
||||
row = strings.TrimSpace(row)
|
||||
// ignore comments and empty lines
|
||||
if row == "" || strings.HasPrefix(row, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
rt, err := (&ketoapi.RelationTuple{}).FromString(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: could not decode %q: %w", i+1, row, err)
|
||||
}
|
||||
rts = append(rts, rt)
|
||||
}
|
||||
|
||||
return rts, nil
|
||||
}
|
||||
132
cmd/relationtuple/parse_test.go
Normal file
132
cmd/relationtuple/parse_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/keto/ketoapi"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseTuplesFromPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input, name string
|
||||
expected []*ketoapi.RelationTuple
|
||||
}{
|
||||
{
|
||||
name: "single basic tuple",
|
||||
input: "nspace:obj#rel@sub\n",
|
||||
expected: []*ketoapi.RelationTuple{{
|
||||
Namespace: "nspace",
|
||||
Object: "obj",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "multiple tuples",
|
||||
input: `nspace:obj1#rel@sub1
|
||||
nspace:obj2#rel@sub2
|
||||
nspace:obj2#rel@(nspace:obj2#rel)`,
|
||||
expected: []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj1",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub1"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub2"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectSet: &ketoapi.SubjectSet{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "crap around tuples",
|
||||
input: `// foo comment
|
||||
nspace:obj#rel@sub
|
||||
|
||||
// also indentation and trailing spaces
|
||||
nspace:indent#rel@sub `,
|
||||
expected: []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "indent",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewBufferString(tc.input))
|
||||
|
||||
actual, err := readTuplesFromPath(cmd, "-", 0, parseHumanReadable)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=error contains line number", func(t *testing.T) {
|
||||
input := "nspace:obj#rel@subns:subid\n\n\ninvalid-line\nnspace:obj2#rel@subns:subid2\n"
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewBufferString(input))
|
||||
|
||||
_, err := readTuplesFromPath(cmd, "-", 0, parseHumanReadable)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "line 4:")
|
||||
})
|
||||
|
||||
t.Run("case=reads from fs", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fn := filepath.Join(dir, "test.tuples")
|
||||
require.NoError(t, os.WriteFile(fn, []byte(`
|
||||
nspace:obj1#rel@sub1
|
||||
|
||||
nspace:obj2#rel@sub2`), 0o600))
|
||||
|
||||
actual, err := readTuplesFromPath(&cobra.Command{}, fn, 0, parseHumanReadable)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj1",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub1"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub2"),
|
||||
},
|
||||
}, actual)
|
||||
})
|
||||
}
|
||||
37
cmd/relationtuple/root.go
Normal file
37
cmd/relationtuple/root.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
)
|
||||
|
||||
func newRelationCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "relation-tuple",
|
||||
Short: "Read and manipulate relationships",
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command) {
|
||||
relationCmd := newRelationCmd()
|
||||
|
||||
parent.AddCommand(relationCmd)
|
||||
|
||||
relationCmd.AddCommand(NewGetCmd(), NewCreateCmd(), NewDeleteCmd(), NewDeleteAllCmd(), NewParseCmd())
|
||||
}
|
||||
|
||||
func registerPackageFlags(flags *pflag.FlagSet) {
|
||||
client.RegisterRemoteURLFlags(flags)
|
||||
cmdx.RegisterFormatFlags(flags)
|
||||
}
|
||||
|
||||
func registerFileFlag(flags *pflag.FlagSet) {
|
||||
flags.StringSliceP(FlagFile, "f", nil, "Read relationships from JSON files or directories (use the special filename - for stdin)")
|
||||
}
|
||||
74
cmd/root.go
Normal file
74
cmd/root.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/ory/keto/cmd/check"
|
||||
"github.com/ory/keto/cmd/expand"
|
||||
"github.com/ory/keto/cmd/migrate"
|
||||
"github.com/ory/keto/cmd/namespace"
|
||||
"github.com/ory/keto/cmd/relationtuple"
|
||||
"github.com/ory/keto/cmd/server"
|
||||
"github.com/ory/keto/cmd/status"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func NewRootCmd(opts ...ketoctx.Option) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
Short: "Global and consistent permission and authorization server",
|
||||
}
|
||||
|
||||
configx.RegisterConfigFlag(cmd.PersistentFlags(), []string{filepath.Join(userHomeDir(), "keto.yml")})
|
||||
|
||||
relationtuple.RegisterCommandsRecursive(cmd)
|
||||
namespace.RegisterCommandsRecursive(cmd, opts)
|
||||
migrate.RegisterCommandsRecursive(cmd, opts)
|
||||
server.RegisterCommandsRecursive(cmd, opts)
|
||||
check.RegisterCommandsRecursive(cmd)
|
||||
expand.RegisterCommandsRecursive(cmd)
|
||||
status.RegisterCommandRecursive(cmd)
|
||||
|
||||
cmd.AddCommand(cmdx.Version(&config.Version, &config.Commit, &config.Date))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := NewRootCmd().ExecuteContext(ctx); err != nil {
|
||||
if !errors.Is(err, cmdx.ErrNoPrintButFail) {
|
||||
fmt.Println(err)
|
||||
}
|
||||
os.Exit(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func userHomeDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH")
|
||||
if home == "" {
|
||||
home = os.Getenv("USERPROFILE")
|
||||
}
|
||||
return home
|
||||
}
|
||||
return os.Getenv("HOME")
|
||||
}
|
||||
177
cmd/serve_debug_test.go
Normal file
177
cmd/serve_debug_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
)
|
||||
|
||||
// This test is intended for debug purposes. Just comment out t.SkipNow() and run it through your IDE debugger with
|
||||
func Test_DebugOnly(t *testing.T) {
|
||||
t.SkipNow()
|
||||
|
||||
ctx := context.Background()
|
||||
err := cmdx.ExecBackgroundCtx(
|
||||
ctx,
|
||||
NewRootCmd(),
|
||||
os.Stdin,
|
||||
os.Stdout,
|
||||
os.Stderr,
|
||||
"serve",
|
||||
"-c",
|
||||
"../contrib/cat-videos-example/keto.yml",
|
||||
).Wait()
|
||||
|
||||
t.Logf("got error %s", err)
|
||||
}
|
||||
|
||||
// This benchmark is intended for profiling specific operations
|
||||
//
|
||||
// Summary namespace manager reload:
|
||||
//
|
||||
// Memory manager:
|
||||
// Mem: 4.35% of all while github.com/ory/x/configx.(*Provider).reload takes 93.56%
|
||||
// CPU: 1.86% of all while github.com/ory/x/configx.(*Provider).reload takes 67.28%
|
||||
// File manager:
|
||||
// Mem: 0.83% of all while github.com/ory/x/configx.(*Provider).reload takes 96.34%
|
||||
// CPU: 3.84% of all while github.com/ory/x/configx.(*Provider).reload takes 61.53%
|
||||
func BenchmarkServe(b *testing.B) {
|
||||
b.SkipNow()
|
||||
|
||||
b.StopTimer()
|
||||
h := test.Hook{}
|
||||
|
||||
rCtx, cancel := context.WithCancel(context.WithValue(context.Background(),
|
||||
driver.LogrusHookContextKey, &h))
|
||||
defer cancel()
|
||||
|
||||
f, err := os.Create(filepath.Join(b.TempDir(), "keto.yml"))
|
||||
require.NoError(b, err)
|
||||
_, err = f.WriteString(`
|
||||
log:
|
||||
level: debug
|
||||
|
||||
namespaces:
|
||||
- id: 0
|
||||
name: videos
|
||||
|
||||
dsn: memory`)
|
||||
require.NoError(b, err)
|
||||
require.NoError(b, f.Sync())
|
||||
|
||||
flags := pflag.NewFlagSet("testing", pflag.ContinueOnError)
|
||||
configx.RegisterFlags(flags)
|
||||
require.NoError(b, flags.Set(configx.FlagConfig, f.Name()))
|
||||
|
||||
reg, err := driver.NewDefaultRegistry(rCtx, flags, false, nil)
|
||||
|
||||
// setting env vars instead of flags bc flags are not defined on every command
|
||||
require.NoError(b, os.Setenv(client.EnvReadRemote, "127.0.0.1:4466"))
|
||||
require.NoError(b, os.Setenv(client.EnvWriteRemote, "127.0.0.1:4467"))
|
||||
|
||||
c := cmdx.CommandExecuter{
|
||||
New: func() *cobra.Command {
|
||||
return NewRootCmd(nil)
|
||||
},
|
||||
Ctx: context.WithValue(rCtx, driver.RegistryContextKey, reg),
|
||||
}
|
||||
stdOut, stdErr := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
eg := c.ExecBackground(nil, stdOut, stdErr, "serve", "-c", f.Name())
|
||||
b.Cleanup(func() {
|
||||
require.NoError(b, eg.Wait())
|
||||
})
|
||||
require.Equal(b, "SERVING\n", c.ExecNoErr(b, "status", "-b"))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
b.StartTimer()
|
||||
|
||||
b.Run("memory namespace manager", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
require.NoError(b, err)
|
||||
_, err = fmt.Fprintf(f, `
|
||||
log:
|
||||
level: debug
|
||||
|
||||
dsn: memory
|
||||
|
||||
namespaces:
|
||||
- id: %d
|
||||
name: videos`, i)
|
||||
require.NoError(b, err)
|
||||
|
||||
var le *logrus.Entry
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
le = h.LastEntry()
|
||||
if le != nil && le.Message == "Configuration change processed successfully." {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
require.NotNil(b, le, "iteration %d", i)
|
||||
require.Equal(b, "Configuration change processed successfully.", le.Message)
|
||||
|
||||
h.Reset()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("file namespace manager", func(b *testing.B) {
|
||||
nn := []string{filepath.Join(b.TempDir(), "namespace.yml"), filepath.Join(b.TempDir(), "namespace.yml")}
|
||||
for _, n := range nn {
|
||||
require.NoError(b, os.WriteFile(n, []byte(`
|
||||
id: 0
|
||||
name: foo`), 0600))
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
require.NoError(b, err)
|
||||
_, err = fmt.Fprintf(f, `
|
||||
log:
|
||||
level: debug
|
||||
|
||||
dsn: memory
|
||||
|
||||
namespaces: file://%s
|
||||
|
||||
# foo bar this is only for ensuring the old values are not around`, nn[i%2])
|
||||
require.NoError(b, err)
|
||||
require.NoError(b, f.Sync())
|
||||
|
||||
var le *logrus.Entry
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
le = h.LastEntry()
|
||||
if le != nil && le.Message == "Configuration change processed successfully." {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
require.NotNil(b, le)
|
||||
require.Equal(b, "Configuration change processed successfully.", le.Message)
|
||||
|
||||
h.Reset()
|
||||
}
|
||||
})
|
||||
}
|
||||
42
cmd/server/registry.go
Normal file
42
cmd/server/registry.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/popx"
|
||||
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/persistence"
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
func NewRegistry(cmd *cobra.Command, opts []ketoctx.Option) (driver.Registry, error) {
|
||||
reg, err := driver.NewDefaultRegistry(cmd.Context(), cmd.Flags(), false, opts)
|
||||
if errors.Is(err, persistence.ErrNetworkMigrationsMissing) {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Migrations were not applied yet, please apply them first.")
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
} else if validationErr := new(jsonschema.ValidationError); errors.As(err, &validationErr) {
|
||||
// the configx provider already printed the validation error
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := reg.PopConnection(cmd.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := popx.VerifyDialect(cmd.Context(), conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
42
cmd/server/serve.go
Normal file
42
cmd/server/serve.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/ketoctx"
|
||||
)
|
||||
|
||||
// serveCmd represents the serve command
|
||||
func newServe(opts []ketoctx.Option) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Starts the server and serves the HTTP REST and gRPC APIs",
|
||||
Long: `This command opens the network ports and listens to HTTP and gRPC API requests.
|
||||
|
||||
## Configuration
|
||||
|
||||
ORY Keto can be configured using environment variables as well as a configuration file. For more information
|
||||
on configuration options, open the configuration documentation:
|
||||
|
||||
>> https://www.ory.com/keto/docs/reference/configuration <<`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
reg, err := NewRegistry(cmd, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return reg.ServeAllSQA(cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("sqa-opt-out", false, "Disable anonymized telemetry reports - for more information please visit https://www.ory.com/docs/ecosystem/sqa")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command, opts []ketoctx.Option) {
|
||||
parent.AddCommand(newServe(opts))
|
||||
}
|
||||
94
cmd/status/root.go
Normal file
94
cmd/status/root.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/stringsx"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
|
||||
|
||||
cliclient "github.com/ory/keto/cmd/client"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagEndpoint = "endpoint"
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
var endpoint string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get the status of the upstream Keto instance",
|
||||
Long: "Get a status report about the upstream Keto instance. Can also block until the service is healthy.",
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
block, err := cmd.Flags().GetBool(cliclient.FlagBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var connect func(*cobra.Command) (*grpc.ClientConn, error)
|
||||
|
||||
switch endpoints := stringsx.SwitchExact(endpoint); {
|
||||
case endpoints.AddCase("read"):
|
||||
connect = cliclient.GetReadConn
|
||||
case endpoints.AddCase("write"):
|
||||
connect = cliclient.GetWriteConn
|
||||
default:
|
||||
return endpoints.ToUnknownCaseErr()
|
||||
}
|
||||
|
||||
loudPrinter := cmdx.NewLoudOutPrinter(cmd)
|
||||
|
||||
conn, err := connect(cmd)
|
||||
for block && err != nil {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
_, _ = loudPrinter.Println("Context deadline exceeded, going to retry.")
|
||||
conn, err = connect(cmd)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprint(cmd.ErrOrStderr(), err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
c := grpcHealthV1.NewHealthClient(conn)
|
||||
|
||||
for {
|
||||
status, err := c.Check(cmd.Context(), &grpcHealthV1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unable to get a check response: %+v\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
if !block || status.GetStatus() == grpcHealthV1.HealthCheckResponse_SERVING {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), status.GetStatus().String())
|
||||
return nil
|
||||
}
|
||||
_, _ = loudPrinter.Printf("Got the status %s, waiting until %s.\n", status.GetStatus(), grpcHealthV1.HealthCheckResponse_SERVING)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cliclient.RegisterRemoteURLFlags(cmd.Flags())
|
||||
cmdx.RegisterNoiseFlags(cmd.Flags())
|
||||
|
||||
cmd.Flags().StringVar(&endpoint, FlagEndpoint, "read", "which endpoint to use; one of {read, write}")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
parent.AddCommand(newStatusCmd())
|
||||
}
|
||||
153
cmd/status/root_test.go
Normal file
153
cmd/status/root_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
)
|
||||
|
||||
func TestStatusCmd(t *testing.T) {
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{{Name: t.Name()}}, newStatusCmd)
|
||||
|
||||
for _, serverType := range []clienttest.ServerType{clienttest.ReadServer, clienttest.WriteServer} {
|
||||
t.Run("server_type="+string(serverType), func(t *testing.T) {
|
||||
ts.Cmd.PersistentArgs = append(ts.Cmd.PersistentArgs, "--"+cmdx.FlagQuiet, "--"+FlagEndpoint, string(serverType))
|
||||
|
||||
t.Run("case=timeout,noblock", func(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
|
||||
stdErr := cmdx.ExecExpectedErrCtx(ctx, t, newStatusCmd(), "--"+FlagEndpoint, string(serverType))
|
||||
assert.Contains(t, stdErr, "context deadline exceeded")
|
||||
})
|
||||
|
||||
t.Run("case=noblock", func(t *testing.T) {
|
||||
stdOut := ts.Cmd.ExecNoErr(t)
|
||||
assert.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING.String()+"\n", stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=block", func(t *testing.T) {
|
||||
ctx := context.WithValue(t.Context(), client.ContextKeyTimeout, 100*time.Millisecond)
|
||||
|
||||
s := httptest.NewUnstartedServer(ts.NewHandler(t, serverType))
|
||||
s.EnableHTTP2 = true
|
||||
|
||||
startServe := make(chan struct{})
|
||||
|
||||
serveErr := &errgroup.Group{}
|
||||
serveErr.Go(func() error {
|
||||
// wait until we get the signal to start
|
||||
<-startServe
|
||||
s.StartTLS()
|
||||
return nil
|
||||
})
|
||||
|
||||
var stdIn, stdErr bytes.Buffer
|
||||
stdOut := cmdx.CallbackWriter{
|
||||
Callbacks: map[string]func([]byte) error{
|
||||
// once we get the first retry message, we want to start serving
|
||||
"Context deadline exceeded, going to retry.": func([]byte) error {
|
||||
// select ensures we only call this if the chan is not already closed
|
||||
select {
|
||||
case <-startServe:
|
||||
default:
|
||||
close(startServe)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t,
|
||||
cmdx.ExecBackgroundCtx(ctx, newStatusCmd(), &stdIn, &stdOut, &stdErr,
|
||||
"--"+FlagEndpoint, string(serverType),
|
||||
"--"+serverType.FlagName(), s.Listener.Addr().String(),
|
||||
"--insecure-skip-hostname-verification=true",
|
||||
"--"+client.FlagBlock,
|
||||
).Wait(),
|
||||
)
|
||||
|
||||
fullStdOut := stdOut.String()
|
||||
assert.True(t, strings.HasSuffix(fullStdOut, "\n"+grpcHealthV1.HealthCheckResponse_SERVING.String()+"\n"), fullStdOut)
|
||||
|
||||
require.NoError(t, serveErr.Wait())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authInterceptor(header, validValue string) connect.UnaryInterceptorFunc {
|
||||
return func(next connect.UnaryFunc) connect.UnaryFunc {
|
||||
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
||||
val := req.Header().Get(header)
|
||||
if val == "" {
|
||||
return nil, errors.New("not authorized, no header values")
|
||||
}
|
||||
if val != validValue {
|
||||
return nil, errors.New("not authorized, incorrect value")
|
||||
}
|
||||
return next(ctx, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedRequest(t *testing.T) {
|
||||
ts := clienttest.NewTestServer(
|
||||
t, []*namespace.Namespace{{Name: t.Name()}}, newStatusCmd,
|
||||
driver.WithHandlerOptions(connect.WithInterceptors(authInterceptor("authorization", "Bearer secret"))),
|
||||
)
|
||||
|
||||
t.Run("case=not authorized", func(t *testing.T) {
|
||||
out := ts.Cmd.ExecExpectedErr(t)
|
||||
assert.Contains(t, out, "not authorized")
|
||||
})
|
||||
|
||||
t.Run("case=authorized", func(t *testing.T) {
|
||||
t.Setenv("KETO_BEARER_TOKEN", "secret")
|
||||
out := ts.Cmd.ExecNoErr(t)
|
||||
assert.Contains(t, out, "SERVING")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorityRequest(t *testing.T) {
|
||||
ts := clienttest.NewTestServer(
|
||||
t, []*namespace.Namespace{{Name: t.Name()}}, newStatusCmd,
|
||||
driver.WithHandlerOptions(connect.WithInterceptors(authInterceptor("Host", "example.com"))),
|
||||
)
|
||||
|
||||
t.Run("case=no authority", func(t *testing.T) {
|
||||
out := ts.Cmd.ExecExpectedErr(t)
|
||||
assert.Contains(t, out, "not authorized")
|
||||
})
|
||||
|
||||
t.Run("case=env authority", func(t *testing.T) {
|
||||
t.Setenv("KETO_AUTHORITY", "example.com")
|
||||
out := ts.Cmd.ExecNoErr(t)
|
||||
assert.Contains(t, out, "SERVING")
|
||||
})
|
||||
|
||||
t.Run("case=flag authority", func(t *testing.T) {
|
||||
out := ts.Cmd.ExecNoErr(t, "--"+client.FlagAuthority, "example.com")
|
||||
assert.Contains(t, out, "SERVING")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user