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

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

92
cmd/check/root.go Normal file
View 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
View 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
View 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.")
}

View 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)),
}
}

View 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
View 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.")
}

View 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
View 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
View 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
View 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, ","))
}
}

View 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
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -0,0 +1 @@
"import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'\n\n// Declare new namespaces as classes that implement `Namespace`"

View File

@@ -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"

View File

@@ -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"

View 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 }}

View 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}))
}

View 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
View 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
View 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
}
}

View 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
}

View 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
}

View 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
View 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
}

View 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
}

View 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.")
})
}

View 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
View 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
View 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()
}

View 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))
})
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
})
}