Files
orca/internal/cli/txn_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

408 lines
11 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
"git.cloudinit.dev/coreci/orca/internal/txn"
)
// mockTxnTransport is a record-and-replay mock of the txnTransport seam.
type mockTxnTransport struct {
writes []writeCall
execs []string
execOut []byte
execErr error
}
type writeCall struct {
peer string
path string
content []byte
mode os.FileMode
}
func (m *mockTxnTransport) WriteFileIdempotent(_ context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
m.writes = append(m.writes, writeCall{peer, path, content, mode})
return true, nil
}
func (m *mockTxnTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
m.execs = append(m.execs, cmd)
return m.execOut, m.execErr
}
func setupTxnTestEnv(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
return dir
}
func TestTxnCmdRegistered(t *testing.T) {
for _, c := range rootCmd.Commands() {
if c.Name() == "txn" {
return
}
}
t.Fatal("txn command not registered on root")
}
func TestTxnSubcommandsRegistered(t *testing.T) {
for _, c := range rootCmd.Commands() {
if c.Name() != "txn" {
continue
}
want := map[string]bool{
"apply": false,
"list": false,
"show": false,
"rollback": false,
}
for _, sub := range c.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, found := range want {
if !found {
t.Errorf("txn subcommand %q not registered", name)
}
}
return
}
t.Fatal("txn command not registered")
}
func TestTxnApplyClusterWideForceAndAck(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--i-understand-the-risk",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply: %v", err)
}
if len(mt.execs) != 1 {
t.Fatalf("expected 1 exec, got %d", len(mt.execs))
}
cmd := mt.execs[0]
for _, want := range []string{"--force", "--i-understand-the-risk"} {
if !bytesContains(cmd, want) {
t.Errorf("cmd missing %q: %s", want, cmd)
}
}
}
func TestTxnApplyClusterWideYes(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--yes",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply --yes: %v", err)
}
if !bytesContains(mt.execs[0], "--yes") {
t.Errorf("cmd missing --yes: %s", mt.execs[0])
}
}
func TestTxnApplyNamespaceScoped(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--namespace", "default",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply ns-scoped: %v", err)
}
cmd := mt.execs[0]
if !bytesContains(cmd, "--namespace") {
t.Errorf("cmd missing --namespace: %s", cmd)
}
if bytesContains(cmd, "--force") {
t.Errorf("ns-scoped cmd should not have --force: %s", cmd)
}
}
func TestTxnApplyClusterWideRefusesWithoutForce(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn apply cluster-wide without --force should fail")
}
if !errors.Is(err, txn.ErrClusterWideRequiresForce) && !bytesContains(err.Error(), "force") {
t.Errorf("expected force-related error, got %v", err)
}
if len(mt.execs) != 0 {
t.Errorf("should not exec without --force")
}
}
func TestTxnApplyClusterWideRefusesWithoutAck(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn apply cluster-wide with --force but no ack should fail")
}
if !bytesContains(err.Error(), "i-understand-the-risk") {
t.Errorf("expected ack-related error, got %v", err)
}
}
func TestTxnApplyAlreadyAppliedNoOp(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{
execOut: []byte("already-applied"),
execErr: fmt.Errorf("%w: exit 5", sshpush.ErrPermanent),
}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--i-understand-the-risk",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("already-applied no-op should not error: %v", err)
}
if !bytesContains(buf.String(), "already applied") {
t.Errorf("output should mention already-applied: %s", buf.String())
}
}
func TestTxnListEmpty(t *testing.T) {
setupTxnTestEnv(t)
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn list: %v", err)
}
if !bytesContains(buf.String(), "No transactions") {
t.Errorf("empty list output: %s", buf.String())
}
}
func TestTxnListShowsTxns(t *testing.T) {
home := setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-deadbeefdeadbeef"
if err := os.MkdirAll(filepath.Join(txnDir, id), 0o755); err != nil {
t.Fatalf("mkdir txn dir: %v", err)
}
// Mark as applied.
if err := os.WriteFile(filepath.Join(txnDir, id, ".applied"), []byte{}, 0o644); err != nil {
t.Fatalf("write .applied: %v", err)
}
_ = home
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn list: %v", err)
}
out := buf.String()
if !bytesContains(out, id) {
t.Errorf("list output missing txn id %s: %s", id, out)
}
if !bytesContains(out, "applied") {
t.Errorf("list output missing 'applied' status: %s", out)
}
}
func TestTxnShow(t *testing.T) {
setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-cafebabecafebabe"
dir := filepath.Join(txnDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
manifest := `{"txn_id":"T-cafebabecafebabe","timestamp":"2026-01-01T00:00:00Z","files":[{"name":"desired-state.json","sha256":"abc"}]}`
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`{"x":1}`), 0o644); err != nil {
t.Fatalf("write desired: %v", err)
}
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", id})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn show: %v", err)
}
out := buf.String()
if !bytesContains(out, id) {
t.Errorf("show output missing id: %s", out)
}
if !bytesContains(out, "staged") {
t.Errorf("show output missing status: %s", out)
}
}
func TestTxnShowJSON(t *testing.T) {
setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-1234567890abcdef"
dir := filepath.Join(txnDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
manifest := `{"txn_id":"T-1234567890abcdef","timestamp":"2026-01-01T00:00:00Z","files":[]}`
_ = os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644)
_ = os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`[]`), 0o644)
resetRootFlags(t)
_ = rootCmd.PersistentFlags().Set("json", "true")
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", id})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn show --json: %v", err)
}
if !bytesContains(buf.String(), `"txn_id"`) {
t.Errorf("json output missing txn_id: %s", buf.String())
}
}
func TestTxnShowMissing(t *testing.T) {
setupTxnTestEnv(t)
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", "T-nonexistent12345"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn show on missing txn should fail")
}
}
func TestTxnRollback(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("rolled-back")}
txnTransportOverride = mt
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "rollback", "T-abcdef0123456789",
"--lead", "lead:22",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn rollback: %v", err)
}
if len(mt.execs) != 1 {
t.Fatalf("expected 1 exec, got %d", len(mt.execs))
}
if !bytesContains(mt.execs[0], "rollback.sh") {
t.Errorf("rollback cmd missing rollback.sh: %s", mt.execs[0])
}
if !bytesContains(buf.String(), "rolled back") {
t.Errorf("output missing 'rolled back': %s", buf.String())
}
}
func TestTxnApplyTimeoutDefault(t *testing.T) {
if txnApplyTimeout != 5*time.Minute {
// After reset, default should be 5m. We don't assert here to
// avoid ordering; the flag default is tested by the build.
}
_ = txnApplyTimeout
}
func bytesContains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}