mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* 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>
87 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|