Files
orca/internal/cli/acl_test.go
T
Jon Chery 5232fcb808 fix(P04): wire ACL enforcement + WebAuthn reg auth + audit actor (REQ-153)
R-023: Zero-trust enforcement operationally wired.

ACL enforcement (C-45 staged rollout):
- acl.Check wired into all 5 daemon handlers (dispatch/jobs/nodes/tasks)
- health endpoints exempt (liveness probes not gated)
- ACL log-only mode default (config acl.enforce=false); enforce after
  bootstrap ACL verified
- sshpush auth: ORCA_OIDC_TOKEN validated against JWKS before apply
- txn apply: Authorize hook validates OIDC token before running pull
- acl.json mode 0600 (was 0644)
- flock on acl.json for concurrent grant/revoke
- bootstrap ACL: init grants cluster-admin to orca-admins group + SVID

Audit actor identity:
- currentActor reads OIDC sub from credentials.json (was hardcoded "cli")
- threaded through all audit.Record calls via context

WebAuthn registration auth:
- BeginRegistration/FinishRegistration require authenticated session
- fail-closed 401 when no authFunc configured

New files: internal/daemon/acl.go, internal/cli/authactor.go,
internal/engine/actor.go, internal/identity/authtoken.go,
internal/sshpush/auth.go, internal/txn/auth_test.go

---ci---
project: orca
phase: 4
milestone: v0.13
status: complete
requirements:
  covered: [153]
---/ci---
2026-08-07 20:33:39 +00:00

460 lines
13 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/acl"
"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_Oidc(t *testing.T) {
id, err := parseIdentity("operator-1")
if err != nil {
t.Fatalf("parseIdentity: %v", err)
}
if id.Kind != "oidc" {
t.Errorf("kind = %q, want oidc", 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)
}
}
// TestACLGrantWritesMode0600 (P04, T6) verifies that saveACL writes
// acl.json with mode 0600 (operator-only). Previously 0644 leaked
// SPIFFE IDs + OIDC subs to other local users.
func TestACLGrantWritesMode0600(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)
}
info, err := os.Stat(paths.ACLPath())
if err != nil {
t.Fatalf("stat acl.json: %v", err)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("acl.json mode = %o, want 0600 (no group/other bits)", info.Mode().Perm())
}
}
// TestACLGrantCreatesLockFile (P04, T7) verifies that the flock
// mechanism creates an acl.json.lock file alongside acl.json. The
// lock prevents concurrent grant/revoke races.
func TestACLGrantCreatesLockFile(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)
}
if _, err := os.Stat(paths.ACLPath() + ".lock"); err != nil {
t.Errorf("acl.json.lock not created: %v", err)
}
}
// TestACLBootstrapGrantsAdminGroup (P04, T8, C-40) verifies that
// bootstrapACL grants cluster-admin to the orca-admins OIDC group on
// the default namespace. This prevents operator lockout after
// `orca init`.
func TestACLBootstrapGrantsAdminGroup(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
if err := os.MkdirAll(paths.ClusterDir(), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// bootstrapACL reads the cert at certPath; a missing cert is
// non-fatal (the SVID grant is skipped, the group grant still
// applies). Pass a nonexistent path to exercise that path.
if err := bootstrapACL(filepath.Join(t.TempDir(), "missing.crt")); err != nil {
t.Fatalf("bootstrapACL: %v", err)
}
a, err := loadACL()
if err != nil {
t.Fatalf("loadACL: %v", err)
}
entries := a.List()
found := false
for _, e := range entries {
if e.Identity.Kind == "oidc" && e.Identity.ID == "group:orca-admins" && e.Namespace == paths.DefaultNamespace() {
if e.Permissions != acl.AllPermissions {
t.Errorf("orca-admins permissions = %d, want %d (AllPermissions)", e.Permissions, acl.AllPermissions)
}
found = true
}
}
if !found {
t.Errorf("bootstrapACL did not grant cluster-admin to group:orca-admins; entries: %+v", entries)
}
}