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---
297 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|