Replace atomic.Pointer for readbility

This commit is contained in:
binwiederhier
2026-05-31 10:45:10 -04:00
parent 62a812b742
commit 6310e3a96f
2 changed files with 125 additions and 122 deletions
+62 -66
View File
@@ -3,105 +3,101 @@ package user
import (
"regexp"
"strings"
"sync/atomic"
"sync"
"heckel.io/ntfy/v2/db"
)
// aclEntry mirrors one user_access row in the in-memory snapshot.
// aclCache is an in-memory index over the entire user_access table.
//
// exact[username][escapedTopic] returns the matching entry in O(1) for the common
// case where the requested topic appears verbatim in some rule. The key is the
// stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming
// topics through escapeUnderscore before probing.
//
// wildcard[username] is the linear-scan list of %-bearing rules for that user.
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
// only -- any user can create them.
type aclCache struct {
exact map[string]map[string]aclEntry
wildcard map[string][]aclEntry
mu sync.RWMutex // Protect exact and wildcard
}
// aclEntry mirrors one user_access row in the in-memory cache.
//
// topic is the raw stored value: it may contain \_ escapes (for literal underscores)
// and % wildcards (translated from user-supplied *). For exact-match entries (no %)
// matcher is nil and the entry is keyed by topic in aclSnapshot.exact. For wildcard
// matcher is nil and the entry is keyed by topic in aclCache.exact. For wildcard
// entries (with %) matcher is the pre-compiled regex equivalent of the LIKE pattern.
type aclEntry struct {
topic string
read bool
write bool
matcher *regexp.Regexp
}
// aclSnapshot is an immutable indexed form of the entire user_access table.
//
// exact[userName][escapedTopic] returns the matching entry in O(1) for the common
// case where the requested topic appears verbatim in some rule. The key is the
// stored form of the topic (i.e. with \_ escapes), so callers must pass topics
// through escapeUnderscore before probing.
//
// wildcards[userName] is the linear scan list of %-bearing rules for that user.
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
// only -- any user can create them.
type aclSnapshot struct {
exact map[string]map[string]aclEntry
wildcards map[string][]aclEntry
}
// aclCache holds the current snapshot behind an atomic pointer so that the hot
// path (Lookup) is lock-free. reload builds a fresh snapshot off the request
// path and atomically swaps the pointer; the old snapshot is GC'd once in-flight
// Lookups release their references.
//
// A nil receiver behaves as if no snapshot were loaded -- Lookup returns
// found=false, which the caller then resolves via DefaultAccess. This keeps
// tests and edge cases (e.g. early-startup) safe.
type aclCache struct {
snap atomic.Pointer[aclSnapshot]
matcher *regexp.Regexp // Nil for exact entries
}
func newAccessCache() *aclCache {
return &aclCache{}
return &aclCache{
exact: make(map[string]map[string]aclEntry),
wildcard: make(map[string][]aclEntry),
}
}
// reload runs the bulk-load query against the primary and atomically swaps in
// a fresh snapshot. The primary is used (not ReadOnly) so a reload immediately
// after an ACL mutation sees the freshly-written rows without replica lag.
// reload runs the bulk-load query against the primary and swaps in freshly-built
// exact and wildcard maps under the write lock. The primary is used (not
// ReadOnly) so a reload immediately after an ACL mutation sees the freshly-
// written rows without replica lag.
func (c *aclCache) reload(d *db.DB, query string) error {
rows, err := d.Query(query)
if err != nil {
return err
}
defer rows.Close()
snap := &aclSnapshot{
exact: make(map[string]map[string]aclEntry),
wildcards: make(map[string][]aclEntry),
}
exact := make(map[string]map[string]aclEntry)
wildcards := make(map[string][]aclEntry)
for rows.Next() {
var userName string
var username string
var entry aclEntry
if err := rows.Scan(&userName, &entry.topic, &entry.read, &entry.write); err != nil {
if err := rows.Scan(&username, &entry.topic, &entry.read, &entry.write); err != nil {
return err
}
if strings.Contains(entry.topic, "%") {
entry.matcher = compileLikeToRegex(entry.topic)
snap.wildcards[userName] = append(snap.wildcards[userName], entry)
} else {
if snap.exact[userName] == nil {
snap.exact[userName] = make(map[string]aclEntry)
re, err := compileLikeToRegex(entry.topic)
if err != nil {
return err
}
snap.exact[userName][entry.topic] = entry
entry.matcher = re
wildcards[username] = append(wildcards[username], entry)
} else {
if exact[username] == nil {
exact[username] = make(map[string]aclEntry)
}
exact[username][entry.topic] = entry
}
}
if err := rows.Err(); err != nil {
return err
}
c.snap.Store(snap)
c.mu.Lock()
c.exact = exact
c.wildcard = wildcards
c.mu.Unlock()
return nil
}
// Lookup returns the effective (read, write, found) permission for the given
// (username, topic), preserving the priority ordering of the original SQL query:
// 1. specific user beats Everyone
// 2. longer pattern beats shorter (more specific wins)
// 3. write beats read at equal length (write is "stronger")
// 1. specific user beats Everyone
// 2. longer pattern beats shorter (more specific wins)
// 3. write beats read at equal length (write is "stronger")
func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) {
if c == nil {
return false, false, false
}
snap := c.snap.Load()
if snap == nil {
return false, false, false
}
// Pre-compute the escaped form once: exact-match keys in the snapshot are
c.mu.RLock()
defer c.mu.RUnlock()
// Pre-compute the escaped form once: exact-match keys in the cache are
// stored as toSQLWildcard would emit them (literal _ -> \_), so the
// incoming topic must be escaped the same way before map lookup.
escaped := escapeUnderscore(topic)
@@ -109,28 +105,28 @@ func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found
// Specific user takes priority over Everyone. Skip the first lookup when
// the request is already anonymous to avoid scanning the same map twice.
if usernameOrEveryone != Everyone {
if e, ok := pickBest(snap, usernameOrEveryone, topic, escaped); ok {
if e, ok := c.pickBestLocked(usernameOrEveryone, topic, escaped); ok {
return e.read, e.write, true
}
}
if e, ok := pickBest(snap, Everyone, topic, escaped); ok {
if e, ok := c.pickBestLocked(Everyone, topic, escaped); ok {
return e.read, e.write, true
}
return false, false, false
}
// pickBest returns the highest-priority entry for a single user, combining the
// exact-match O(1) probe with a linear scan over the (usually empty or tiny)
// wildcard list. Priority within a user: longer pattern wins; write wins ties.
func pickBest(snap *aclSnapshot, userName, topic, escaped string) (aclEntry, bool) {
// pickBestLocked returns the highest-priority entry for a single user, combining
// the exact-match O(1) probe with a linear scan over the (usually empty or tiny)
// wildcard list. Caller must hold c.mu (RLock is sufficient).
func (c *aclCache) pickBestLocked(username, topic, escaped string) (aclEntry, bool) {
var best aclEntry
var found bool
if m, ok := snap.exact[userName]; ok {
if m, ok := c.exact[username]; ok {
if e, ok := m[escaped]; ok {
best, found = e, true
}
}
for _, w := range snap.wildcards[userName] {
for _, w := range c.wildcard[username] {
if !w.matcher.MatchString(topic) {
continue
}
@@ -158,7 +154,7 @@ func better(a, b aclEntry) bool {
// \_ is a literal underscore; no other backslashes occur. Topics themselves are
// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray
// backslashes appear in user-supplied input.
func compileLikeToRegex(pattern string) *regexp.Regexp {
func compileLikeToRegex(pattern string) (*regexp.Regexp, error) {
var sb strings.Builder
sb.WriteString("^")
i := 0
@@ -176,5 +172,5 @@ func compileLikeToRegex(pattern string) *regexp.Regexp {
}
}
sb.WriteString("$")
return regexp.MustCompile(sb.String())
return regexp.Compile(sb.String())
}