Files
multica/server/internal/auth/membership_cache.go
Zohar Babin 15152c6ccd feat(auth): cache workspace membership for daemon heartbeat path (MUL-2247) (#2638)
* feat(auth): cache workspace membership for daemon heartbeat path

Cache workspace membership existence (not role) in Redis to eliminate a
DB round-trip on every PAT-authenticated daemon heartbeat. Follows the
existing PATCache nil-safe pattern.

Key design decisions per reviewer feedback:
- Cache existence only (sentinel "1"), not role string. Authorization
  decisions that depend on role always hit the DB directly. This
  eliminates the cache-aside race where a stale elevated role could
  persist after a downgrade.
- Proactive invalidation on UpdateMember, DeleteMember, LeaveWorkspace,
  and DeleteWorkspace (iterates members before cascade delete).
- 5 min TTL. Combined with PATCache (10 min), worst-case revocation
  delay is max(10m, 5m) = 10 min — consistent with original PATCache
  design decision.

Limitations:
- Non-members still hit DB on every request (negative caching not
  implemented — the scenario is rare for daemon endpoints which require
  valid workspace-scoped tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(auth): drive membership cache invalidation through real handlers

- TestRequireDaemonWorkspaceAccess_CacheHit now uses a ghost user with no
  member row, so the only path to a granted access is the cache short-circuit.
  Without priming the cache the access check must fail; with priming it must
  succeed. A future change that bypasses the cache would fail the second
  assertion.
- Replaces the cache-only InvalidatedOnMemberRemoval test (which only
  re-exercised the auth-package primitive) with four handler-driven tests
  that exercise DeleteMember, UpdateMember, LeaveWorkspace and
  DeleteWorkspace via their real HTTP handlers. Each test prepares a real
  member, primes the cache, calls the handler, and asserts the cache entry
  is gone — so a refactor that drops one of the Invalidate(...) calls in
  workspace.go will fail CI.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-05-18 13:30:35 +08:00

87 lines
2.8 KiB
Go

package auth
import (
"context"
"errors"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
const membershipCachePrefix = "mul:auth:member:"
// MembershipCacheTTL bounds how long a workspace membership lookup stays
// cached before the handler goes back to Postgres. Short enough that a
// removed member loses access within minutes; long enough that a
// high-frequency caller (daemon heartbeat every ~15s) collapses from one
// DB round-trip per request to one per TTL window.
const MembershipCacheTTL = 5 * time.Minute
// MembershipCache caches workspace membership existence checks in Redis.
// It tracks ONLY whether a user is a member of a workspace — it does NOT
// store role information. Authorization decisions that depend on role
// (requireWorkspaceRole, RequireWorkspaceRoleFromURL) MUST always query
// the database directly.
//
// Revocation latency: a removed member may retain cached access for up to
// MembershipCacheTTL (5 min). Combined with PATCache (10 min), the
// worst-case revocation delay is max(10m, 5m) = 10 min — consistent with
// the original PATCache design decision.
//
// A nil *MembershipCache is safe to use — every method becomes a no-op or
// reports a cache miss, and the caller degrades to direct DB lookups.
type MembershipCache struct {
rdb *redis.Client
}
// NewMembershipCache returns a cache backed by rdb. Pass nil to disable
// caching; the returned *MembershipCache is safe to call but never hits
// Redis.
func NewMembershipCache(rdb *redis.Client) *MembershipCache {
if rdb == nil {
return nil
}
return &MembershipCache{rdb: rdb}
}
func membershipKey(userID, workspaceID string) string {
return membershipCachePrefix + userID + ":" + workspaceID
}
// Get returns whether the user is a cached member of the workspace.
// Returns false on miss or any Redis error.
func (c *MembershipCache) Get(ctx context.Context, userID, workspaceID string) (ok bool) {
if c == nil {
return false
}
_, err := c.rdb.Get(ctx, membershipKey(userID, workspaceID)).Result()
if err != nil {
if !errors.Is(err, redis.Nil) {
slog.Warn("membership_cache: get failed; falling back to DB", "error", err)
}
return false
}
return true
}
// Set caches the existence of membership for the given user+workspace pair.
func (c *MembershipCache) Set(ctx context.Context, userID, workspaceID string) {
if c == nil {
return
}
if err := c.rdb.Set(ctx, membershipKey(userID, workspaceID), "1", MembershipCacheTTL).Err(); err != nil {
slog.Warn("membership_cache: set failed", "error", err)
}
}
// Invalidate removes the cached entry for a specific user+workspace.
func (c *MembershipCache) Invalidate(ctx context.Context, userID, workspaceID string) {
if c == nil {
return
}
if err := c.rdb.Del(ctx, membershipKey(userID, workspaceID)).Err(); err != nil {
slog.Warn("membership_cache: invalidate failed", "error", err)
}
}