mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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.
200 lines
6.8 KiB
Go
200 lines
6.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Daemon context keys.
|
|
type daemonContextKey int
|
|
|
|
const (
|
|
ctxKeyDaemonWorkspaceID daemonContextKey = iota
|
|
ctxKeyDaemonID
|
|
ctxKeyDaemonAuthPath
|
|
)
|
|
|
|
// Daemon auth path labels exposed via context for slow-log attribution.
|
|
const (
|
|
DaemonAuthPathDaemonToken = "daemon_token"
|
|
DaemonAuthPathPAT = "pat"
|
|
DaemonAuthPathJWT = "jwt"
|
|
)
|
|
|
|
// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware.
|
|
func DaemonWorkspaceIDFromContext(ctx context.Context) string {
|
|
id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string)
|
|
return id
|
|
}
|
|
|
|
// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware.
|
|
func DaemonIDFromContext(ctx context.Context) string {
|
|
id, _ := ctx.Value(ctxKeyDaemonID).(string)
|
|
return id
|
|
}
|
|
|
|
// DaemonAuthPathFromContext returns which token kind authenticated this
|
|
// request — "daemon_token", "pat", or "jwt" — for telemetry. Empty when the
|
|
// request did not pass through DaemonAuth.
|
|
func DaemonAuthPathFromContext(ctx context.Context) string {
|
|
p, _ := ctx.Value(ctxKeyDaemonAuthPath).(string)
|
|
return p
|
|
}
|
|
|
|
// WithDaemonContext returns a new context with the daemon workspace ID and daemon ID set.
|
|
// This is used by tests to simulate daemon token authentication.
|
|
func WithDaemonContext(ctx context.Context, workspaceID, daemonID string) context.Context {
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonWorkspaceID, workspaceID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonID, daemonID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonAuthPath, DaemonAuthPathDaemonToken)
|
|
return ctx
|
|
}
|
|
|
|
// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to
|
|
// JWT/PAT validation for backward compatibility with daemons that
|
|
// authenticate via user tokens.
|
|
//
|
|
// Both caches are optional. When non-nil:
|
|
// - daemonCache short-circuits the daemon_token DB lookup on the mdt_ path
|
|
// - patCache short-circuits the PAT DB lookup AND the last_used_at update
|
|
// on the mul_ fallback path. This is the same cache shared with the
|
|
// regular Auth middleware, so a single hot PAT used by both human CLI
|
|
// and a daemon converges on one DB round-trip per AuthCacheTTL window.
|
|
//
|
|
// Cache misses fall back to the original DB-backed behavior.
|
|
func DaemonAuth(queries *db.Queries, patCache *auth.PATCache, daemonCache *auth.DaemonTokenCache) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path)
|
|
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
|
return
|
|
}
|
|
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if tokenString == authHeader {
|
|
slog.Debug("daemon_auth: invalid format", "path", r.URL.Path)
|
|
writeError(w, http.StatusUnauthorized, "invalid authorization format")
|
|
return
|
|
}
|
|
|
|
// Daemon token: "mdt_" prefix.
|
|
if strings.HasPrefix(tokenString, "mdt_") {
|
|
hash := auth.HashToken(tokenString)
|
|
|
|
if id, ok := daemonCache.Get(r.Context(), hash); ok {
|
|
ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, id.WorkspaceID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonID, id.DaemonID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonAuthPath, DaemonAuthPathDaemonToken)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
if queries == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid daemon token")
|
|
return
|
|
}
|
|
dt, err := queries.GetDaemonTokenByHash(r.Context(), hash)
|
|
if err != nil {
|
|
slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err)
|
|
writeError(w, http.StatusUnauthorized, "invalid daemon token")
|
|
return
|
|
}
|
|
|
|
identity := auth.DaemonTokenIdentity{
|
|
WorkspaceID: uuidToString(dt.WorkspaceID),
|
|
DaemonID: dt.DaemonID,
|
|
}
|
|
// daemon_token.expires_at is NOT NULL; pgtype Valid is true
|
|
// in normal operation, but defend against zero just in case.
|
|
var expiresAt time.Time
|
|
if dt.ExpiresAt.Valid {
|
|
expiresAt = dt.ExpiresAt.Time
|
|
}
|
|
daemonCache.Set(r.Context(), hash, identity, auth.TTLForExpiry(time.Now(), expiresAt))
|
|
|
|
ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, identity.WorkspaceID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonID, identity.DaemonID)
|
|
ctx = context.WithValue(ctx, ctxKeyDaemonAuthPath, DaemonAuthPathDaemonToken)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// Fallback: PAT tokens ("mul_" prefix).
|
|
if strings.HasPrefix(tokenString, "mul_") {
|
|
hash := auth.HashToken(tokenString)
|
|
|
|
if userID, ok := patCache.Get(r.Context(), hash); ok {
|
|
r.Header.Set("X-User-ID", userID)
|
|
ctx := context.WithValue(r.Context(), ctxKeyDaemonAuthPath, DaemonAuthPathPAT)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
if queries == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid token")
|
|
return
|
|
}
|
|
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
|
|
if err != nil {
|
|
slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err)
|
|
writeError(w, http.StatusUnauthorized, "invalid token")
|
|
return
|
|
}
|
|
|
|
userID := uuidToString(pat.UserID)
|
|
r.Header.Set("X-User-ID", userID)
|
|
|
|
var expiresAt time.Time
|
|
if pat.ExpiresAt.Valid {
|
|
expiresAt = pat.ExpiresAt.Time
|
|
}
|
|
patCache.Set(r.Context(), hash, userID, auth.TTLForExpiry(time.Now(), expiresAt))
|
|
|
|
// Cache miss = first request in this TTL window. Refresh
|
|
// last_used_at; subsequent hits skip the write entirely.
|
|
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
|
|
|
ctx := context.WithValue(r.Context(), ctxKeyDaemonAuthPath, DaemonAuthPathPAT)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// Fallback: JWT tokens.
|
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
return auth.JWTSecret(), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err)
|
|
writeError(w, http.StatusUnauthorized, "invalid token")
|
|
return
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "invalid claims")
|
|
return
|
|
}
|
|
sub, ok := claims["sub"].(string)
|
|
if !ok || strings.TrimSpace(sub) == "" {
|
|
writeError(w, http.StatusUnauthorized, "invalid claims")
|
|
return
|
|
}
|
|
r.Header.Set("X-User-ID", sub)
|
|
ctx := context.WithValue(r.Context(), ctxKeyDaemonAuthPath, DaemonAuthPathJWT)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|