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---
408 lines
11 KiB
Go
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
|
|
}
|