5232fcb808
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---
460 lines
13 KiB
Go
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)
|
|
}
|
|
}
|