129 lines
3.4 KiB
Go
129 lines
3.4 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// cacheEntry pairs the admin aggregate with its bcrypt hash. The
|
|
// hash is private to the admin package: handlers receive only the
|
|
// Admin shape, and Verify consumes the hash directly off the cache.
|
|
type cacheEntry struct {
|
|
admin Admin
|
|
passwordHash []byte
|
|
}
|
|
|
|
// Cache is the in-memory write-through projection of the rows in
|
|
// `backend.admin_accounts`. Reads (Get) are RLocked; writes (Put,
|
|
// Remove) are Locked.
|
|
//
|
|
// The cache mirrors the `auth.Cache` and `user.Cache` idioms: callers
|
|
// commit to Postgres first, then update the cache. A commit failure
|
|
// leaves the cache untouched, matching the previous DB state.
|
|
type Cache struct {
|
|
mu sync.RWMutex
|
|
byName map[string]cacheEntry
|
|
ready atomic.Bool
|
|
}
|
|
|
|
// NewCache constructs an empty Cache. The cache reports Ready() ==
|
|
// false until Warm completes successfully.
|
|
func NewCache() *Cache {
|
|
return &Cache{
|
|
byName: make(map[string]cacheEntry),
|
|
}
|
|
}
|
|
|
|
// Warm replaces the cache contents with every row loaded from store.
|
|
// It is intended to be called exactly once at process boot before the
|
|
// HTTP listener accepts traffic; successful completion flips Ready to
|
|
// true. Subsequent calls re-warm the cache (useful in tests).
|
|
func (c *Cache) Warm(ctx context.Context, store *Store) error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
admins, hashes, err := store.ListAll(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("admin cache warm: %w", err)
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.byName = make(map[string]cacheEntry, len(admins))
|
|
for i, a := range admins {
|
|
c.byName[a.Username] = cacheEntry{
|
|
admin: a,
|
|
passwordHash: hashes[i],
|
|
}
|
|
}
|
|
c.ready.Store(true)
|
|
return nil
|
|
}
|
|
|
|
// Ready reports whether Warm has completed at least once. The HTTP
|
|
// readiness probe wires through this method together with the auth
|
|
// and user caches so `/readyz` only flips to 200 after every cache is
|
|
// hydrated.
|
|
func (c *Cache) Ready() bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
return c.ready.Load()
|
|
}
|
|
|
|
// Size returns the number of cached admin accounts. Useful for the
|
|
// startup log line and tests.
|
|
func (c *Cache) Size() int {
|
|
if c == nil {
|
|
return 0
|
|
}
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return len(c.byName)
|
|
}
|
|
|
|
// Get returns the cached entry for username and a presence flag.
|
|
// Misses always return the zero entry and false.
|
|
func (c *Cache) Get(username string) (Admin, []byte, bool) {
|
|
if c == nil {
|
|
return Admin{}, nil, false
|
|
}
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
entry, ok := c.byName[username]
|
|
if !ok {
|
|
return Admin{}, nil, false
|
|
}
|
|
return entry.admin, entry.passwordHash, true
|
|
}
|
|
|
|
// Put stores admin and its bcrypt hash in the cache. It is safe to
|
|
// call on an existing entry — the value is overwritten with the
|
|
// latest snapshot. The slice is stored by reference; callers must not
|
|
// mutate it after handing it to Put.
|
|
func (c *Cache) Put(admin Admin, passwordHash []byte) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.byName[admin.Username] = cacheEntry{
|
|
admin: admin,
|
|
passwordHash: passwordHash,
|
|
}
|
|
}
|
|
|
|
// Remove evicts the entry for username. Calling Remove on a missing
|
|
// entry is a no-op. The current implementation ships no Delete operation; the helper
|
|
// exists for symmetry with `auth.Cache` / `user.Cache` and for any
|
|
// future hard-delete flow.
|
|
func (c *Cache) Remove(username string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.byName, username)
|
|
}
|