* 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.
* feat(server/auth): cache PAT lookups in Redis with 60s TTL
Personal access tokens used to hit Postgres on every request: a SELECT
to resolve token_hash → user_id, plus a fire-and-forget UPDATE of
last_used_at. For a CLI / daemon making many requests per second this
is wasted DB load — the token is the same and the answer hasn't changed.
Add a Redis-backed cache (auth.PATCache) keyed by token hash, TTL 60s:
- On cache hit, the auth middleware skips both the SELECT and the
last_used_at UPDATE. last_used_at is now refreshed at most once per
TTL window per token, not per request.
- On cache miss the middleware falls back to today's behavior: query
Postgres, populate the cache, async-update last_used_at.
- On revoke, the handler invalidates the cache entry so revocation
takes effect immediately rather than waiting for the TTL to expire.
This required changing RevokePersonalAccessToken from :exec to :one
RETURNING token_hash.
The cache is nil-safe: when REDIS_URL isn't configured, NewPATCache
returns nil and the middleware degrades to today's always-hit-DB
behavior. JWT validation is untouched (already DB-free).
Tested with REDIS_TEST_URL — same gating pattern the rest of the
suite uses for Redis-backed tests. New tests cover nil-safety, set/
get/invalidate, TTL, and the middleware short-circuit on cache hit.
* fix(server/auth): clamp PAT cache TTL to token's remaining lifetime
GPT-Boy review caught: a PAT expiring in <60s would still be cached
for the full PATCacheTTL window, so the token could continue passing
auth on cache hit for up to ~60s after its expires_at. The DB query
filters expired tokens (revoked = FALSE AND expires_at > now()), but
that filter never ran on a cache hit.
Make Set take an explicit ttl, and add TTLForExpiry to compute it:
- no expires_at → full PATCacheTTL
- expires_at far → full PATCacheTTL
- expires_at <60s → time until expiry
- already expired → 0, Set skips caching (TOCTOU defense between
the SELECT and the Set, since the SELECT
already filters expired rows)
Regression test pins the clamp behavior end-to-end against Redis.
* feat(server/auth): cache daemon-token + PAT lookups in DaemonAuth, bump TTL to 10m
Daemon /api/daemon/* requests (heartbeat, claim task) hit DaemonAuth
which previously did its own GetDaemonTokenByHash on every request and
*also* duplicated the PAT lookup on the mul_ fallback — bypassing the
cache added in 1cdd674c. Today's daemons authenticate via mul_ PATs
(mdt_ minting isn't wired up yet), so the duplicate PAT path is the one
that actually matters for hot-path DB load.
Three changes:
1. New auth.DaemonTokenCache mirrors PATCache for the mdt_ path
(key = mul:auth:daemon:<sha256>, JSON value = {workspace_id, daemon_id}).
Forward-looking infrastructure for when daemon tokens get minted; the
middleware short-circuits the DB SELECT on cache hit. TTL clamped to
the token's expires_at via the shared TTLForExpiry helper.
2. DaemonAuth now also consults PATCache on its mul_ fallback, sharing
the same cache as the regular Auth middleware. A daemon making 4 hb/min
collapses from 4 GetPersonalAccessTokenByHash + 4 last_used_at writes
per minute to ~1 of each per AuthCacheTTL window (~10 minutes).
3. Rename PATCacheTTL → AuthCacheTTL and bump from 60s to 10 minutes.
The constant is now shared between PAT and daemon caches; 10m matches
the user-requested longer TTL for further DB write reduction. Revoke
latency on the happy path is still instant via active invalidation;
the worst-case (Redis Del miss / direct-DB revoke) grows from ~60s to
~10m.
Tests cover nil-safety, set/get/invalidate, TTL, clamped TTL on near-
expiry tokens, and the middleware short-circuit for both cache paths
(mdt_ via DaemonTokenCache, mul_ fallback via PATCache).
* feat(server/auth): cache PAT lookups on the WebSocket auth path
The third place a PAT is resolved — patResolver.ResolveToken used by
realtime.HandleWebSocket — was still hitting Postgres on every /ws
auth and firing an unconditional last_used_at UPDATE, bypassing the
cache added in 1cdd674c. Wire it through the same shared PATCache so
revoking a token through any path (Auth middleware, DaemonAuth PAT
fallback, or WS auth) hits all three caches with one Invalidate.
Also leaves a comment on DeleteDaemonTokensByWorkspaceAndDaemon —
the query has no caller today, but a future deregister/rotate flow
must remember to call DaemonTokenCache.Invalidate(hash) for each
deleted row, otherwise deleted daemon tokens stay valid until TTL.
Prod slow-log on the deployed v0.2.17 fix shows total_ms=4012,
auth_ms=4010, update_ms=1, all skill stages = 0 — meaning the bottleneck
on /api/daemon/heartbeat is now the auth section, not the Redis claim
path. To pinpoint which sub-stage dominates, decompose auth_ms into:
- decode_ms — JSON body decode
- runtime_lookup_ms — Queries.GetAgentRuntime (PG PK select)
- workspace_check_ms — requireDaemonWorkspaceAccess (string compare for
daemon-token, requireWorkspaceMember for PAT/JWT)
Also add auth_path ("daemon_token" | "pat" | "jwt") set by DaemonAuth
middleware so slow-logs disambiguate which token kind was used. PAT/JWT
takes an extra DB round-trip via requireWorkspaceMember and is a
candidate cause of long auth tails on daemons that haven't migrated to
mdt_ tokens.
The handler keeps the same external behavior; the change inlines and
instruments requireDaemonRuntimeAccess in DaemonHeartbeat only — other
callers of the helper are untouched. logHeartbeatEndpointSlow gains the
new fields.
Existing heartbeat tests pass; the slow-probe test output now shows the
new auth_path / decode_ms / runtime_lookup_ms / workspace_check_ms
fields populated.
* fix(security): add workspace ownership checks to all daemon API routes
Switch daemon routes from middleware.Auth to middleware.DaemonAuth and
add per-handler workspace ownership verification. This prevents
cross-workspace access to runtimes, tasks, usage, and daemon lifecycle
endpoints (HIGH-1/2/3 + CHAIN-1/2/3).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(security): support mdt_ daemon tokens in DaemonRegister + add regression tests
DaemonRegister now handles both auth paths:
- mdt_ daemon tokens: verify workspace match, skip member check, zero OwnerID
(SQL COALESCE preserves existing owner on upsert)
- PAT/JWT: existing member check + OwnerID from member
Also adds WithDaemonContext helper and regression tests covering:
- Successful register with daemon token
- Workspace mismatch rejection
- Cross-workspace heartbeat rejection
- Cross-workspace task status rejection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Issue daemon auth tokens (mdt_) on pairing session claim, bound to
workspace_id + daemon_id with 1-year expiry. Add DaemonAuth middleware
that validates these tokens and falls back to JWT/PAT for backward
compatibility. Apply middleware to all daemon routes except pairing
endpoints.