Merge pull request #1769 from binwiederhier/access-cache-1

Access cache to optimize performance
This commit is contained in:
Philipp C. Heckel
2026-06-01 21:22:05 -04:00
committed by GitHub
15 changed files with 952 additions and 52 deletions
+3
View File
@@ -52,6 +52,7 @@ var flagsServe = append(
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -168,6 +169,7 @@ func execServe(c *cli.Context) error {
authUsersRaw := c.StringSlice("auth-users")
authAccessRaw := c.StringSlice("auth-access")
authTokensRaw := c.StringSlice("auth-tokens")
authAccessCacheEnabled := c.Bool("auth-access-cache")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -468,6 +470,7 @@ func execServe(c *cli.Context) error {
conf.AuthUsers = authUsers
conf.AuthAccess = authAccess
conf.AuthTokens = authTokens
conf.AuthAccessCacheEnabled = authAccessCacheEnabled
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
+1
View File
@@ -378,6 +378,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AccessCacheEnabled: false, // Do not cache for CLI commands
}
if databaseURL != "" {
host, dbErr := pg.Open(databaseURL)
+5 -3
View File
@@ -1,14 +1,15 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/user"
"os"
"path/filepath"
"testing"
)
func TestCLI_User_Add(t *testing.T) {
@@ -128,6 +129,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
conf.File = configFile
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = false
s, port = test.StartServerWithConfig(t, conf)
return
}
+3 -1
View File
@@ -2284,6 +2284,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `auth-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory ACL cache so authorization checks no longer hit the database. Only worth enabling on high-volume servers. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
@@ -2295,7 +2296,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
@@ -2392,6 +2393,7 @@ OPTIONS:
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+4
View File
@@ -1926,6 +1926,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
### ntfy server v2.24.0 (UNRELEASED)
**Features:**
* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers
**Bug fixes + maintenance:**
* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution)
+4
View File
@@ -116,6 +116,8 @@ type Config struct {
AuthTokens map[string][]*user.Token
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
AttachmentCacheDir string
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
@@ -223,6 +225,8 @@ func NewConfig() *Config {
AuthDefault: user.PermissionReadWrite,
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
+12 -10
View File
@@ -247,16 +247,18 @@ func New(conf *Config) (*Server, error) {
var userManager *user.Manager
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
AccessCacheEnabled: conf.AuthAccessCacheEnabled,
AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval,
}
if pool != nil {
userManager, err = user.NewPostgresManager(pool, authConfig)
+3
View File
@@ -116,6 +116,8 @@
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
# Use 'ntfy token generate' to generate a new access token.
# - auth-access-cache enables an in-memory snapshot of the access control table that authorizes every
# request without a database round-trip.
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -131,6 +133,7 @@
# auth-users:
# auth-access:
# auth-tokens:
# auth-access-cache: false
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection.
+252
View File
@@ -0,0 +1,252 @@
package user
import (
"regexp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
)
// accessCache 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.
//
// pattern[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 accessCache struct {
exact map[string]map[string]aclEntry
pattern map[string][]aclEntry
mu sync.RWMutex // Protect exact and pattern
}
// aclEntry mirrors one user_access row. length feeds better()'s "longer
// pattern wins" tie-break; the stored topic/pattern string itself is not kept
// on the entry (the exact map already keys on it; surfacing wildcard "topics"
// like "up%" alongside real ones would invite misuse). pattern is the
// compiled regex form of the LIKE pattern; nil for exact entries.
type aclEntry struct {
length int
pattern *regexp.Regexp
read bool
write bool
}
func newAccessCache() *accessCache {
return &accessCache{
exact: make(map[string]map[string]aclEntry),
pattern: make(map[string][]aclEntry),
}
}
// 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")
func (c *accessCache) Lookup(username, topic string) (read, write, found bool) {
escapedTopic := escapeUnderscore(topic)
c.mu.RLock()
if username != Everyone {
if entry, found := c.lookupNoLock(username, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, username, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
}
if entry, found := c.lookupNoLock(Everyone, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, Everyone, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
c.mu.RUnlock()
maybeLogACLDecision(username, "", topic, false, false)
return false, false, false
}
// Reload scans (user_name, topic, read, write) rows and merges them into the
// cache. With no usernames the cache is replaced wholesale; otherwise the
// query is invoked with those usernames as positional args and only the
// listed users' slices are touched (a username absent from the result drops
// them from both maps). Runs against the primary so a reload after a
// mutation sees the just-written rows.
func (c *accessCache) Reload(d *db.DB, query string, usernames ...string) error {
started := time.Now()
scope := "full"
if len(usernames) > 0 {
scope = "users=" + strings.Join(usernames, ",")
}
args := make([]any, len(usernames))
for i, u := range usernames {
args[i] = u
}
// Query the database for all ACL entries
rows, err := d.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
exacts := make(map[string]map[string]aclEntry)
patterns := make(map[string][]aclEntry)
updatedEntries := 0
for rows.Next() {
var username, escapedTopic string
var read, write bool
if err := rows.Scan(&username, &escapedTopic, &read, &write); err != nil {
return err
}
entry, hasWildcard, err := toACLEntry(escapedTopic, read, write)
if err != nil {
return err
}
if hasWildcard {
patterns[username] = append(patterns[username], entry)
} else {
if exacts[username] == nil {
exacts[username] = make(map[string]aclEntry)
}
exacts[username][escapedTopic] = entry
}
updatedEntries++
}
if err := rows.Err(); err != nil {
return err
}
// Replace or update the internal maps
c.mu.Lock()
if len(usernames) == 0 {
c.exact = exacts
c.pattern = patterns
} else {
for _, u := range usernames {
if e, ok := exacts[u]; ok {
c.exact[u] = e
} else {
delete(c.exact, u)
}
if p, ok := patterns[u]; ok {
c.pattern[u] = p
} else {
delete(c.pattern, u)
}
}
}
c.mu.Unlock()
log.Tag(tag).
Field("reload_scope", scope).
Field("updated_entries", updatedEntries).
Field("duration_ms", time.Since(started).Milliseconds()).
Debug("Reloaded ACL cache")
return nil
}
// lookupNoLock returns the highest-priority entry for a single user. When
// more than one of that user's rules matches the requested topic, the winner
// is chosen by:
//
// 1. longer stored pattern beats shorter (a more specific rule wins over a
// more general one)
// 2. at equal length, write beats read (a stronger permission wins the tie)
//
// Exact and wildcard rules are ranked together under the same criteria, so
// an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard
// "foo%" (length 4) beats an exact "foo" (length 3).
func (c *accessCache) lookupNoLock(username, topic, escapedTopic string) (*aclEntry, bool) {
var best aclEntry
var found bool
if exact, exists := c.exact[username]; exists {
if entry, exists := exact[escapedTopic]; exists {
best, found = entry, true
}
}
for _, pattern := range c.pattern[username] {
if !pattern.pattern.MatchString(topic) {
continue
} else if !found || better(pattern, best) {
best, found = pattern, true
}
}
return &best, found
}
// toACLEntry builds an aclEntry from one user_access row's values. The
// isWildcard return tells the caller which storage slot the entry belongs in:
// the per-user wildcard slice if true, the per-user exact map if false.
// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact
// entries leave entry.pattern nil.
func toACLEntry(escapedTopic string, read, write bool) (entry aclEntry, hasWildcard bool, err error) {
entry = aclEntry{
length: len(escapedTopic),
read: read,
write: write,
}
if !strings.Contains(escapedTopic, "%") {
return entry, false, nil
}
pattern, err := compileLikeToRegex(escapedTopic)
if err != nil {
return entry, true, err
}
entry.pattern = pattern
return entry, true, nil
}
// better implements the (length DESC, write DESC) tie-break used by the original
// query's ORDER BY for entries owned by the same user.
func better(a, b aclEntry) bool {
if a.length != b.length {
return a.length > b.length
} else if a.write != b.write {
return a.write
}
return false
}
// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go
// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and
// \_ 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, error) {
var sb strings.Builder
sb.WriteString("^")
i := 0
for i < len(pattern) {
switch {
case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_':
sb.WriteString(regexp.QuoteMeta("_"))
i += 2
case pattern[i] == '%':
sb.WriteString(".*")
i++
default:
sb.WriteString(regexp.QuoteMeta(string(pattern[i])))
i++
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
// maybeLogACLDecision logs an ACL lookup result
func maybeLogACLDecision(requestUser, matchedUser, topic string, read, write bool) {
ev := log.Tag(tag).
Field("user_name", requestUser).
Field("topic", topic).
Field("read", read).
Field("write", write)
if !ev.IsTrace() {
return
}
if matchedUser == "" {
ev.Trace("ACL no match")
return
}
ev.Field("matched_user", matchedUser).Trace("ACL match")
}
+312
View File
@@ -0,0 +1,312 @@
package user
import (
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// Cache-only unit tests. Integration with the Manager (loading from the DB,
// reload-after-mutation, end-to-end Authorize behavior) is covered by the
// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via
// forEachStoreBackend.
func TestCompileLikeToRegex_Exact(t *testing.T) {
r := mustCompileLikeToRegex(t, "foo")
require.True(t, r.MatchString("foo"))
require.False(t, r.MatchString("foox"))
require.False(t, r.MatchString("xfoo"))
}
func TestCompileLikeToRegex_TrailingPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "up%")
require.True(t, r.MatchString("up"))
require.True(t, r.MatchString("up123"))
require.False(t, r.MatchString("xup"))
}
func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "%test%")
require.True(t, r.MatchString("test"))
require.True(t, r.MatchString("mytest"))
require.True(t, r.MatchString("testxxx"))
require.True(t, r.MatchString("xtestx"))
require.False(t, r.MatchString("nope"))
}
func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) {
// "my\_topic" is the stored form of a literal "my_topic" -- the underscore
// must match itself, NOT act as a SQL one-character wildcard.
r := mustCompileLikeToRegex(t, `my\_topic`)
require.True(t, r.MatchString("my_topic"))
require.False(t, r.MatchString("myXtopic"))
require.False(t, r.MatchString("mytopic"))
}
func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) {
// "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix
// followed by any suffix.
r := mustCompileLikeToRegex(t, `nz\_vip\_%`)
require.True(t, r.MatchString("nz_vip_"))
require.True(t, r.MatchString("nz_vip_alpha"))
require.False(t, r.MatchString("nz_vipX"))
require.False(t, r.MatchString("nzvip_alpha"))
}
func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) {
// Topics in ntfy can include '-', which is benign, but make sure
// regex metacharacters in the pattern are escaped properly anyway.
r := mustCompileLikeToRegex(t, "foo-bar")
require.True(t, r.MatchString("foo-bar"))
require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class
}
func TestACLCache_LookupBeforeReload(t *testing.T) {
// A freshly-constructed cache has empty exact and wildcards maps. The
// cache treats this as "no rule found", which the caller resolves via
// DefaultAccess.
c := newAccessCache()
read, write, found := c.Lookup("phil", "mytopic")
require.False(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_ExactMatchHit(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactMatchMiss(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
_, _, found := c.Lookup("phil", "othertopic")
require.False(t, found)
}
func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) {
// Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore
// in the requested topic must match, while any other single char must not.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: `my\_topic`, read: true, write: false},
})
read, write, found := c.Lookup("phil", "my_topic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
_, _, found = c.Lookup("phil", "myXtopic")
require.False(t, found)
}
func TestACLCache_WildcardMatch(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "up%", read: false, write: true},
})
read, write, found := c.Lookup("phil", "up42")
require.True(t, found)
require.False(t, read)
require.True(t, write)
}
func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: false},
{user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) {
// The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic).
// Concretely: a specific user with a shorter matching rule still wins over
// Everyone with a longer matching rule.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) {
// Same-length rules but conflicting permissions across user boundary: the
// specific user always wins, even if its permission set is weaker (or
// stronger, in either direction).
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open
{user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_AnonymousReadsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "announcements", read: true, write: false},
})
read, write, found := c.Lookup(Everyone, "announcements")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) {
// Both rules belong to the same user (Everyone). The more specific (longer)
// "mytopic%" should beat the catch-all "%".
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "%", read: true, write: false},
{user: Everyone, topic: "mytopic%", read: true, write: true},
})
read, write, found := c.Lookup(Everyone, "mytopicX")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "f%"
// (length 2). The longer one wins, which is the exact rule -- mirroring
// the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed
// "best" from the exact map probe before walking wildcards, otherwise a
// shorter wildcard could overwrite a longer exact.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%"
// (length 4). The wildcard wins on length DESC. Exercises the "swap best
// to wildcard when better() returns true" path.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all
{user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) {
// Two wildcard rules of identical length for the same user. The write rule
// should win the tie-break. The two-rows-with-same-topic shape is
// impossible via real upsert (pkey would conflict), so we inject the entries
// directly into the cache's wildcard slice.
c := newAccessCache()
c.mu.Lock()
c.exact = map[string]map[string]aclEntry{}
c.pattern = map[string][]aclEntry{
Everyone: {
{length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")},
{length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")},
},
}
c.mu.Unlock()
_, write, found := c.Lookup(Everyone, "abc")
require.True(t, found)
require.True(t, write)
}
func TestACLCache_ConcurrentLookupAndReload(t *testing.T) {
// Lock-based swap must be safe under concurrent reads. The race detector
// catches any unsafe shared mutation.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true},
})
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for !stop.Load() {
_, _, _ = c.Lookup(Everyone, "mytopic")
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1},
})
}
stop.Store(true)
}()
wg.Wait()
}
// rawACLRow models the rows that reload would Scan from the DB but avoids
// actually opening a DB for these unit tests.
type rawACLRow struct {
user string
topic string
read bool
write bool
}
// loadCache writes the given rows into the cache under its write lock,
// preserving the same exact/wildcard partitioning that reload would produce.
func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) {
t.Helper()
exact := make(map[string]map[string]aclEntry)
wildcards := make(map[string][]aclEntry)
for _, r := range rows {
e := aclEntry{length: len(r.topic), read: r.read, write: r.write}
if strings.Contains(r.topic, "%") {
e.pattern = mustCompileLikeToRegex(t, r.topic)
wildcards[r.user] = append(wildcards[r.user], e)
} else {
if exact[r.user] == nil {
exact[r.user] = make(map[string]aclEntry)
}
exact[r.user][r.topic] = e
}
}
c.mu.Lock()
c.exact = exact
c.pattern = wildcards
c.mu.Unlock()
}
func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp {
t.Helper()
r, err := compileLikeToRegex(pattern)
require.NoError(t, err)
return r
}
+148 -27
View File
@@ -38,6 +38,8 @@ const (
const (
DefaultUserStatsQueueWriterInterval = 33 * time.Second
DefaultUserPasswordBcryptCost = 10
DefaultAccessCacheEnabled = false
DefaultAccessCacheReloadInterval = 87 * time.Second
)
var (
@@ -48,12 +50,14 @@ var (
// Manager handles user authentication, authorization, and management
type Manager struct {
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
mu sync.Mutex
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
accessCache *accessCache // In-memory snapshot of user_access; refreshed by maybeReloadAccessCache after every ACL mutation
quit chan struct{} // Closed by Close() to signal background goroutines to stop
mu sync.Mutex
}
var _ Auther = (*Manager)(nil)
@@ -65,20 +69,65 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
if config.AccessCacheReloadInterval <= 0 {
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
}
manager := &Manager{
config: config,
db: d,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
quit: make(chan struct{}),
queries: queries,
}
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(manager.config.QueueWriterInterval)
if config.AccessCacheEnabled {
manager.accessCache = newAccessCache()
if err := manager.maybeReloadAccessCache(); err != nil {
return nil, err
}
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
}
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
return manager, nil
}
// maybeReloadAccessCache refreshes the in-memory access cache from the
// primary database. No-op when the cache is disabled. With no usernames it
// does a full bulk reload; with one or more it refreshes only those users'
// slices in a single DB round-trip via an IN clause.
func (a *Manager) maybeReloadAccessCache(usernames ...string) error {
if a.accessCache == nil {
return nil
}
if len(usernames) == 0 {
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheAll)
}
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...)
}
// asyncAccessCacheReloadLoop periodically bulk-reloads the access cache so that
// writes made by other processes against the same database (most notably the
// `ntfy access` CLI subcommand running while a server holds the cache) become
// visible within the configured interval. This Manager's own mutations do
// not depend on the poller -- they refresh affected users synchronously.
func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.maybeReloadAccessCache(); err != nil {
log.Tag(tag).Err(err).Warn("Reloading ACL cache failed")
}
}
}
}
// Authenticate checks username and password and returns a User if correct, and the user has not been
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
// the password is correct or incorrect.
@@ -151,9 +200,14 @@ func (a *Manager) RemoveUser(username string) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username)
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(username, Everyone)
}
// removeUserTx deletes the user with the given username
@@ -174,7 +228,7 @@ func (a *Manager) MarkUserRemoved(user *User) error {
if !AllowedUsername(user.Name) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.resetUserAccessTx(tx, user.Name); err != nil {
return err
}
@@ -187,14 +241,27 @@ func (a *Manager) MarkUserRemoved(user *User) error {
}
return nil
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(user.Name, Everyone)
}
// RemoveDeletedUsers deletes all users that have been marked deleted
func (a *Manager) RemoveDeletedUsers() error {
if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil {
res, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix())
if err != nil {
return err
}
return nil
affected, err := res.RowsAffected()
if err != nil {
return err
} else if affected == 0 {
return nil
}
// Full cache reload, because we don't know which users were affected.
return a.maybeReloadAccessCache()
}
// ChangePassword changes a user's password
@@ -225,9 +292,14 @@ func (a *Manager) ChangeRole(username string, role Role) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role)
})
if err != nil {
return err
}
// Full cache reload: Role changes are extremely rare.
return a.maybeReloadAccessCache()
}
// changeRoleTx changes a user's role
@@ -351,14 +423,20 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) {
a.statsQueue[userID] = stats
}
func (a *Manager) asyncQueueWriter(interval time.Duration) {
func (a *Manager) asyncQueueWriteLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
for range ticker.C {
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
}
}
}
}
@@ -588,9 +666,14 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
if err != nil {
return err
}
// Only this user's row set changed; refresh their slice only.
return a.maybeReloadAccessCache(username)
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
@@ -606,9 +689,20 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
func (a *Manager) ResetAccess(username string, topicPattern string) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.resetAccessTx(tx, username, topicPattern)
})
if err != nil {
return err
}
// Empty username -> deleteAllAccess affected every user, bulk reload.
// Otherwise refresh the named user plus Everyone, since resetUserAccessTx
// and deleteTopicAccess both touch rows owned by the user (typically the
// Everyone row from their reservations).
if username == "" {
return a.maybeReloadAccessCache()
}
return a.maybeReloadAccessCache(username, Everyone)
}
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
@@ -650,10 +744,20 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// authorizeTopicAccess returns the read/write permissions for the given username and topic.
// The found return value indicates whether an ACL entry was found at all.
//
// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions
// Priority:
// - Specific user beats Everyone
// - Longer pattern beats shorter (a more specific rule beats a more general one,
// e.g. "test*" > "*")
// - Write beats read at equal length
//
// When AccessCacheEnabled is true (config), the lookup is served entirely from
// the in-memory snapshot maintained by accessCache. Otherwise the original SQL
// query is executed against the database on every call.
func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
if a.accessCache != nil {
read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic)
return read, write, found, nil
}
rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil {
return false, false, false, err
@@ -731,7 +835,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if limit > 0 {
hasReservation, err := a.hasReservationTx(tx, username, topic)
if err != nil {
@@ -755,6 +859,11 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
}
return nil
})
if err != nil {
return err
}
// Both user's and Everyone's rows changed.
return a.maybeReloadAccessCache(username, Everyone)
}
// RemoveReservations deletes the access control entries associated with the given username/topic,
@@ -769,7 +878,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
return ErrInvalidArgument
}
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
for _, topic := range topics {
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return err
@@ -777,6 +886,12 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
}
return nil
})
if err != nil {
return err
}
// Mirror the DB: rows for this user and any Everyone rows owned by this
// user are gone. Refresh both slices.
return a.maybeReloadAccessCache(username, Everyone)
}
// Reservations returns all user-owned topics, and the associated everyone-access
@@ -1515,8 +1630,14 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string,
return nil
}
// Close closes the underlying database
// Close stops background goroutines and closes the underlying database.
func (a *Manager) Close() error {
select {
case <-a.quit:
// Already closed
default:
close(a.quit)
}
return a.db.Close()
}
+25
View File
@@ -1,6 +1,9 @@
package user
import (
"fmt"
"strings"
"heckel.io/ntfy/v2/db"
)
@@ -77,6 +80,11 @@ const (
WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\'
ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC
`
postgresSelectAccessCacheAllQuery = `
SELECT u.user_name, a.topic, a.read, a.write
FROM user_access a
JOIN "user" u ON u.id = a.user_id
`
postgresSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -221,6 +229,21 @@ const (
`
)
// postgresSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "$1, $2, ..." IN clause sized for n usernames.
func postgresSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE u.user_name IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "$%d", i+1)
}
sb.WriteString(")")
return sb.String()
}
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery,
@@ -245,6 +268,8 @@ var postgresQueries = queries{
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery,
+23
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -83,6 +84,11 @@ const (
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
`
sqliteSelectAccessCacheAllQuery = `
SELECT u.user, a.topic, a.read, a.write
FROM user_access a
JOIN user u ON u.id = a.user_id
`
sqliteSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -220,6 +226,21 @@ const (
`
)
// sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "?, ?, ..." IN clause sized for n usernames.
func sqliteSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("?")
}
sb.WriteString(")")
return sb.String()
}
var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
@@ -243,6 +264,8 @@ var sqliteQueries = queries{
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery,
+142
View File
@@ -2169,6 +2169,148 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
})
}
// TestAuthorizeTopicAccess_CacheAndDirectDBAgree wires up two Managers on the
// same backend storage -- one with AccessCacheEnabled=true (in-memory cache
// path) and one with AccessCacheEnabled=false (direct SQL path) -- then runs
// an identical battery of authorizeTopicAccess queries against both and
// asserts byte-identical (read, write, found) responses for every query.
// This protects the in-memory implementation from drifting away from the
// SQL behavior it is meant to mirror.
func TestAuthorizeTopicAccess_CacheAndDirectDBAgree(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// Seed via a Manager with the cache enabled. Writes go to the shared
// backend; both Managers will see them after the writes commit.
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleAdmin, false))
require.Nil(t, writer.AddUser("ben", "mypass", RoleUser, false))
require.Nil(t, writer.AddUser("alice", "mypass", RoleUser, false))
// A mix that exercises every branch of the priority logic:
// - exact and wildcard rules for the same user
// - exact and wildcard rules under Everyone
// - Everyone rules that are longer than the matching user rule
// - literal underscores (stored as "\_")
// - deny-all permissions
require.Nil(t, writer.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, writer.AllowAccess("ben", "writeme", PermissionWrite))
require.Nil(t, writer.AllowAccess("ben", "ben_topic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "mytopic*", PermissionRead))
require.Nil(t, writer.AllowAccess("alice", "alice_*", PermissionWrite))
require.Nil(t, writer.AllowAccess("alice", "secret", PermissionDenyAll))
require.Nil(t, writer.AllowAccess(Everyone, "announcements", PermissionRead))
require.Nil(t, writer.AllowAccess(Everyone, "up*", PermissionWrite))
require.Nil(t, writer.AllowAccess(Everyone, "mytopic", PermissionDenyAll))
// Build a reader Manager with the cache OFF, pointing at the same backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { reader.Close() })
// Probe matrix: every (user, topic) pair that exercises some branch.
cases := []struct {
user, topic string
}{
// Anonymous reads.
{Everyone, "announcements"},
{Everyone, "up42"},
{Everyone, "up"},
{Everyone, "downstream"},
{Everyone, "mytopic"},
{Everyone, "nope"},
// Specific user, only-user rules.
{"ben", "mytopic"},
{"ben", "readme"},
{"ben", "writeme"},
{"ben", "ben_topic"},
{"ben", "benXtopic"}, // underscore in rule means "X" must NOT match
// Specific user falls through to Everyone.
{"ben", "announcements"},
{"ben", "up5"},
{"alice", "announcements"},
// Wildcards with literal underscores.
{"alice", "alice_anything"},
{"alice", "alice_"},
{"alice", "aliceX"}, // does NOT match alice_*
// Exact-vs-wildcard overlap for the same user (ben has both
// "mytopic" exact and "mytopic*" wildcard).
{"ben", "mytopic"}, // exact wins on length
{"ben", "mytopicX"}, // only wildcard matches
{"ben", "mytopicYZ"}, // only wildcard matches
// Deny-all override.
{"alice", "secret"},
// No matching rule anywhere.
{"ben", "completely_unmatched"},
{"alice", "completely_unmatched"},
{Everyone, "completely_unmatched"},
}
// Sanity: the two Managers must agree on every probe.
for _, tc := range cases {
cRead, cWrite, cFound, cErr := writer.authorizeTopicAccess(tc.user, tc.topic)
dRead, dWrite, dFound, dErr := reader.authorizeTopicAccess(tc.user, tc.topic)
require.Nil(t, cErr, "cache path errored for (%s, %s)", tc.user, tc.topic)
require.Nil(t, dErr, "direct-DB path errored for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dFound, cFound, "found mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dRead, cRead, "read mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dWrite, cWrite, "write mismatch for (%s, %s)", tc.user, tc.topic)
}
})
}
// TestAccessCacheReloadInterval_PicksUpExternalWrite proves that the
// background reloader actually closes the cross-process coherence gap: a
// write made through a *different* Manager on the same backend becomes
// visible to a cache-enabled Manager within roughly one reload interval,
// without that Manager being told about the write.
func TestAccessCacheReloadInterval_PicksUpExternalWrite(t *testing.T) {
const interval = 25 * time.Millisecond
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// reader holds the cache and polls; writer plays the role of an
// out-of-band process (e.g. `ntfy access` CLI) writing to the same
// backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
AccessCacheReloadInterval: interval,
})
t.Cleanup(func() { reader.Close() })
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleUser, false))
// Sanity: before the write, the reader sees no rule for this topic.
_, _, found, err := reader.authorizeTopicAccess("phil", "via-poller")
require.Nil(t, err)
require.False(t, found)
// Write through the second Manager. reader's cache is unaware.
require.Nil(t, writer.AllowAccess("phil", "via-poller", PermissionReadWrite))
// Wait for the poller to catch up. The interval is 25ms; allow a
// generous multiple to keep this test from flaking on slow CI.
require.Eventually(t, func() bool {
read, write, found, err := reader.authorizeTopicAccess("phil", "via-poller")
return err == nil && found && read && write
}, 2*time.Second, 10*time.Millisecond, "reader's cache never observed the external write")
})
}
func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
+15 -11
View File
@@ -245,16 +245,18 @@ const (
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
}
// Error constants used by the package
@@ -303,7 +305,9 @@ type queries struct {
deleteUsersProvisioned string
// Access queries
selectTopicPerms string
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache
selectAccessCacheUsers func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames
selectUserAllAccess string
selectUserAccess string
selectUserReservations string