feat(P02): ACL — SPIFFE + token identities, deny-by-default

internal/acl/acl.go: Identity, Permission, ACLEntry, ACL with
Grant/Revoke/Check/List; SpiffeNamespace extraction; deny-by-default.
internal/cli/acl.go: orca acl grant/revoke/list/check CLI;
state at cluster/acl.json. Tests: grant/revoke/deny/ns-isolation/concurrent.

---ci---
project: orca
phase: 02
milestone: v0.11
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-07 04:38:00 +00:00
parent 734c9fa0fa
commit 33c2b4a78b
5 changed files with 1142 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
// Package cli: acl.go implements the `orca acl` subcommand family
// (P02, v0.11). Subcommands:
//
// orca acl grant <identity> --namespace <ns> --permissions <perms>
// orca acl revoke <identity> --namespace <ns>
// orca acl list
// orca acl check <identity> --namespace <ns> --permission <perm>
//
// ACL state is stored at paths.ClusterDir()/acl.json (a simple JSON
// file — no DB needed for v0.11). <identity> is either a SPIFFE URI
// (spiffe://orca.local/ns/.../sa/.../...) or a bare token ID.
package cli
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
var (
aclGrantNamespace string
aclGrantPermissions string
aclRevokeNamespace string
aclCheckNamespace string
aclCheckPermission string
)
var aclCmd = &cobra.Command{
Use: "acl",
Short: "Manage access-control entries (SPIFFE + token identities)",
Long: `Manage the cluster ACL (P02, v0.11). Identities are either
SPIFFE workload URIs (spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc>) or
operator token IDs. Permissions are deny-by-default: an identity with
no matching entry on a namespace has no access.
State is stored at ` + "`" + `ClusterDir()/acl.json` + "`" + `.`,
}
// parseIdentity classifies <identity> as a SPIFFE or token identity.
// A SPIFFE identity is detected by the spiffe:// scheme; its namespace
// is extracted from the URI path. Anything else is treated as a token
// ID whose namespace must be supplied via the --namespace flag.
func parseIdentity(raw string) (acl.Identity, error) {
if strings.HasPrefix(raw, "spiffe://") {
ns, err := acl.SpiffeNamespace(raw)
if err != nil {
return acl.Identity{}, fmt.Errorf("parse spiffe identity: %w", err)
}
return acl.Identity{Kind: acl.KindSpiffe, ID: raw, Namespace: ns}, nil
}
if raw == "" {
return acl.Identity{}, fmt.Errorf("identity is empty")
}
return acl.Identity{Kind: acl.KindToken, ID: raw}, nil
}
// parsePermissions parses a comma-separated list of "read","write",
// "admin" into a Permission bitmask. Empty string defaults to read.
func parsePermissions(s string) (acl.Permission, error) {
s = strings.TrimSpace(s)
if s == "" {
return acl.PermRead, nil
}
var perms acl.Permission
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(strings.ToLower(part))
switch part {
case "read":
perms |= acl.PermRead
case "write":
perms |= acl.PermWrite
case "admin":
perms |= acl.PermAdmin
default:
return 0, fmt.Errorf("unknown permission %q (want read, write, or admin)", part)
}
}
if perms == 0 {
return 0, fmt.Errorf("no permissions in %q", s)
}
return perms, nil
}
// permName renders a Permission bitmask as a comma-separated string.
func permName(p acl.Permission) string {
var parts []string
if p&acl.PermRead != 0 {
parts = append(parts, "read")
}
if p&acl.PermWrite != 0 {
parts = append(parts, "write")
}
if p&acl.PermAdmin != 0 {
parts = append(parts, "admin")
}
if len(parts) == 0 {
return "none"
}
return strings.Join(parts, ",")
}
// aclState is the on-disk JSON shape for acl.json.
type aclState struct {
Entries []acl.ACLEntry `json:"entries"`
}
// loadACL reads paths.ACLPath() and returns an *acl.ACL. A missing
// file is treated as an empty ACL (not an error).
func loadACL() (*acl.ACL, error) {
a := acl.NewACL()
path := paths.ACLPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return a, nil
}
return nil, fmt.Errorf("read acl state: %w", err)
}
if len(data) == 0 {
return a, nil
}
var st aclState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parse acl state: %w", err)
}
for _, e := range st.Entries {
a.Grant(e.Identity, e.Namespace, e.Permissions)
}
return a, nil
}
// saveACL writes the ACL to paths.ACLPath() atomically (write to temp,
// rename). The cluster dir is created if missing.
func saveACL(a *acl.ACL) error {
path := paths.ACLPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create cluster dir: %w", err)
}
st := aclState{Entries: a.List()}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("marshal acl state: %w", err)
}
if err := writeAtomicFile(path, data, 0o644); err != nil {
return fmt.Errorf("write acl state: %w", err)
}
return nil
}
// writeAtomicFile writes data to a temp file in dir(path) and renames
// it into place, matching the security.WriteAtomic pattern (P02 keeps
// a local copy to avoid importing internal/security into the CLI).
func writeAtomicFile(path string, data []byte, mode os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".acl-tmp-*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp: %w", err)
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp: %w", err)
}
return nil
}
var aclGrantCmd = &cobra.Command{
Use: "grant <identity>",
Short: "Grant permissions to an identity on a namespace",
Long: `Grant permissions to an identity on a namespace. The identity
is either a SPIFFE URI (its namespace is extracted from the path and
must match --namespace) or a bare token ID (whose namespace is
--namespace). --permissions is a comma-separated list of read,write,
admin (default: read).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
identity, err := parseIdentity(args[0])
if err != nil {
return err
}
ns := aclGrantNamespace
if ns == "" {
ns = identity.Namespace
}
if ns == "" {
return fmt.Errorf("--namespace is required for token identities (or set it to match the spiffe path)")
}
if identity.Kind == acl.KindSpiffe && identity.Namespace != "" && identity.Namespace != ns {
return fmt.Errorf("spiffe namespace %q does not match --namespace %q", identity.Namespace, ns)
}
perms, err := parsePermissions(aclGrantPermissions)
if err != nil {
return err
}
a, err := loadACL()
if err != nil {
return err
}
identity.Namespace = ns
a.Grant(identity, ns, perms)
if err := saveACL(a); err != nil {
return err
}
slog.Info("acl grant", "identity", identity.ID, "namespace", ns, "permissions", permName(perms))
if jsonOutput {
return printJSON(map[string]any{
"identity": identity,
"namespace": ns,
"permissions": permName(perms),
"granted": true,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Granted %s on %s to %s\n", permName(perms), ns, identity.ID)
return nil
},
}
var aclRevokeCmd = &cobra.Command{
Use: "revoke <identity>",
Short: "Revoke an identity's access on a namespace",
Long: `Revoke an identity's entry on a namespace. For a SPIFFE
identity the namespace defaults to the one in the URI path; for a
token identity --namespace is required.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
identity, err := parseIdentity(args[0])
if err != nil {
return err
}
ns := aclRevokeNamespace
if ns == "" {
ns = identity.Namespace
}
if ns == "" {
return fmt.Errorf("--namespace is required for token identities")
}
a, err := loadACL()
if err != nil {
return err
}
identity.Namespace = ns
a.Revoke(identity, ns)
if err := saveACL(a); err != nil {
return err
}
slog.Info("acl revoke", "identity", identity.ID, "namespace", ns)
if jsonOutput {
return printJSON(map[string]any{
"identity": identity,
"namespace": ns,
"revoked": true,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Revoked %s on %s\n", identity.ID, ns)
return nil
},
}
var aclListCmd = &cobra.Command{
Use: "list",
Short: "List all ACL entries",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := loadACL()
if err != nil {
return err
}
entries := a.List()
if jsonOutput {
return printJSON(entries)
}
out := cmd.OutOrStdout()
if len(entries) == 0 {
fmt.Fprintln(out, "No ACL entries. Use `orca acl grant` to add one.")
return nil
}
fmt.Fprintf(out, "%-12s %-50s %-16s %s\n", "KIND", "IDENTITY", "NAMESPACE", "PERMISSIONS")
for _, e := range entries {
fmt.Fprintf(out, "%-12s %-50s %-16s %s\n", e.Identity.Kind, e.Identity.ID, e.Namespace, permName(e.Permissions))
}
return nil
},
}
var aclCheckCmd = &cobra.Command{
Use: "check <identity>",
Short: "Check whether an identity has a permission on a namespace",
Long: `Check whether an identity has the given permission on the
namespace. Exits 0 if allowed, 1 if denied. --permission is one of
read, write, admin (default: read).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
identity, err := parseIdentity(args[0])
if err != nil {
return err
}
ns := aclCheckNamespace
if ns == "" {
ns = identity.Namespace
}
if ns == "" {
return fmt.Errorf("--namespace is required for token identities")
}
permStr := strings.TrimSpace(aclCheckPermission)
if permStr == "" {
permStr = "read"
}
perm, err := parsePermissions(permStr)
if err != nil {
return err
}
a, err := loadACL()
if err != nil {
return err
}
identity.Namespace = ns
allowed := a.Check(identity, ns, perm)
if jsonOutput {
return printJSON(map[string]any{
"identity": identity,
"namespace": ns,
"permission": permStr,
"allowed": allowed,
})
}
if allowed {
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s has %s on %s\n", identity.ID, permStr, ns)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "✗ %s does NOT have %s on %s\n", identity.ID, permStr, ns)
return fmt.Errorf("denied")
},
}
func init() {
aclGrantCmd.Flags().StringVar(&aclGrantNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
aclGrantCmd.Flags().StringVar(&aclGrantPermissions, "permissions", "read", "comma-separated permissions: read,write,admin")
aclRevokeCmd.Flags().StringVar(&aclRevokeNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
aclCheckCmd.Flags().StringVar(&aclCheckNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
aclCheckCmd.Flags().StringVar(&aclCheckPermission, "permission", "read", "permission to check: read, write, or admin")
aclCmd.AddCommand(aclGrantCmd)
aclCmd.AddCommand(aclRevokeCmd)
aclCmd.AddCommand(aclListCmd)
aclCmd.AddCommand(aclCheckCmd)
rootCmd.AddCommand(aclCmd)
}
+386
View File
@@ -0,0 +1,386 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
func resetACLFlags() {
aclGrantNamespace = ""
aclGrantPermissions = "read"
aclRevokeNamespace = ""
aclCheckNamespace = ""
aclCheckPermission = "read"
}
func TestACLCommandRegistered(t *testing.T) {
registered := make(map[string]bool)
for _, cmd := range rootCmd.Commands() {
registered[cmd.Name()] = true
}
if !registered["acl"] {
t.Fatal("acl command not registered on root")
}
}
func TestACLSubcommands(t *testing.T) {
expected := []string{"grant", "revoke", "list", "check"}
registered := make(map[string]bool)
for _, cmd := range aclCmd.Commands() {
registered[cmd.Name()] = true
}
for _, name := range expected {
if !registered[name] {
t.Errorf("expected acl subcommand %q not registered", name)
}
}
}
func TestParseIdentity_Spiffe(t *testing.T) {
id, err := parseIdentity("spiffe://orca.local/ns/myapp/sa/svc1/alloc-1")
if err != nil {
t.Fatalf("parseIdentity: %v", err)
}
if id.Kind != "spiffe" {
t.Errorf("kind = %q, want spiffe", id.Kind)
}
if id.Namespace != "myapp" {
t.Errorf("namespace = %q, want myapp", id.Namespace)
}
}
func TestParseIdentity_Token(t *testing.T) {
id, err := parseIdentity("operator-1")
if err != nil {
t.Fatalf("parseIdentity: %v", err)
}
if id.Kind != "token" {
t.Errorf("kind = %q, want token", id.Kind)
}
if id.ID != "operator-1" {
t.Errorf("id = %q, want operator-1", id.ID)
}
if id.Namespace != "" {
t.Errorf("namespace = %q, want empty (set via --namespace)", id.Namespace)
}
}
func TestParseIdentity_Empty(t *testing.T) {
if _, err := parseIdentity(""); err == nil {
t.Errorf("parseIdentity(\"\"): expected error, got nil")
}
}
func TestParsePermissions(t *testing.T) {
cases := []struct {
in string
want uint8
}{
{"", 1},
{"read", 1},
{"write", 2},
{"admin", 4},
{"read,write", 3},
{"read,write,admin", 7},
{"READ,Write", 3},
}
for _, c := range cases {
got, err := parsePermissions(c.in)
if err != nil {
t.Errorf("parsePermissions(%q): unexpected err %v", c.in, err)
continue
}
if uint8(got) != c.want {
t.Errorf("parsePermissions(%q) = %d, want %d", c.in, uint8(got), c.want)
}
}
}
func TestParsePermissions_Unknown(t *testing.T) {
if _, err := parsePermissions("read,delete"); err == nil {
t.Errorf("parsePermissions(read,delete): expected error, got nil")
}
}
func TestACLGrantAndCheck(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read,write"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("acl grant: %v", err)
}
if _, err := os.Stat(paths.ACLPath()); err != nil {
t.Fatalf("acl.json not written: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("acl check read: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "admin"})
err := rootCmd.Execute()
if err == nil {
t.Fatalf("acl check admin: expected denied error, got nil")
}
}
func TestACLCheckDeniedExits1(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "ghost", "--namespace", "prod", "--permission", "read"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected denied error for un-granted identity, got nil")
}
}
func TestACLRevoke(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "revoke", "operator-1", "--namespace", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("revoke: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"})
if err := rootCmd.Execute(); err == nil {
t.Fatalf("check after revoke: expected denied, got nil")
}
}
func TestACLListEmpty(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetArgs([]string{"acl", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("acl list empty: %v", err)
}
if !strings.Contains(buf.String(), "No ACL entries") {
t.Errorf("acl list empty: %s", buf.String())
}
}
func TestACLListWithEntries(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read,write"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "myapp", "--permissions", "admin"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant spiffe: %v", err)
}
resetRootFlags(t)
resetACLFlags()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetArgs([]string{"acl", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("acl list: %v", err)
}
out := buf.String()
if !strings.Contains(out, "operator-1") || !strings.Contains(out, "prod") {
t.Errorf("list missing operator-1/prod: %s", out)
}
if !strings.Contains(out, "spiffe://orca.local/ns/myapp") || !strings.Contains(out, "myapp") {
t.Errorf("list missing spiffe entry: %s", out)
}
if !strings.Contains(out, "read,write") || !strings.Contains(out, "admin") {
t.Errorf("list missing permissions: %s", out)
}
}
func TestACLListJSON(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant: %v", err)
}
resetRootFlags(t)
resetACLFlags()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetArgs([]string{"acl", "list", "--json"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("acl list --json: %v", err)
}
var entries []map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &entries); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
}
if len(entries) != 1 {
t.Fatalf("entries len = %d, want 1", len(entries))
}
id, _ := entries[0]["identity"].(map[string]any)
if id == nil || id["id"] != "operator-1" {
t.Errorf("identity = %v, want operator-1", entries[0]["identity"])
}
}
func TestACLGrantSpiffeNamespaceMismatch(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "other"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected mismatch error, got nil")
}
if !strings.Contains(err.Error(), "does not match") {
t.Errorf("error = %q, want contains 'does not match'", err.Error())
}
}
func TestACLGrantSpiffeDefaultsNamespaceFromPath(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--permissions", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant spiffe (no --namespace): %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "myapp", "--permission", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("check spiffe: %v", err)
}
}
func TestACLGrantTokenRequiresNamespace(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--permissions", "read"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for token grant without --namespace, got nil")
}
}
func TestACLStatePersists(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant: %v", err)
}
data, err := os.ReadFile(paths.ACLPath())
if err != nil {
t.Fatalf("read acl.json: %v", err)
}
if !strings.Contains(string(data), "operator-1") || !strings.Contains(string(data), "prod") {
t.Errorf("acl.json missing entry: %s", string(data))
}
}
func TestACLAtomicWriteNoPartialFile(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant: %v", err)
}
entries, err := os.ReadDir(filepath.Dir(paths.ACLPath()))
if err != nil {
t.Fatalf("readdir cluster: %v", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".acl-tmp-") {
t.Errorf("leftover temp file: %s", e.Name())
}
}
}
func TestACLCheckJSONDenied(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetArgs([]string{"acl", "check", "ghost", "--namespace", "prod", "--permission", "read", "--json"})
_ = rootCmd.Execute()
var result map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
}
if result["allowed"] != false {
t.Errorf("allowed = %v, want false", result["allowed"])
}
}
func TestACLAdminImpliesReadCheck(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "admin"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("grant admin: %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("check read (admin grant): %v", err)
}
resetRootFlags(t)
resetACLFlags()
rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "write"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("check write (admin grant): %v", err)
}
}