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

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