snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
37
cmd/relationtuple/create.go
Normal file
37
cmd/relationtuple/create.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
const FlagFile = "file"
|
||||
|
||||
func NewCreateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <subject_namespace>:<subject_id> <relation> <object_namespace>:<object_id>",
|
||||
Short: "Create relationship tuples from inline arguments or JSON files and folders",
|
||||
Long: `Create relationship tuples from inline arguments or JSON files and folders.
|
||||
Inline example:
|
||||
keto relation-tuple create User:alice owner Doc:readme
|
||||
|
||||
From file or folder:
|
||||
keto relation-tuple create -f relationships1.json -f relationships2.json
|
||||
keto relation-tuple create -f relationships-dir1 -f relationships-dir2
|
||||
|
||||
If a directory is provided, all JSON files inside it are processed.
|
||||
Use '-' as filename to read from STD_IN:
|
||||
keto relation-tuple create -f -`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: transactTuples(rts.RelationTupleDelta_ACTION_INSERT),
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
registerPackageFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
168
cmd/relationtuple/create_test.go
Normal file
168
cmd/relationtuple/create_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/randx"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func renderTable(table cmdx.Table) string {
|
||||
var buf bytes.Buffer
|
||||
cmdx.PrintTablef(&buf, table)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestCreateCmd(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
otherNspace := &namespace.Namespace{Name: "other"}
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser, otherNspace}, newCmd)
|
||||
|
||||
createFile := func(t *testing.T, tuple any) string {
|
||||
dir := t.TempDir()
|
||||
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := randx.MustString(10, randx.AlphaLowerNum) + ".json"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), data, 0o600))
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
|
||||
type getTuple func() *ketoapi.RelationTuple
|
||||
|
||||
fns := map[string]getTuple{
|
||||
"subjectID": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
},
|
||||
"subjectSet": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
},
|
||||
}
|
||||
|
||||
for name, createTuple := range fns {
|
||||
t.Run("case="+name, func(t *testing.T) {
|
||||
t.Run("case=creates single tuple from files", func(t *testing.T) {
|
||||
tuple1 := createTuple()
|
||||
tuple2 := createTuple()
|
||||
|
||||
tmpFile := createFile(t, tuple1)
|
||||
tmpFile2 := createFile(t, []*ketoapi.RelationTuple{tuple2})
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f", tmpFile, "-f", tmpFile2)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
|
||||
// assert that we can `get` it
|
||||
res1 := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple1.Namespace, "--object", tuple1.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection([]*ketoapi.RelationTuple{tuple1}),
|
||||
IsLastPage: true,
|
||||
}), res1)
|
||||
|
||||
// assert that we can `get` it
|
||||
res2 := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple2.Namespace, "--object", tuple2.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection([]*ketoapi.RelationTuple{tuple2}),
|
||||
IsLastPage: true,
|
||||
}), res2)
|
||||
})
|
||||
|
||||
t.Run("case=creates tuples from files and stdin", func(t *testing.T) {
|
||||
tuple1 := createTuple()
|
||||
tupleStdin := createTuple()
|
||||
|
||||
tupleFile := createFile(t, tuple1)
|
||||
data, err := json.Marshal(tupleStdin)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(bytes.NewReader(data), "relation-tuple", "create", "-f", "-", "-f", tupleFile)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stdErr)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tupleStdin, tuple1})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=creates tuples from directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tuple1 := createTuple()
|
||||
tuple2 := createTuple()
|
||||
|
||||
data1, err := json.Marshal(tuple1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple1.json"), data1, 0o600))
|
||||
|
||||
data2, err := json.Marshal(tuple2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple2.json"), data2, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f="+dir)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=creates inline tuple", func(t *testing.T) {
|
||||
tuple := createTuple()
|
||||
|
||||
subject := ""
|
||||
if tuple.SubjectID != nil {
|
||||
subject = *tuple.SubjectID
|
||||
} else {
|
||||
subject = tuple.SubjectSet.String()
|
||||
}
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "create", subject, tuple.Relation, tuple.Namespace+":"+tuple.Object)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=fails on invalid JSON", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "invalid.json")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("not valid json"), 0o600))
|
||||
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "-f="+tmpFile)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "could not decode")
|
||||
})
|
||||
|
||||
t.Run("case=fails on nonexistent file", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "-f=/nonexistent/file.json")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "error getting stats")
|
||||
})
|
||||
|
||||
t.Run("case=fails on json file arguments without -f", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "file1.json", "file2.json")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected inline arguments or JSON files")
|
||||
})
|
||||
|
||||
t.Run("case=missing colon between ns and obj", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "create", "sns:so1", "rel", "nsobj")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format, got \"nsobj\"")
|
||||
})
|
||||
}
|
||||
282
cmd/relationtuple/delete.go
Normal file
282
cmd/relationtuple/delete.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
"github.com/ory/keto/cmd/helpers"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxFileSizeBytes = 10 * 1024 * 1024 // 10 MB
|
||||
MaxDirDepth = 3
|
||||
)
|
||||
|
||||
func NewDeleteCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <subject_namespace>:<subject_id> <relation> <object_namespace>:<object_id>",
|
||||
Short: "Delete relationship tuples from inline arguments or JSON files and folders",
|
||||
Long: `Delete relationship tuples from inline arguments or JSON files and folders.
|
||||
Inline example:
|
||||
keto relation-tuple delete User:alice owner Doc:readme
|
||||
|
||||
From file or folder:
|
||||
keto relation-tuple delete -f relationships1.json -f relationships2.json
|
||||
keto relation-tuple delete -f relationships-dir1 -f relationships-dir2
|
||||
|
||||
If a directory is provided, all JSON files inside it are processed.
|
||||
Use '-' as filename to read from STD_IN:
|
||||
keto relation-tuple delete -f -`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: transactTuples(rts.RelationTupleDelta_ACTION_DELETE),
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
registerPackageFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func transactTuples(action rts.RelationTupleDelta_Action) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
files, err := cmd.Flags().GetStringSlice(FlagFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) > 0 {
|
||||
return transactTuplesFromFiles(cmd, action, files)
|
||||
}
|
||||
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("expected inline arguments or JSON files and folders")
|
||||
}
|
||||
|
||||
return transactTupleFromInlineArgs(cmd, action, args)
|
||||
}
|
||||
}
|
||||
|
||||
func transactTupleFromInlineArgs(cmd *cobra.Command, action rts.RelationTupleDelta_Action, args []string) error {
|
||||
namespace, object, err := helpers.ParseNamespaceObject(cmd, args[2:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub, err := helpers.ParseSubject(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse subject %q: %w", args[0], err)
|
||||
}
|
||||
tuple := &rts.RelationTuple{
|
||||
Namespace: namespace,
|
||||
Object: object,
|
||||
Relation: args[1],
|
||||
Subject: sub,
|
||||
}
|
||||
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.TransactRelationTuples(cmd.Context(), &rts.TransactRelationTuplesRequest{
|
||||
RelationTupleDeltas: []*rts.RelationTupleDelta{{
|
||||
Action: action,
|
||||
RelationTuple: tuple,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
c, err := NewProtoCollection([]*rts.RelationTuple{tuple})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "error printing the response: %s\n", err)
|
||||
}
|
||||
cmdx.PrintTable(cmd, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func transactTuplesFromFiles(cmd *cobra.Command, action rts.RelationTupleDelta_Action, files []string) error {
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
var deltas []*rts.RelationTupleDelta
|
||||
for _, fn := range files {
|
||||
parsed, err := readTuplesFromPath(cmd, fn, 0, decodeTuples)
|
||||
if err != nil {
|
||||
// silencing the usage message as from here on, there can't be any syntax error in the command usage,
|
||||
// but rather an error with the content of the files
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
for _, t := range parsed {
|
||||
tuples = append(tuples, t)
|
||||
deltas = append(deltas, &rts.RelationTupleDelta{
|
||||
Action: action,
|
||||
RelationTuple: t.ToProto(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.TransactRelationTuples(cmd.Context(), &rts.TransactRelationTuplesRequest{
|
||||
RelationTupleDeltas: deltas,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
cmdx.PrintTable(cmd, NewAPICollection(tuples))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TupleDecoder reads relation tuples from a reader. Implementations handle
|
||||
// format-specific decoding (e.g. JSON, human-readable).
|
||||
type TupleDecoder func(r io.Reader) ([]*ketoapi.RelationTuple, error)
|
||||
|
||||
// readTuplesFromPath reads relation tuples from a file path, directory, or
|
||||
// stdin (when path is "-"). The depth parameter tracks directory nesting to
|
||||
// enforce MaxDirDepth. The decode function handles format-specific parsing.
|
||||
func readTuplesFromPath(cmd *cobra.Command, path string, depth int, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
if path == "-" {
|
||||
tuples, err := decode(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin: %w", err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting stats for %s: %w", path, err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if depth >= MaxDirDepth {
|
||||
return nil, fmt.Errorf("maximum directory depth of %d exceeded at %s", MaxDirDepth, path)
|
||||
}
|
||||
tuples, err := readTuplesFromDir(cleanPath, decode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
return readTuplesFromFile(cleanPath, decode)
|
||||
}
|
||||
|
||||
func readTuplesFromDir(dir string, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
|
||||
fsys := os.DirFS(dir)
|
||||
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return fmt.Errorf("walking %s: %w", path, walkErr)
|
||||
}
|
||||
|
||||
depth := 0
|
||||
if path != "." {
|
||||
depth = strings.Count(path, "/") + 1
|
||||
}
|
||||
|
||||
if depth > MaxDirDepth {
|
||||
return fmt.Errorf("maximum directory depth of %d exceeded at %s", MaxDirDepth, path)
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, filepath.FromSlash(path))
|
||||
|
||||
t, err := readTuplesFromFile(fullPath, decode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tuples = append(tuples, t...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
// readTuplesFromFile opens a single file and decodes its content using the
|
||||
// provided decoder.
|
||||
func readTuplesFromFile(path string, decode TupleDecoder) ([]*ketoapi.RelationTuple, error) {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening file %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
tuples, err := decode(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
// decodeTuples decodes JSON from the reader as either a single RelationTuple
|
||||
// or an array of RelationTuples. Input is limited to MaxFileSizeBytes.
|
||||
func decodeTuples(r io.Reader) ([]*ketoapi.RelationTuple, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, MaxFileSizeBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
if len(data) > MaxFileSizeBytes {
|
||||
return nil, fmt.Errorf("input exceeds maximum size of %d bytes", MaxFileSizeBytes)
|
||||
}
|
||||
|
||||
// Trim UTF-8 BOM first, then leading/trailing whitespace
|
||||
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
trimmed = bytes.TrimSpace(trimmed)
|
||||
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("empty input after trimming whitespace")
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if trimmed[0] == '[' {
|
||||
var tuples []*ketoapi.RelationTuple
|
||||
if err := decoder.Decode(&tuples); err != nil {
|
||||
return nil, fmt.Errorf("could not decode: %w", err)
|
||||
}
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
var tuple ketoapi.RelationTuple
|
||||
if err := decoder.Decode(&tuple); err != nil {
|
||||
return nil, fmt.Errorf("could not decode: %w", err)
|
||||
}
|
||||
return []*ketoapi.RelationTuple{&tuple}, nil
|
||||
}
|
||||
67
cmd/relationtuple/delete_all.go
Normal file
67
cmd/relationtuple/delete_all.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagForce = "force"
|
||||
)
|
||||
|
||||
func NewDeleteAllCmd() *cobra.Command {
|
||||
var force bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete-all",
|
||||
Short: "Delete ALL relationships matching the relation query.",
|
||||
Long: "Delete all relationships matching the relation query.\n" +
|
||||
"It is recommended to first run the command without the `--force` flag to verify that the operation is safe.",
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !force {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "WARNING: This operation is not reversible. Please use the `--%s` flag to proceed.\nThese tuples would be deleted:\n\n", FlagForce)
|
||||
return getTuples(new(int32(100)), new(""))(cmd, args)
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed(FlagSubject) {
|
||||
return fmt.Errorf("usage of --%s is not supported anymore, use --%s or --%s respectively", FlagSubject, FlagSubjectID, FlagSubjectSet)
|
||||
}
|
||||
|
||||
query, err := readQueryFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := client.GetWriteConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
cl := rts.NewWriteServiceClient(conn)
|
||||
_, err = cl.DeleteRelationTuples(cmd.Context(), &rts.DeleteRelationTuplesRequest{
|
||||
RelationQuery: query,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registerPackageFlags(cmd.Flags())
|
||||
registerRelationTupleFlags(cmd.Flags())
|
||||
cmd.Flags().BoolVar(&force, FlagForce, false, "Force the deletion of relationships")
|
||||
|
||||
return cmd
|
||||
}
|
||||
25
cmd/relationtuple/delete_all_test.go
Normal file
25
cmd/relationtuple/delete_all_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteAllCmd(t *testing.T) {
|
||||
t.Run("executes get instead of delete when run without force", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := NewDeleteAllCmd()
|
||||
// we will get an error here because there is no server running, but we really only care about the execution path
|
||||
stdout, _, _ := cmdx.ExecCtx(ctx, cmd, nil, "--insecure-skip-hostname-verification=true")
|
||||
assert.Contains(t, stdout, "WARNING: This operation is not reversible.")
|
||||
})
|
||||
}
|
||||
236
cmd/relationtuple/delete_test.go
Normal file
236
cmd/relationtuple/delete_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/cmd/client/clienttest"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func TestDeleteCmd(t *testing.T) {
|
||||
nspace := &namespace.Namespace{Name: t.Name()}
|
||||
nspaceUser := &namespace.Namespace{Name: "User"}
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keto",
|
||||
}
|
||||
RegisterCommandsRecursive(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
otherNspace := &namespace.Namespace{Name: "other"}
|
||||
ts := clienttest.NewTestServer(t, []*namespace.Namespace{nspace, nspaceUser, otherNspace}, newCmd)
|
||||
|
||||
// createTuple is a helper that creates a tuple via the create command so we can then delete it.
|
||||
createTuple := func(t *testing.T, tuple *ketoapi.RelationTuple) {
|
||||
t.Helper()
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(tmpFile, data, 0o600))
|
||||
ts.Cmd.ExecNoErr(t, "relation-tuple", "create", "-f="+tmpFile)
|
||||
}
|
||||
|
||||
type getTuple func() *ketoapi.RelationTuple
|
||||
|
||||
fns := map[string]getTuple{
|
||||
"subjectID": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
},
|
||||
"subjectSet": func() *ketoapi.RelationTuple {
|
||||
return clienttest.RandomTupleWithSubjectSet(nspace.Name, nspaceUser.Name)
|
||||
},
|
||||
}
|
||||
|
||||
for name, makeTuple := range fns {
|
||||
t.Run("case="+name, func(t *testing.T) {
|
||||
t.Run("case=deletes single tuple from file", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(tmpFile, data, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
|
||||
// assert that the tuple is gone
|
||||
res := ts.Cmd.ExecNoErr(t, "relation-tuple", "get", "--namespace", tuple.Namespace, "--object", tuple.Object)
|
||||
require.Equal(t, renderTable(&responseOutput{
|
||||
RelationTuples: NewAPICollection(nil),
|
||||
IsLastPage: true,
|
||||
}), res)
|
||||
})
|
||||
|
||||
t.Run("case=deletes tuples from stdin", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := ts.Cmd.Exec(bytes.NewReader(data), "relation-tuple", "delete", "-f=-")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stdErr)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=deletes tuples from directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tuple1 := makeTuple()
|
||||
tuple2 := makeTuple()
|
||||
createTuple(t, tuple1)
|
||||
createTuple(t, tuple2)
|
||||
|
||||
data1, err := json.Marshal(tuple1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple1.json"), data1, 0o600))
|
||||
|
||||
data2, err := json.Marshal(tuple2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tuple2.json"), data2, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+dir)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut)
|
||||
})
|
||||
|
||||
t.Run("case=deletes inline tuple", func(t *testing.T) {
|
||||
tuple := makeTuple()
|
||||
createTuple(t, tuple)
|
||||
|
||||
subject := ""
|
||||
if tuple.SubjectID != nil {
|
||||
subject = *tuple.SubjectID
|
||||
} else {
|
||||
subject = tuple.SubjectSet.String()
|
||||
}
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", subject, tuple.Relation, tuple.Namespace+":"+tuple.Object)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=fails on invalid JSON", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "invalid.json")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("not valid json"), 0o600))
|
||||
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "could not decode")
|
||||
})
|
||||
|
||||
t.Run("case=fails on nonexistent file", func(t *testing.T) {
|
||||
stdErr := ts.Cmd.ExecExpectedErr(t, "relation-tuple", "delete", "-f=/nonexistent/file.json")
|
||||
require.Contains(t, stdErr, "error getting stats")
|
||||
})
|
||||
|
||||
t.Run("case=fails on json file arguments without -f", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "file1.json", "file2.json")
|
||||
require.Error(t, err)
|
||||
require.NotEmpty(t, stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=missing colon between ns and obj", func(t *testing.T) {
|
||||
_, stdErr, err := ts.Cmd.Exec(nil, "relation-tuple", "delete", "sns:so1", "rel", "nsobj")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, stdErr, "expected <object_namespace>:<object_id> format, got \"nsobj\"")
|
||||
})
|
||||
|
||||
t.Run("case=handles JSON with leading whitespace and BOM", func(t *testing.T) {
|
||||
// Test with various prefixes: whitespace, BOM, and combination
|
||||
prefixes := [][]byte{
|
||||
{'\n', '\n', ' ', ' ', '\t'},
|
||||
{0xEF, 0xBB, 0xBF},
|
||||
{0xEF, 0xBB, 0xBF, '\n', ' ', ' '},
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
// Test single tuple
|
||||
tuple := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
createTuple(t, tuple)
|
||||
|
||||
tmpFile := filepath.Join(t.TempDir(), "tuple.json")
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
dataWithPrefix := append(prefix, data...)
|
||||
require.NoError(t, os.WriteFile(tmpFile, dataWithPrefix, 0o600))
|
||||
|
||||
stdOut := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple})), stdOut)
|
||||
|
||||
// Test array
|
||||
tuple1 := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
tuple2 := clienttest.RandomTupleWithSubjectID(nspace.Name)
|
||||
createTuple(t, tuple1)
|
||||
createTuple(t, tuple2)
|
||||
|
||||
tmpFile2 := filepath.Join(t.TempDir(), "tuples.json")
|
||||
data2, err := json.Marshal([]*ketoapi.RelationTuple{tuple1, tuple2})
|
||||
require.NoError(t, err)
|
||||
dataWithPrefix2 := append(prefix, data2...)
|
||||
require.NoError(t, os.WriteFile(tmpFile2, dataWithPrefix2, 0o600))
|
||||
|
||||
stdOut2 := ts.Cmd.ExecNoErr(t, "relation-tuple", "delete", "-f="+tmpFile2)
|
||||
require.Equal(t, renderTable(NewAPICollection([]*ketoapi.RelationTuple{tuple1, tuple2})), stdOut2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadTuplesFromDir(t *testing.T) {
|
||||
writeTuple := func(t *testing.T, dir string, name string) *ketoapi.RelationTuple {
|
||||
t.Helper()
|
||||
tuple := clienttest.RandomTupleWithSubjectID(t.Name())
|
||||
data, err := json.Marshal(tuple)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
|
||||
return tuple
|
||||
}
|
||||
|
||||
mkdirs := func(t *testing.T, root string, depth int) string {
|
||||
t.Helper()
|
||||
dir := root
|
||||
for i := range depth {
|
||||
dir = filepath.Join(dir, fmt.Sprintf("d%d", i))
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(dir, 0o750))
|
||||
return dir
|
||||
}
|
||||
|
||||
t.Run("case=succeeds at MaxDirDepth nested directories", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
deepest := mkdirs(t, root, MaxDirDepth)
|
||||
|
||||
// Place a file inside the second-to-last directory so its
|
||||
// path-component depth equals MaxDirDepth.
|
||||
tuple := writeTuple(t, filepath.Dir(deepest), "tuple.json")
|
||||
|
||||
tuples, err := readTuplesFromDir(root, decodeTuples)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []*ketoapi.RelationTuple{tuple}, tuples)
|
||||
})
|
||||
|
||||
t.Run("case=fails at MaxDirDepth+1 nested directories", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirs(t, root, MaxDirDepth+1)
|
||||
|
||||
_, err := readTuplesFromDir(root, decodeTuples)
|
||||
require.ErrorContains(t, err, "maximum directory depth")
|
||||
})
|
||||
}
|
||||
157
cmd/relationtuple/get.go
Normal file
157
cmd/relationtuple/get.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagNamespace = "namespace"
|
||||
FlagSubject = "subject"
|
||||
FlagSubjectID = "subject-id"
|
||||
FlagSubjectSet = "subject-set"
|
||||
FlagRelation = "relation"
|
||||
FlagObject = "object"
|
||||
FlagPageSize = "page-size"
|
||||
FlagPageToken = "page-token"
|
||||
)
|
||||
|
||||
func registerRelationTupleFlags(flags *pflag.FlagSet) {
|
||||
flags.String(FlagNamespace, "", "Set the requested namespace")
|
||||
flags.String(FlagSubjectID, "", "Set the requested subject ID")
|
||||
flags.String(FlagSubjectSet, "", `Set the requested subject set; format: "namespace:object#relation"`)
|
||||
flags.String(FlagRelation, "", "Set the requested relation")
|
||||
flags.String(FlagObject, "", "Set the requested object")
|
||||
|
||||
flags.String(FlagSubject, "", "")
|
||||
if err := flags.MarkHidden(FlagSubject); err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func readQueryFromFlags(cmd *cobra.Command) (*rts.RelationQuery, error) {
|
||||
getStringPtr := func(flagName string) *string {
|
||||
if f := cmd.Flags().Lookup(flagName); f.Changed {
|
||||
return new(f.Value.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
query := &ketoapi.RelationQuery{
|
||||
Namespace: getStringPtr(FlagNamespace),
|
||||
Object: getStringPtr(FlagObject),
|
||||
Relation: getStringPtr(FlagRelation),
|
||||
SubjectID: getStringPtr(FlagSubjectID),
|
||||
}
|
||||
if f := cmd.Flags().Lookup(FlagSubjectSet); f.Changed {
|
||||
s, err := (&ketoapi.SubjectSet{}).FromString(f.Value.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.SubjectSet = s
|
||||
}
|
||||
|
||||
return query.ToProto(), nil
|
||||
}
|
||||
|
||||
func NewGetCmd() *cobra.Command {
|
||||
var (
|
||||
pageSize int32
|
||||
pageToken string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get relationships",
|
||||
Long: "Get relationships matching the given partial tuple.\n" +
|
||||
"Returns paginated results.",
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: getTuples(&pageSize, &pageToken),
|
||||
}
|
||||
|
||||
registerPackageFlags(cmd.Flags())
|
||||
registerRelationTupleFlags(cmd.Flags())
|
||||
|
||||
cmd.Flags().StringVar(&pageToken, FlagPageToken, "", "page token acquired from a previous response")
|
||||
cmd.Flags().Int32Var(&pageSize, FlagPageSize, 100, "maximum number of items to return")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getTuples(pageSize *int32, pageToken *string) func(cmd *cobra.Command, _ []string) error {
|
||||
return func(cmd *cobra.Command, _ []string) error {
|
||||
if cmd.Flags().Changed(FlagSubject) {
|
||||
return fmt.Errorf("usage of --%s is not supported anymore, use --%s or --%s respectively", FlagSubject, FlagSubjectID, FlagSubjectSet)
|
||||
}
|
||||
|
||||
conn, err := client.GetReadConn(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cl := rts.NewReadServiceClient(conn)
|
||||
query, err := readQueryFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := cl.ListRelationTuples(cmd.Context(), &rts.ListRelationTuplesRequest{
|
||||
RelationQuery: query,
|
||||
PageSize: *pageSize,
|
||||
PageToken: *pageToken,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not make request: %s\n", err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
relationTuples, err := NewProtoCollection(resp.RelationTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmdx.PrintTable(cmd, &responseOutput{
|
||||
RelationTuples: relationTuples,
|
||||
IsLastPage: resp.NextPageToken == "",
|
||||
NextPageToken: resp.NextPageToken,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type responseOutput struct {
|
||||
RelationTuples *Collection `json:"relation_tuples"`
|
||||
IsLastPage bool `json:"is_last_page"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
func (r *responseOutput) Header() []string {
|
||||
return r.RelationTuples.Header()
|
||||
}
|
||||
|
||||
func (r *responseOutput) Table() [][]string {
|
||||
return append(
|
||||
r.RelationTuples.Table(),
|
||||
[]string{},
|
||||
[]string{"NEXT PAGE TOKEN", r.NextPageToken},
|
||||
[]string{"IS LAST PAGE", strconv.FormatBool(r.IsLastPage)},
|
||||
)
|
||||
}
|
||||
|
||||
func (r *responseOutput) Interface() any { return r }
|
||||
func (r *responseOutput) Len() int { return r.RelationTuples.Len() + 3 }
|
||||
func (r *responseOutput) IDs() []string { return r.RelationTuples.IDs() }
|
||||
|
||||
var _ cmdx.Table = (*responseOutput)(nil)
|
||||
114
cmd/relationtuple/output.go
Normal file
114
cmd/relationtuple/output.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
Collection struct {
|
||||
apiRelations []*ketoapi.RelationTuple
|
||||
}
|
||||
OutputTuple struct {
|
||||
*ketoapi.RelationTuple
|
||||
}
|
||||
)
|
||||
|
||||
func NewProtoCollection(rels []*rts.RelationTuple) (*Collection, error) {
|
||||
r := &Collection{apiRelations: make([]*ketoapi.RelationTuple, len(rels))}
|
||||
for i, rel := range rels {
|
||||
var err error
|
||||
r.apiRelations[i], err = (&ketoapi.RelationTuple{}).FromDataProvider(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func MustNewProtoCollection(rels []*rts.RelationTuple) *Collection {
|
||||
c, err := NewProtoCollection(rels)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func NewAPICollection(rels []*ketoapi.RelationTuple) *Collection {
|
||||
return &Collection{apiRelations: rels}
|
||||
}
|
||||
|
||||
func (r *Collection) Header() []string {
|
||||
return []string{
|
||||
"NAMESPACE",
|
||||
"OBJECT",
|
||||
"RELATION NAME",
|
||||
"SUBJECT",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Collection) Table() [][]string {
|
||||
ir := r.apiRelations
|
||||
|
||||
data := make([][]string, len(ir))
|
||||
for i, rel := range ir {
|
||||
var sub string
|
||||
if rel.SubjectID != nil {
|
||||
sub = *rel.SubjectID
|
||||
} else {
|
||||
sub = rel.SubjectSet.String()
|
||||
}
|
||||
|
||||
data[i] = []string{rel.Namespace, rel.Object, rel.Relation, sub}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (r *Collection) Interface() any { return r.apiRelations }
|
||||
func (r *Collection) UnmarshalJSON(raw []byte) error { return json.Unmarshal(raw, &r.apiRelations) }
|
||||
func (r *Collection) Len() int { return len(r.apiRelations) }
|
||||
|
||||
func (r *Collection) MarshalJSON() ([]byte, error) {
|
||||
ir := r.apiRelations
|
||||
return json.Marshal(ir)
|
||||
}
|
||||
|
||||
func (r *Collection) IDs() []string {
|
||||
ts := r.apiRelations
|
||||
ids := make([]string, len(ts))
|
||||
for i, rt := range ts {
|
||||
ids[i] = rt.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *OutputTuple) Header() []string {
|
||||
return []string{
|
||||
"NAMESPACE",
|
||||
"OBJECT ID",
|
||||
"RELATION NAME",
|
||||
"SUBJECT",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OutputTuple) Columns() []string {
|
||||
return []string{
|
||||
r.Namespace,
|
||||
r.Object,
|
||||
r.Relation,
|
||||
outputSubject(r.RelationTuple),
|
||||
}
|
||||
}
|
||||
|
||||
func outputSubject(r *ketoapi.RelationTuple) string {
|
||||
if r.SubjectID != nil {
|
||||
return *r.SubjectID
|
||||
}
|
||||
return r.SubjectSet.String()
|
||||
}
|
||||
118
cmd/relationtuple/output_test.go
Normal file
118
cmd/relationtuple/output_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func TestRelationCollection(t *testing.T) {
|
||||
t.Run("case=prints all", func(t *testing.T) {
|
||||
expected := make([]*ketoapi.RelationTuple, 3)
|
||||
for i := range expected {
|
||||
expected[i] = &ketoapi.RelationTuple{
|
||||
Namespace: "n" + strconv.Itoa(i),
|
||||
Object: "o" + strconv.Itoa(i),
|
||||
Relation: "r" + strconv.Itoa(i),
|
||||
SubjectID: new("s" + strconv.Itoa(i)),
|
||||
}
|
||||
}
|
||||
expected[2].SubjectSet = &ketoapi.SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
}
|
||||
expected[2].SubjectID = nil
|
||||
|
||||
proto := make([]*rts.RelationTuple, 3)
|
||||
for i := range expected {
|
||||
proto[i] = &rts.RelationTuple{
|
||||
Namespace: "n" + strconv.Itoa(i),
|
||||
Object: "o" + strconv.Itoa(i),
|
||||
Relation: "r" + strconv.Itoa(i),
|
||||
Subject: rts.NewSubjectID("s" + strconv.Itoa(i)),
|
||||
}
|
||||
}
|
||||
proto[2].Subject = rts.NewSubjectSet("sn", "so", "sr")
|
||||
|
||||
for i, c := range []*Collection{
|
||||
NewAPICollection(expected),
|
||||
MustNewProtoCollection(proto),
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
|
||||
var vals []string
|
||||
for _, row := range c.Table() {
|
||||
vals = append(vals, row...)
|
||||
}
|
||||
|
||||
ev := reflect.ValueOf(expected)
|
||||
for el := 0; el < ev.Len(); el++ {
|
||||
et := reflect.TypeOf(expected).Elem().Elem()
|
||||
|
||||
for f := 0; f < et.NumField(); f++ {
|
||||
v := ev.Index(el).Elem().Field(f)
|
||||
// private field or nil
|
||||
if !v.CanSet() || (v.Kind() == reflect.Pointer && v.IsNil()) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch k := v.Kind(); {
|
||||
case k == reflect.String:
|
||||
assert.Contains(t, vals, v.String())
|
||||
case k == reflect.Pointer && v.Elem().Kind() == reflect.String:
|
||||
assert.Contains(t, vals, v.Elem().String())
|
||||
default:
|
||||
t.Logf("unhandled kind %s %T", v.Kind(), v.Interface())
|
||||
str := v.MethodByName("String").Call(nil)[0].String()
|
||||
assert.Contains(t, vals, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("func=NewProtoCollection", func(t *testing.T) {
|
||||
_, err := NewProtoCollection([]*rts.RelationTuple{{ /*subject is nil*/ }})
|
||||
require.ErrorIs(t, err, ketoapi.ErrNilSubject())
|
||||
})
|
||||
|
||||
t.Run("func=json", func(t *testing.T) {
|
||||
ts := []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectID: new("s"),
|
||||
},
|
||||
{
|
||||
Namespace: "n",
|
||||
Object: "o",
|
||||
Relation: "r",
|
||||
SubjectSet: &ketoapi.SubjectSet{
|
||||
Namespace: "sn",
|
||||
Object: "so",
|
||||
Relation: "sr",
|
||||
},
|
||||
},
|
||||
}
|
||||
expected, err := json.Marshal(ts)
|
||||
require.NoError(t, err)
|
||||
|
||||
collection := NewAPICollection(ts)
|
||||
actual, err := json.Marshal(collection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(expected), string(actual))
|
||||
})
|
||||
}
|
||||
93
cmd/relationtuple/parse.go
Normal file
93
cmd/relationtuple/parse.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func NewParseCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "parse",
|
||||
Short: "Parse human readable relationships",
|
||||
Long: "Parse human readable relationships as used in the documentation.\n" +
|
||||
"Supports various output formats. Especially useful for piping into other commands by using `--format json`.\n" +
|
||||
"Ignores comments (lines starting with `//`) and blank lines.\n\n" +
|
||||
"From file or folder:\n" +
|
||||
"\tketo relation-tuple parse -f tuples1.txt -f tuples2.txt\n" +
|
||||
"\tketo relation-tuple parse -f tuples-dir\n\n" +
|
||||
"Use '-' as filename to read from STD_IN:\n" +
|
||||
"\tketo relation-tuple parse -f -",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
files, err := cmd.Flags().GetStringSlice(FlagFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("at least one file must be specified with -f")
|
||||
}
|
||||
|
||||
var rts []*ketoapi.RelationTuple
|
||||
for _, fn := range files {
|
||||
rtss, err := readTuplesFromPath(cmd, fn, 0, parseHumanReadable)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
rts = append(rts, rtss...)
|
||||
}
|
||||
|
||||
if len(rts) == 1 {
|
||||
cmdx.PrintRow(cmd, rts[0])
|
||||
return nil
|
||||
}
|
||||
cmdx.PrintTable(cmd, NewAPICollection(rts))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerFileFlag(cmd.Flags())
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseHumanReadable reads human-readable relation tuples from the reader.
|
||||
// Input is limited to MaxFileSizeBytes.
|
||||
func parseHumanReadable(r io.Reader) ([]*ketoapi.RelationTuple, error) {
|
||||
fc, err := io.ReadAll(io.LimitReader(r, int64(MaxFileSizeBytes)+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
if len(fc) > MaxFileSizeBytes {
|
||||
return nil, fmt.Errorf("input exceeds maximum size of %d bytes", MaxFileSizeBytes)
|
||||
}
|
||||
|
||||
parts := strings.Split(string(fc), "\n")
|
||||
rts := make([]*ketoapi.RelationTuple, 0, len(parts))
|
||||
for i, row := range parts {
|
||||
row = strings.TrimSpace(row)
|
||||
// ignore comments and empty lines
|
||||
if row == "" || strings.HasPrefix(row, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
rt, err := (&ketoapi.RelationTuple{}).FromString(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: could not decode %q: %w", i+1, row, err)
|
||||
}
|
||||
rts = append(rts, rt)
|
||||
}
|
||||
|
||||
return rts, nil
|
||||
}
|
||||
132
cmd/relationtuple/parse_test.go
Normal file
132
cmd/relationtuple/parse_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/keto/ketoapi"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseTuplesFromPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input, name string
|
||||
expected []*ketoapi.RelationTuple
|
||||
}{
|
||||
{
|
||||
name: "single basic tuple",
|
||||
input: "nspace:obj#rel@sub\n",
|
||||
expected: []*ketoapi.RelationTuple{{
|
||||
Namespace: "nspace",
|
||||
Object: "obj",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "multiple tuples",
|
||||
input: `nspace:obj1#rel@sub1
|
||||
nspace:obj2#rel@sub2
|
||||
nspace:obj2#rel@(nspace:obj2#rel)`,
|
||||
expected: []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj1",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub1"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub2"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectSet: &ketoapi.SubjectSet{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "crap around tuples",
|
||||
input: `// foo comment
|
||||
nspace:obj#rel@sub
|
||||
|
||||
// also indentation and trailing spaces
|
||||
nspace:indent#rel@sub `,
|
||||
expected: []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "indent",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub"),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewBufferString(tc.input))
|
||||
|
||||
actual, err := readTuplesFromPath(cmd, "-", 0, parseHumanReadable)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=error contains line number", func(t *testing.T) {
|
||||
input := "nspace:obj#rel@subns:subid\n\n\ninvalid-line\nnspace:obj2#rel@subns:subid2\n"
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewBufferString(input))
|
||||
|
||||
_, err := readTuplesFromPath(cmd, "-", 0, parseHumanReadable)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "line 4:")
|
||||
})
|
||||
|
||||
t.Run("case=reads from fs", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fn := filepath.Join(dir, "test.tuples")
|
||||
require.NoError(t, os.WriteFile(fn, []byte(`
|
||||
nspace:obj1#rel@sub1
|
||||
|
||||
nspace:obj2#rel@sub2`), 0o600))
|
||||
|
||||
actual, err := readTuplesFromPath(&cobra.Command{}, fn, 0, parseHumanReadable)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []*ketoapi.RelationTuple{
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj1",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub1"),
|
||||
},
|
||||
{
|
||||
Namespace: "nspace",
|
||||
Object: "obj2",
|
||||
Relation: "rel",
|
||||
SubjectID: new("sub2"),
|
||||
},
|
||||
}, actual)
|
||||
})
|
||||
}
|
||||
37
cmd/relationtuple/root.go
Normal file
37
cmd/relationtuple/root.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relationtuple
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/keto/cmd/client"
|
||||
)
|
||||
|
||||
func newRelationCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "relation-tuple",
|
||||
Short: "Read and manipulate relationships",
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterCommandsRecursive(parent *cobra.Command) {
|
||||
relationCmd := newRelationCmd()
|
||||
|
||||
parent.AddCommand(relationCmd)
|
||||
|
||||
relationCmd.AddCommand(NewGetCmd(), NewCreateCmd(), NewDeleteCmd(), NewDeleteAllCmd(), NewParseCmd())
|
||||
}
|
||||
|
||||
func registerPackageFlags(flags *pflag.FlagSet) {
|
||||
client.RegisterRemoteURLFlags(flags)
|
||||
cmdx.RegisterFormatFlags(flags)
|
||||
}
|
||||
|
||||
func registerFileFlag(flags *pflag.FlagSet) {
|
||||
flags.StringSliceP(FlagFile, "f", nil, "Read relationships from JSON files or directories (use the special filename - for stdin)")
|
||||
}
|
||||
Reference in New Issue
Block a user