33c2b4a78b
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---
387 lines
10 KiB
Go
387 lines
10 KiB
Go
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)
|
|
}
|
|
}
|