test: control-plane auth middleware + end-to-end enforce
membership/auth_test: golden (signed+registered accepted), error paths (unregistered 401, replayed nonce 401, clock skew 401, tampered body 401, missing headers 401), exemptions (healthz, soft allows, off no-op). client_test: end-to-end with the real client against an enforce server — registered peer accepted, unregistered rejected, revoked peer denied without a server restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
package client_test
|
package client_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +29,7 @@ type testHarness struct {
|
|||||||
ctrlURL string
|
ctrlURL string
|
||||||
ns *server.Server
|
ns *server.Server
|
||||||
httpts *httptest.Server
|
httpts *httptest.Server
|
||||||
|
store *membership.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func freePort(t *testing.T) int {
|
func freePort(t *testing.T) int {
|
||||||
@@ -39,7 +42,12 @@ func freePort(t *testing.T) int {
|
|||||||
return l.Addr().(*net.TCPAddr).Port
|
return l.Addr().(*net.TCPAddr).Port
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHarness(t *testing.T) *testHarness {
|
func newHarness(t *testing.T) *testHarness { return newHarnessMode(t, membership.AuthOff) }
|
||||||
|
|
||||||
|
// newHarnessMode is newHarness with an explicit control-plane auth mode, so auth
|
||||||
|
// tests can boot the real server in enforce/soft and exercise it through the
|
||||||
|
// production client (which signs every request).
|
||||||
|
func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -58,10 +66,10 @@ func newHarness(t *testing.T) *testHarness {
|
|||||||
ns.Shutdown()
|
ns.Shutdown()
|
||||||
t.Fatalf("blob store: %v", err)
|
t.Fatalf("blob store: %v", err)
|
||||||
}
|
}
|
||||||
srv := membership.NewServer(store, blobs)
|
srv := membership.NewServer(store, blobs, mode)
|
||||||
httpts := httptest.NewServer(srv)
|
httpts := httptest.NewServer(srv)
|
||||||
|
|
||||||
h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts}
|
h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
httpts.Close()
|
httpts.Close()
|
||||||
store.Close()
|
store.Close()
|
||||||
@@ -71,6 +79,15 @@ func newHarness(t *testing.T) *testHarness {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerClient adds a peer's signing identity to the bus allowlist so its
|
||||||
|
// signed control-plane requests pass under enforce.
|
||||||
|
func registerClient(t *testing.T, h *testHarness, c *client.Client, handle, role string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := h.store.AddUser(hex.EncodeToString(c.Endpoint().SignPub), handle, role); err != nil {
|
||||||
|
t.Fatalf("register %s: %v", handle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitHealth(t *testing.T, ctrlURL string) {
|
func waitHealth(t *testing.T, ctrlURL string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
@@ -455,6 +472,50 @@ func TestListMyRoomsDiscovery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestControlPlaneAuthEnforceE2E closes the loop end to end with the production
|
||||||
|
// client against a server in enforce mode: a registered peer's signed requests
|
||||||
|
// are accepted (golden), and an unregistered peer is rejected with 401 on its
|
||||||
|
// first control-plane call (error path). This proves the client's real
|
||||||
|
// signature construction matches the server's verification.
|
||||||
|
func TestControlPlaneAuthEnforceE2E(t *testing.T) {
|
||||||
|
h := newHarnessMode(t, membership.AuthEnforce)
|
||||||
|
waitHealth(t, h.ctrlURL)
|
||||||
|
|
||||||
|
a, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect A: %v", err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
registerClient(t, h, a, "alice", membership.RoleAdmin)
|
||||||
|
|
||||||
|
// Golden: registered peer's signed request is accepted.
|
||||||
|
if _, err := a.CreateRoom("room.enforced", room.ModeNATS); err != nil {
|
||||||
|
t.Fatalf("registered peer should create a room under enforce: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: an unregistered peer is rejected on its first control-plane call.
|
||||||
|
b, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect B: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
_, err = b.CreateRoom("room.denied", room.ModeNATS)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("unregistered peer must be rejected under enforce")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "401") && !strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||||
|
t.Fatalf("expected a 401/unauthorized error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revocation takes effect without restart: revoke A, its next request fails.
|
||||||
|
if err := h.store.RevokeUser(hex.EncodeToString(a.Endpoint().SignPub)); err != nil {
|
||||||
|
t.Fatalf("revoke A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.CreateRoom("room.after-revoke", room.ModeNATS); err == nil {
|
||||||
|
t.Fatalf("revoked peer must be rejected without a server restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- test helpers ---------------------------------------------------------
|
// ---- test helpers ---------------------------------------------------------
|
||||||
|
|
||||||
type collector struct {
|
type collector struct {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package membership
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authHarness boots an in-process membershipd HTTP server in the given auth mode
|
||||||
|
// with a fresh store + blob store, and seeds one active admin ("alice").
|
||||||
|
type authHarness struct {
|
||||||
|
ts *httptest.Server
|
||||||
|
store *Store
|
||||||
|
alice cs.Identity
|
||||||
|
alicePub string // hex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthHarness(t *testing.T, mode AuthMode) *authHarness {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := Open(filepath.Join(dir, "unibus.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open blobs: %v", err)
|
||||||
|
}
|
||||||
|
alice, err := cs.GenerateIdentity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identity: %v", err)
|
||||||
|
}
|
||||||
|
alicePub := hex.EncodeToString(alice.SignPub)
|
||||||
|
if err := store.AddUser(alicePub, "alice", RoleAdmin); err != nil {
|
||||||
|
t.Fatalf("seed admin: %v", err)
|
||||||
|
}
|
||||||
|
srv := NewServer(store, blobs, mode)
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ts.Close()
|
||||||
|
store.Close()
|
||||||
|
})
|
||||||
|
return &authHarness{ts: ts, store: store, alice: alice, alicePub: alicePub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedReq builds a control-plane request signed by id, with explicit ts/nonce
|
||||||
|
// so tests can force skew and replay. It signs via the same CanonicalRequest the
|
||||||
|
// production client uses, so the test verifies the real wire contract.
|
||||||
|
func signedReq(t *testing.T, base, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
rdr = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, base+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
tss := strconv.FormatInt(ts, 10)
|
||||||
|
canonical := CanonicalRequest(method, path, tss, nonce, body)
|
||||||
|
sig := cs.SignEd25519(id.SignPriv, canonical)
|
||||||
|
req.Header.Set(hdrPub, hex.EncodeToString(id.SignPub))
|
||||||
|
req.Header.Set(hdrTs, tss)
|
||||||
|
req.Header.Set(hdrNonce, nonce)
|
||||||
|
req.Header.Set(hdrSig, base64.StdEncoding.EncodeToString(sig))
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, req *http.Request) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const okPath = "/members/alice-endpoint/rooms" // always 200 with an empty list
|
||||||
|
|
||||||
|
// Golden: a request signed by a registered, active identity is accepted.
|
||||||
|
func TestAuthGoldenAccepted(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
code, _ := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-golden"))
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("golden signed request should be 200, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: a structurally valid signature from an identity that is NOT in the
|
||||||
|
// allowlist is rejected with 401.
|
||||||
|
func TestAuthUnregisteredRejected(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
bob, _ := cs.GenerateIdentity()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, bob, now, "nonce-bob"))
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unregistered identity should be 401, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: replaying a captured request (same nonce + signature) is rejected.
|
||||||
|
func TestAuthReplayRejected(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
first := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay")
|
||||||
|
if code, body := do(t, first); code != http.StatusOK {
|
||||||
|
t.Fatalf("first request should be 200, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
// Identical ts + nonce + signature: a replay.
|
||||||
|
second := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay")
|
||||||
|
if code, body := do(t, second); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("replayed request should be 401, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: a timestamp outside the ±30s window is rejected even with a valid
|
||||||
|
// signature (defends against long-delayed captured requests).
|
||||||
|
func TestAuthClockSkewRejected(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
stale := time.Now().Unix() - 120
|
||||||
|
code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, stale, "nonce-skew"))
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("clock-skewed request should be 401, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: tampering the body after signing invalidates the signature.
|
||||||
|
func TestAuthTamperedBodyRejected(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
req := signedReq(t, h.ts.URL, "POST", "/rooms", []byte(`{"subject":"x"}`), h.alice, now, "nonce-tamper")
|
||||||
|
// Swap the body for different bytes the signature does not cover.
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader([]byte(`{"subject":"evil"}`)))
|
||||||
|
req.ContentLength = int64(len(`{"subject":"evil"}`))
|
||||||
|
code, body := do(t, req)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("tampered body should be 401, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error path: missing auth headers under enforce are rejected.
|
||||||
|
func TestAuthMissingHeadersRejected(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
||||||
|
code, _ := do(t, req)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unsigned request under enforce should be 401, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exemption: the health probe bypasses auth even under enforce.
|
||||||
|
func TestAuthHealthExempt(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
req, _ := http.NewRequest("GET", h.ts.URL+"/healthz", nil)
|
||||||
|
code, _ := do(t, req)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("/healthz must be reachable without auth, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft mode: an unauthenticated request is logged but allowed through, so
|
||||||
|
// clients can migrate without an outage.
|
||||||
|
func TestAuthSoftAllowsUnauthenticated(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthSoft)
|
||||||
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
||||||
|
code, _ := do(t, req)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("soft mode should allow unsigned request, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off mode (default for legacy callers): no verification at all.
|
||||||
|
func TestAuthOffNoVerification(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthOff)
|
||||||
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
||||||
|
code, _ := do(t, req)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("off mode should allow unsigned request, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user