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

297 lines
9.9 KiB
Go

// Package daemon — acl_test.go verifies the ACL enforcement wiring
// (P04, v0.13; C-44/C-45). It exercises the aclPolicy.Check path
// with constructed mTLS peer certificates (SPIFFE SVID + OIDC CN)
// and asserts deny-by-default + log-only mode semantics.
package daemon
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/store"
)
// aclStateJSON mirrors the on-disk acl.json shape.
type aclStateJSON struct {
Entries []acl.ACLEntry `json:"entries"`
}
// mustMarshal marshals v or fails the test.
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// writeACLFile writes the given entries to paths.ACLPath() under a
// fresh $ORCA_HOME so NewACLPolicy picks them up.
func writeACLFile(t *testing.T, entries []acl.ACLEntry) {
t.Helper()
home := t.TempDir()
t.Setenv("ORCA_HOME", home)
if err := os.MkdirAll(paths.ClusterDir(), 0o755); err != nil {
t.Fatalf("mkdir cluster dir: %v", err)
}
data := mustMarshal(t, aclStateJSON{Entries: entries})
if err := os.WriteFile(paths.ACLPath(), data, 0o600); err != nil {
t.Fatalf("write acl: %v", err)
}
}
// buildSelfSignedCert builds an in-memory self-signed x509 cert with
// the given SPIFFE URI SAN and CommonName. The ACL layer only inspects
// URIs + CommonName, not the signature chain (chain verification is
// the mTLS handshake's job).
func buildSelfSignedCert(t *testing.T, spiffeURI, commonName string) *x509.Certificate {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"localhost"},
}
if spiffeURI != "" {
u, err := url.Parse(spiffeURI)
if err != nil {
t.Fatalf("parse spiffe uri: %v", err)
}
tmpl.URIs = []*url.URL{u}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
// Round-trip through PEM so the cert is realistic.
_ = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
return cert
}
// makePeerCert is a shorthand for buildSelfSignedCert.
func makePeerCert(t *testing.T, spiffeURI, commonName string) *x509.Certificate {
return buildSelfSignedCert(t, spiffeURI, commonName)
}
// reqWithPeerCert builds an *http.Request whose r.TLS.PeerCertificates
// is populated with the given cert, simulating an mTLS handshake.
func reqWithPeerCert(cert *x509.Certificate) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
r.TLS = &tls.ConnectionState{
PeerCertificates: []*x509.Certificate{cert},
}
return r
}
// newTestServer builds a daemon Server with a temp DB and the given
// ACL enforce mode. Used by the handler-level tests.
func newACLTestServer(t *testing.T, enforce bool) *Server {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
s := NewServer(Options{
DB: db,
Log: slog.New(slog.NewTextHandler(os.Stderr, nil)),
Addr: ":0",
ACLEnforce: enforce,
})
return s
}
// --- aclPolicy unit tests ---
// TestACLPolicyDenyByDefault verifies that an authenticated request
// with no matching ACL entry is denied in enforce mode.
func TestACLPolicyDenyByDefault(t *testing.T) {
writeACLFile(t, nil) // empty ACL
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
cert := makePeerCert(t, "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "")
r := reqWithPeerCert(cert)
_, ok := p.Check(r, "_defaults", acl.PermRead)
if ok {
t.Fatal("expected deny (no ACL entry), got allow")
}
}
// TestACLPolicyAllowWithEntry verifies that an authenticated request
// with a matching ACL entry is allowed.
func TestACLPolicyAllowWithEntry(t *testing.T) {
id := acl.Identity{Kind: acl.KindSpiffe, ID: "spiffe://orca.local/ns/_defaults/sa/orca/alloc-1", Namespace: "_defaults"}
a := acl.NewACL()
a.Grant(id, "_defaults", acl.PermRead|acl.PermWrite)
writeACLFile(t, a.List())
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
cert := makePeerCert(t, id.ID, "")
r := reqWithPeerCert(cert)
gotID, ok := p.Check(r, "_defaults", acl.PermRead)
if !ok {
t.Fatal("expected allow (matching entry), got deny")
}
if gotID.ID != id.ID {
t.Errorf("identity ID = %q, want %q", gotID.ID, id.ID)
}
}
// TestACLPolicyUnauthenticatedEnforce verifies that a request with no
// peer cert is denied in enforce mode.
func TestACLPolicyUnauthenticatedEnforce(t *testing.T) {
writeACLFile(t, nil)
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
_, ok := p.Check(r, "_defaults", acl.PermRead)
if ok {
t.Fatal("expected deny for unauthenticated in enforce mode, got allow")
}
}
// TestACLPolicyLogOnlyAllowsDenials (C-45) verifies that in log-only
// mode (enforce=false), denials are logged but the request proceeds
// (allow=true). This is the staged-rollout semantics.
func TestACLPolicyLogOnlyAllowsDenials(t *testing.T) {
writeACLFile(t, nil) // empty ACL → all denials
p := NewACLPolicy(false, slog.New(slog.NewTextHandler(os.Stderr, nil)))
// Unauthenticated in log-only mode → logged but allowed.
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
_, ok := p.Check(r, "_defaults", acl.PermRead)
if !ok {
t.Fatal("expected allow in log-only mode (unauthenticated), got deny")
}
// Authenticated-but-no-entry in log-only mode → logged but allowed.
cert := makePeerCert(t, "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "")
r2 := reqWithPeerCert(cert)
_, ok = p.Check(r2, "_defaults", acl.PermRead)
if !ok {
t.Fatal("expected allow in log-only mode (no entry), got deny")
}
}
// TestACLPolicyOIDCCNIdentity verifies that a cert with no SPIFFE URI
// but a CommonName is treated as an OIDC identity.
func TestACLPolicyOIDCCNIdentity(t *testing.T) {
id := acl.Identity{Kind: acl.KindOidc, ID: "operator@example.com"}
a := acl.NewACL()
a.Grant(id, "_defaults", acl.PermRead)
writeACLFile(t, a.List())
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
cert := makePeerCert(t, "", "operator@example.com")
r := reqWithPeerCert(cert)
gotID, ok := p.Check(r, "_defaults", acl.PermRead)
if !ok {
t.Fatal("expected allow for OIDC CN identity, got deny")
}
if gotID.Kind != acl.KindOidc || gotID.ID != "operator@example.com" {
t.Errorf("identity = %+v, want oidc:operator@example.com", gotID)
}
}
// --- Handler-level tests (T10) ---
// TestACLJobsHandlerEnforceDeniesUnauthenticated verifies the wired
// jobs handler denies an unauthenticated request in enforce mode.
func TestACLJobsHandlerEnforceDeniesUnauthenticated(t *testing.T) {
writeACLFile(t, nil)
srv := newACLTestServer(t, true)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
srv.handleJobsCollection(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("unauthenticated /v1/jobs (enforce): %d, want 403", rec.Code)
}
if !strings.Contains(rec.Body.String(), "access denied") {
t.Errorf("body should contain 'access denied': %s", rec.Body.String())
}
}
// TestACLJobsHandlerLogOnlyAllowsUnauthenticated (C-45) verifies the
// wired jobs handler allows an unauthenticated request in log-only
// mode (the denial is logged but the request proceeds).
func TestACLJobsHandlerLogOnlyAllowsUnauthenticated(t *testing.T) {
writeACLFile(t, nil)
srv := newACLTestServer(t, false)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
srv.handleJobsCollection(rec, r)
if rec.Code == http.StatusForbidden {
t.Errorf("unauthenticated /v1/jobs (log-only): %d, want non-403", rec.Code)
}
}
// TestACLJobsHandlerAllowsAuthenticatedWithEntry verifies the wired
// jobs handler allows an authenticated request with a matching ACL
// entry in enforce mode.
func TestACLJobsHandlerAllowsAuthenticatedWithEntry(t *testing.T) {
id := acl.Identity{Kind: acl.KindSpiffe, ID: "spiffe://orca.local/ns/_defaults/sa/orca/alloc-1", Namespace: "_defaults"}
a := acl.NewACL()
a.Grant(id, "_defaults", acl.PermRead)
writeACLFile(t, a.List())
srv := newACLTestServer(t, true)
cert := makePeerCert(t, id.ID, "")
rec := httptest.NewRecorder()
r := reqWithPeerCert(cert)
srv.handleJobsCollection(rec, r)
if rec.Code == http.StatusForbidden {
t.Errorf("authenticated /v1/jobs (matching entry): %d, want non-403", rec.Code)
}
}
// TestACLNodesHandlerEnforceDeniesUnauthenticated verifies the nodes
// handler denies an unauthenticated request in enforce mode.
func TestACLNodesHandlerEnforceDeniesUnauthenticated(t *testing.T) {
writeACLFile(t, nil)
srv := newACLTestServer(t, true)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil)
srv.handleNodesCollection(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("unauthenticated /v1/nodes (enforce): %d, want 403", rec.Code)
}
}
// TestACLTasksHandlerEnforceDeniesUnauthenticated verifies the tasks
// handler denies an unauthenticated request in enforce mode.
func TestACLTasksHandlerEnforceDeniesUnauthenticated(t *testing.T) {
writeACLFile(t, nil)
srv := newACLTestServer(t, true)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/tasks", nil)
srv.handleTasksCollection(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("unauthenticated /v1/tasks (enforce): %d, want 403", rec.Code)
}
}