mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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.
153 lines
5.1 KiB
Go
153 lines
5.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// seedOwnerLookupUser inserts a fresh user row and returns its UUID
|
|
// as the canonical hyphenated string. Mirrors the lightweight fixture
|
|
// pattern used by setupResolverFixture so the lookup helper can be
|
|
// exercised against a real DB without dragging in the heavier handler
|
|
// fixture.
|
|
func seedOwnerLookupUser(t *testing.T, queries *db.Queries) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
stamp := time.Now().UnixNano()
|
|
user, err := queries.CreateUser(ctx, db.CreateUserParams{
|
|
Name: "owner-lookup",
|
|
Email: pgtypeUniqueEmail(stamp),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
// Best-effort cleanup; another test parallel run will land
|
|
// on a different stamp so a leak here doesn't bleed.
|
|
_ = user
|
|
})
|
|
return uuidToString(user.ID)
|
|
}
|
|
|
|
// pgtypeUniqueEmail builds an email that is guaranteed unique within
|
|
// a test run so concurrent tests don't collide on the email UNIQUE
|
|
// index. Using nanosecond + a static suffix mirrors the patterns used
|
|
// elsewhere in the repo.
|
|
func pgtypeUniqueEmail(stamp int64) string {
|
|
return time.Unix(0, stamp).UTC().Format("20060102T150405.000000000") + "@owner-lookup.test"
|
|
}
|
|
|
|
// TestOwnerLookupFor_NilQueries pins the contract that a middleware
|
|
// constructed without a *db.Queries handle skips the lookup entirely
|
|
// (Verify treats a nil OwnerLookupFunc as "no lookup configured").
|
|
// This path only exists to support unit tests that wire up the
|
|
// verifier without a real database.
|
|
func TestOwnerLookupFor_NilQueries(t *testing.T) {
|
|
if got := ownerLookupFor(nil); got != nil {
|
|
t.Fatalf("ownerLookupFor(nil) must return nil, got %T", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLookupFor_ExistingUser confirms the happy path: a real
|
|
// row in the user table resolves to (true, nil).
|
|
func TestOwnerLookupFor_ExistingUser(t *testing.T) {
|
|
pool := openPool(t)
|
|
defer pool.Close()
|
|
queries := db.New(pool)
|
|
|
|
userID := seedOwnerLookupUser(t, queries)
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
|
|
})
|
|
|
|
lookup := ownerLookupFor(queries)
|
|
exists, err := lookup(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("lookup returned error: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatalf("expected user to be found")
|
|
}
|
|
}
|
|
|
|
// TestOwnerLookupFor_MissingUser confirms that a syntactically valid
|
|
// UUID that does not match any row resolves to (false, nil) — the
|
|
// signal the verifier uses to emit reason="owner_unknown" and reject
|
|
// without caching.
|
|
func TestOwnerLookupFor_MissingUser(t *testing.T) {
|
|
pool := openPool(t)
|
|
defer pool.Close()
|
|
queries := db.New(pool)
|
|
|
|
lookup := ownerLookupFor(queries)
|
|
// Random unused UUID — pgx.ErrNoRows territory.
|
|
exists, err := lookup(context.Background(), "00000000-0000-0000-0000-0000deadbeef")
|
|
if err != nil {
|
|
t.Fatalf("missing user must NOT surface as a lookup error, got %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatalf("expected lookup to report user-not-found")
|
|
}
|
|
}
|
|
|
|
// TestOwnerLookupFor_MalformedOwnerID confirms that an unparseable
|
|
// owner_id is treated as "user not found" (false, nil), not as a
|
|
// transient error. Cloud has already vetted the format on its side
|
|
// before signing the verify response, so a bad UUID here means
|
|
// either a contract violation or a forged response — either way the
|
|
// safe answer is to reject the token, same as a missing user.
|
|
func TestOwnerLookupFor_MalformedOwnerID(t *testing.T) {
|
|
pool := openPool(t)
|
|
defer pool.Close()
|
|
queries := db.New(pool)
|
|
|
|
lookup := ownerLookupFor(queries)
|
|
exists, err := lookup(context.Background(), "not-a-uuid")
|
|
if err != nil {
|
|
t.Fatalf("malformed owner_id must NOT surface as a lookup error, got %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatalf("malformed owner_id must report user-not-found")
|
|
}
|
|
}
|
|
|
|
// TestOwnerLookupFor_DBError mirrors the production failure mode the
|
|
// verifier maps to ErrCloudPATUnavailable: a query error (DB hung up,
|
|
// pool empty, etc.). Closing the pool before the call gives us a
|
|
// guaranteed real error rather than mocking the queries layer, which
|
|
// the rest of the suite doesn't do.
|
|
func TestOwnerLookupFor_DBError(t *testing.T) {
|
|
pool := openPool(t)
|
|
queries := db.New(pool)
|
|
pool.Close() // intentional — every subsequent query must fail
|
|
|
|
lookup := ownerLookupFor(queries)
|
|
exists, err := lookup(context.Background(), "00000000-0000-0000-0000-000000000001")
|
|
if err == nil {
|
|
t.Fatal("expected real DB error to surface, got nil")
|
|
}
|
|
if exists {
|
|
t.Fatal("DB error must not report user-found")
|
|
}
|
|
// And the error must NOT be classified as ErrNoRows by the
|
|
// caller — that's how the verifier distinguishes "user really
|
|
// doesn't exist" (401) from "DB is broken right now" (503).
|
|
if errors.Is(err, errors.New("no rows")) {
|
|
t.Fatalf("DB error must not look like ErrNoRows, got %v", err)
|
|
}
|
|
}
|
|
|
|
// uuidToStringForOwnerLookup avoids depending on the existing
|
|
// uuidToString helper here in case its signature changes; the
|
|
// fixtures only need the canonical hyphenated form.
|
|
//
|
|
//nolint:unused // referenced via seedOwnerLookupUser indirectly.
|
|
func uuidToStringForOwnerLookup(u pgtype.UUID) string {
|
|
return uuidToString(u)
|
|
}
|