Files
multica/server/internal/middleware/daemon_auth_test.go
LinYushen c968c13c87 feat(auth): support mcn_ Cloud Node PATs verified via Fleet (#3349)
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet

Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.

Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:

  * Fleet says valid:false -> 401 (token genuinely bad)
  * Fleet unreachable / 5xx -> 503 (transient, retry)
  * No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)

Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.

Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.

Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.

* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing

Two related changes:

1. Cloud-verified owner_id is now checked against our local users table.
   The Cloud owner_id and our users.id share the same UUID space by
   contract; a missing local user means either the row was deleted
   under an active node or something is forging owner_ids — either
   way, fail closed.

   CloudPATVerifier.Verify takes a new OwnerLookupFunc:
     - returns (true, nil)   -> success, cache + return
     - returns (false, nil)  -> ErrCloudPATInvalid (reason='owner_unknown'),
                                NOT cached (so a freshly-created user
                                doesn't get locked out for a TTL window)
     - returns (_, error)    -> ErrCloudPATUnavailable (transient,
                                middleware emits 503)

   Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
   helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
   UUIDs to (false, nil) and other errors to a real Go error.

2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
   PATs itself during /api/v1/nodes (see multica-cloud
   docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
   so multica-api no longer needs to forward the caller's mul_ PAT.
   Propagating a long-lived user PAT into a remote machine widened
   the blast radius of any node compromise; that's gone now.

   Removed:
     - cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
       generateCloudRuntimePAT, revokeGeneratedPAT
     - cloudruntime/Request.UserPAT field + X-User-PAT header
     - X-User-PAT from CORS allowed headers
     - obsolete handler tests:
         TestCreateCloudRuntimeNodeForwardsValidatedPAT
         TestCreateCloudRuntimeNodeRejectsUnownedPAT
         TestCreateCloudRuntimeNodeRejectsExpiredPAT
         TestCreateCloudRuntimeNodeAutoGeneratesPAT
       replaced with TestCreateCloudRuntimeNodeForwardsBody
     - X-User-PAT references in packages/core/api/client.test.ts

Tests:
  * 3 new verifier-level tests (owner_unknown not cached, lookup error
    -> Unavailable, success path is cached for both fleet AND lookup)
  * 5 new owner_lookup_test.go tests (nil queries, existing user,
    missing user, malformed UUID, DB error)
  * 1 new end-to-end DaemonAuth test (cloud says valid, no local user
    -> 401)
  * Existing X-User-PAT TS assertions removed; full vitest run passes.
  * go test ./... and go vet ./... clean on the server module.
2026-05-27 14:52:03 +08:00

270 lines
9.7 KiB
Go

package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/internal/auth"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestDaemonAuth_DaemonTokenCacheHit pins the daemon-token cache short-circuit:
// when the cache holds an entry for an mdt_ token, DaemonAuth must skip the DB
// lookup. nil queries would otherwise nil-deref on a miss.
func TestDaemonAuth_DaemonTokenCacheHit(t *testing.T) {
rdb := newRedisTestClient(t)
cache := auth.NewDaemonTokenCache(rdb)
if cache == nil {
t.Fatal("expected non-nil cache")
}
const rawToken = "mdt_cache_hit_test_token"
hash := auth.HashToken(rawToken)
cache.Set(context.Background(), hash, auth.DaemonTokenIdentity{
WorkspaceID: "ws-cached",
DaemonID: "daemon-cached",
}, auth.AuthCacheTTL)
var gotWS, gotDaemon, gotPath string
mw := DaemonAuth(nil, nil, cache, nil) // nil queries — only safe on cache hit
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotWS = DaemonWorkspaceIDFromContext(r.Context())
gotDaemon = DaemonIDFromContext(r.Context())
gotPath = DaemonAuthPathFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on cache hit, got %d: %s", w.Code, w.Body.String())
}
if gotWS != "ws-cached" || gotDaemon != "daemon-cached" {
t.Fatalf("expected (ws-cached, daemon-cached), got (%q, %q)", gotWS, gotDaemon)
}
if gotPath != DaemonAuthPathDaemonToken {
t.Fatalf("expected auth path %q, got %q", DaemonAuthPathDaemonToken, gotPath)
}
}
// TestDaemonAuth_PATCacheHit pins the PAT-fallback short-circuit. Production
// daemon traffic today uses mul_ PATs (mdt_ minting isn't wired up yet), so
// this is the cache hit that actually matters for /api/daemon/* DB load.
func TestDaemonAuth_PATCacheHit(t *testing.T) {
rdb := newRedisTestClient(t)
cache := auth.NewPATCache(rdb)
if cache == nil {
t.Fatal("expected non-nil cache")
}
const rawToken = "mul_daemon_pat_cache_hit_test"
hash := auth.HashToken(rawToken)
cache.Set(context.Background(), hash, "cached-user-id", auth.AuthCacheTTL)
var gotUserID, gotPath string
mw := DaemonAuth(nil, cache, nil, nil)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-ID")
gotPath = DaemonAuthPathFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotUserID != "cached-user-id" {
t.Fatalf("expected cached X-User-ID, got %q", gotUserID)
}
if gotPath != DaemonAuthPathPAT {
t.Fatalf("expected auth path %q, got %q", DaemonAuthPathPAT, gotPath)
}
}
func TestDaemonAuth_MissingAuth(t *testing.T) {
mw := DaemonAuth(nil, nil, nil, nil)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestDaemonAuth_InvalidMDT_NilQueries(t *testing.T) {
mw := DaemonAuth(nil, nil, nil, nil) // no caches, no DB
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mdt_unknown")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
// TestDaemonAuth_MCN_NoVerifierConfigured pins the fail-closed
// behaviour when MULTICA_CLOUD_FLEET_URL is empty: an mcn_ token MUST
// be rejected at the prefix branch with 401, not silently fall
// through to the mul_/JWT paths (an mcn_ string would never match a
// valid PAT or JWT, but failing closed makes the contract explicit).
func TestDaemonAuth_MCN_NoVerifierConfigured(t *testing.T) {
mw := DaemonAuth(nil, nil, nil, nil)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called when verifier is unconfigured")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mcn_anything")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with no verifier, got %d", w.Code)
}
}
// TestDaemonAuth_MCN_ValidTokenSetsUserID confirms that on a successful
// Fleet verify, DaemonAuth surfaces owner_id as X-User-ID and tags the
// auth path as cloud_pat for telemetry. We use a stub Fleet here
// (no Redis) so the test runs without external services.
func TestDaemonAuth_MCN_ValidTokenSetsUserID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"valid": true,
"owner_id": "01972f7e-7e8d-77ef-a13d-1b0ce3e9c001",
"instance_id": "i-01"
}`))
}))
defer srv.Close()
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
var gotUser, gotPath string
mw := DaemonAuth(nil, nil, nil, verifier)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser = r.Header.Get("X-User-ID")
gotPath = DaemonAuthPathFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mcn_some_token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotUser != "01972f7e-7e8d-77ef-a13d-1b0ce3e9c001" {
t.Errorf("expected owner_id propagated as X-User-ID, got %q", gotUser)
}
if gotPath != DaemonAuthPathCloudPAT {
t.Errorf("expected auth path %q, got %q", DaemonAuthPathCloudPAT, gotPath)
}
}
// TestDaemonAuth_MCN_FleetSaysInvalid confirms that a valid:false
// Fleet response maps to 401 (not 503) — the token IS known to be bad,
// retrying won't help.
func TestDaemonAuth_MCN_FleetSaysInvalid(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"valid":false,"reason":"token_revoked"}`))
}))
defer srv.Close()
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
mw := DaemonAuth(nil, nil, nil, verifier)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called when fleet says invalid")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mcn_revoked")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d", w.Code)
}
}
// TestDaemonAuth_MCN_FleetUnreachable confirms the Unavailable branch
// emits 503 — the daemon must distinguish "your token is bad" (401, drop
// it) from "cloud is down" (503, retry later) so a brief outage doesn't
// invalidate everyone's PAT.
func TestDaemonAuth_MCN_FleetUnreachable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
mw := DaemonAuth(nil, nil, nil, verifier)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called when fleet is unavailable")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mcn_x")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when fleet is unavailable, got %d", w.Code)
}
}
// TestDaemonAuth_MCN_OwnerNotInLocalDB pins the new owner-existence
// guard end-to-end through the middleware. Cloud verifies the token
// successfully and returns an owner_id that does not exist in our
// local user table — DaemonAuth must reject with 401 (not 503) and
// MUST NOT call the next handler with a phantom X-User-ID.
func TestDaemonAuth_MCN_OwnerNotInLocalDB(t *testing.T) {
pool := openPool(t)
defer pool.Close()
queries := db.New(pool)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Owner_id is syntactically valid but not seeded in the DB.
_, _ = w.Write([]byte(`{
"valid": true,
"owner_id": "00000000-0000-0000-0000-0000000feed1",
"instance_id": "i-99"
}`))
}))
defer srv.Close()
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
mw := DaemonAuth(queries, nil, nil, verifier)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next must not be called when owner_id has no local user")
}))
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
req.Header.Set("Authorization", "Bearer mcn_phantom_owner")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 when local user is missing, got %d", w.Code)
}
}