From efef5876717f82e9a2b449ff590dd65a7ae8c02c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 22:36:01 +0200 Subject: [PATCH 1/2] WIP: Predefined users --- cmd/serve.go | 3 +++ cmd/user.go | 1 - server/config.go | 1 + server/server.go | 9 +++++++- user/manager.go | 55 +++++++++++++++++++++++++++++++----------------- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..516356c5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,6 +52,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), 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"}), 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)"}), @@ -157,6 +158,7 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") + authUsers := c.StringSlice("auth-users") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -406,6 +408,7 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + conf.AuthUsers = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index e6867b11..9902dace 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -94,7 +94,6 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. - `, }, { diff --git a/server/config.go b/server/config.go index 59b11c16..67554021 100644 --- a/server/config.go +++ b/server/config.go @@ -93,6 +93,7 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission + AuthUsers []user.User AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index bfa7eb6b..10ad7d8e 100644 --- a/server/server.go +++ b/server/server.go @@ -189,7 +189,14 @@ func New(conf *Config) (*Server, error) { } var userManager *user.Manager if conf.AuthFile != "" { - userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval) + authConfig := &user.Config{ + Filename: conf.AuthFile, + StartupQueries: conf.AuthStartupQueries, + DefaultAccess: conf.AuthDefault, + BcryptCost: conf.AuthBcryptCost, + QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + } + userManager, err = user.NewManager(authConfig) if err != nil { return nil, err } diff --git a/user/manager.go b/user/manager.go index 814ee827..04c3c878 100644 --- a/user/manager.go +++ b/user/manager.go @@ -441,36 +441,53 @@ var ( // Manager is an implementation of Manager. It stores users and access control list // in a SQLite database. type Manager struct { - db *sql.DB - defaultAccess Permission // Default permission if no ACL matches - 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) - bcryptCost int // Makes testing easier - mu sync.Mutex + config *Config + db *sql.DB + 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 +} + +type Config struct { + Filename string + StartupQueries string + DefaultAccess Permission // Default permission if no ACL matches + ProvisionedUsers []*User // Predefined users to create on startup + ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup + BcryptCost int // Makes testing easier + QueueWriterInterval time.Duration } var _ Auther = (*Manager)(nil) // NewManager creates a new Manager instance -func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { - db, err := sql.Open("sqlite3", filename) +func NewManager(config *Config) (*Manager, error) { + // Set defaults + if config.BcryptCost <= 0 { + config.BcryptCost = DefaultUserPasswordBcryptCost + } + if config.QueueWriterInterval.Seconds() <= 0 { + config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval + } + + // Open DB and run setup queries + db, err := sql.Open("sqlite3", config.Filename) if err != nil { return nil, err } if err := setupDB(db); err != nil { return nil, err } - if err := runStartupQueries(db, startupQueries); err != nil { + if err := runStartupQueries(db, config.StartupQueries); err != nil { return nil, err } manager := &Manager{ - db: db, - defaultAccess: defaultAccess, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - bcryptCost: bcryptCost, + db: db, + config: config, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), } - go manager.asyncQueueWriter(queueWriterInterval) + go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -843,7 +860,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { } defer rows.Close() if !rows.Next() { - return a.resolvePerms(a.defaultAccess, perm) + return a.resolvePerms(a.config.DefaultAccess, perm) } var read, write bool if err := rows.Scan(&read, &write); err != nil { @@ -873,7 +890,7 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1205,7 +1222,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1387,7 +1404,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { // DefaultAccess returns the default read/write access if no access control entry matches func (a *Manager) DefaultAccess() Permission { - return a.defaultAccess + return a.config.DefaultAccess } // AddTier creates a new tier in the database From c0b5151baeee36cfeeb0fea1c3c83519d8acb490 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 10 Jul 2025 20:50:29 +0200 Subject: [PATCH 2/2] Predefined users --- .goreleaser.yml | 84 +++++++++++++++++++------------------------- Makefile | 2 +- cmd/serve.go | 32 ++++++++++++++--- cmd/user.go | 19 +++++++--- server/config.go | 3 +- server/server.go | 2 ++ user/manager.go | 28 ++++++++++++--- user/manager_test.go | 54 +++++++++++++++++++++++++--- 8 files changed, 157 insertions(+), 67 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..82ab53e2 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } diff --git a/cmd/serve.go b/cmd/serve.go index 516356c5..abd9ac06 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,7 +52,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - 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-provisioned-users", Aliases: []string{"auth_provisioned_users"}, EnvVars: []string{"NTFY_AUTH_PROVISIONED_USERS"}, Usage: "pre-provisioned declarative users"}), 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"}), 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)"}), @@ -158,7 +158,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authUsers := c.StringSlice("auth-users") + authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users") + //authProvisionedAccessRaw := c.StringSlice("auth-provisioned-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -348,11 +349,33 @@ func execServe(c *cli.Context) error { webRoot = "/" + webRoot } - // Default auth permissions + // Convert default auth permission, read provisioned users authDefault, err := user.ParsePermission(authDefaultAccess) if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } + authProvisionedUsers := make([]*user.User, 0) + for _, userLine := range authProvisionedUsersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return fmt.Errorf("invalid provisioned user %s, expected format: 'name:hash:role'", userLine) + } + username := strings.TrimSpace(parts[0]) + passwordHash := strings.TrimSpace(parts[1]) + role := user.Role(strings.TrimSpace(parts[2])) + if !user.AllowedUsername(username) { + return fmt.Errorf("invalid provisioned user %s, username invalid", userLine) + } else if passwordHash == "" { + return fmt.Errorf("invalid provisioned user %s, password hash cannot be empty", userLine) + } else if !user.AllowedRole(role) { + return fmt.Errorf("invalid provisioned user %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + authProvisionedUsers = append(authProvisionedUsers, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + }) + } // Special case: Unset default if listenHTTP == "-" { @@ -408,7 +431,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthUsers = nil // FIXME + conf.AuthProvisionedUsers = authProvisionedUsers + conf.AuthProvisionedAccess = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index 9902dace..7519438c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -224,7 +224,7 @@ func execUserDel(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.RemoveUser(username); err != nil { @@ -250,7 +250,7 @@ func execUserChangePass(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if password == "" { @@ -278,7 +278,7 @@ func execUserChangeRole(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.ChangeRole(username, role); err != nil { @@ -302,7 +302,7 @@ func execUserChangeTier(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if tier == tierReset { @@ -344,7 +344,16 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { if err != nil { return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) + authConfig := &user.Config{ + Filename: authFile, + StartupQueries: authStartupQueries, + DefaultAccess: authDefault, + ProvisionedUsers: nil, //FIXME + ProvisionedAccess: nil, //FIXME + BcryptCost: user.DefaultUserPasswordBcryptCost, + QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + } + return user.NewManager(authConfig) } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/server/config.go b/server/config.go index 67554021..c163614f 100644 --- a/server/config.go +++ b/server/config.go @@ -93,7 +93,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthUsers []user.User + AuthProvisionedUsers []*user.User + AuthProvisionedAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 10ad7d8e..cba9b181 100644 --- a/server/server.go +++ b/server/server.go @@ -193,6 +193,8 @@ func New(conf *Config) (*Server, error) { Filename: conf.AuthFile, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, + ProvisionedUsers: conf.AuthProvisionedUsers, + ProvisionedAccess: conf.AuthProvisionedAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 04c3c878..8932f34a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -449,13 +449,13 @@ type Manager struct { } type Config struct { - Filename string - StartupQueries string + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches ProvisionedUsers []*User // Predefined users to create on startup ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup - BcryptCost int // Makes testing easier - QueueWriterInterval time.Duration + 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 } var _ Auther = (*Manager)(nil) @@ -469,7 +469,6 @@ func NewManager(config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - // Open DB and run setup queries db, err := sql.Open("sqlite3", config.Filename) if err != nil { @@ -487,6 +486,9 @@ func NewManager(config *Config) (*Manager, error) { statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), } + if err := manager.provisionUsers(); err != nil { + return nil, err + } go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -1522,6 +1524,22 @@ func (a *Manager) Close() error { return a.db.Close() } +func (a *Manager) provisionUsers() error { + for _, user := range a.config.ProvisionedUsers { + if err := a.AddUser(user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { + return err + } + } + for username, grants := range a.config.ProvisionedAccess { + for _, grant := range grants { + if err := a.AllowAccess(username, grant.TopicPattern, grant.Allow); err != nil { + return err + } + } + } + return nil +} + // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, // and escapes '_', assuming '\' as escape character. func toSQLWildcard(s string) string { diff --git a/user/manager_test.go b/user/manager_test.go index 89f35e3c..b57c762c 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -731,7 +731,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { } func TestManager_EnqueueStats_ResetStats(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -773,7 +780,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) { } func TestManager_EnqueueTokenUpdate(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -806,7 +820,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) { } func TestManager_ChangeSettings(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -1075,6 +1096,24 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) } +func TestManager_WithProvisionedUsers(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + conf := &Config{ + Filename: f, + DefaultAccess: PermissionReadWrite, + ProvisionedUsers: []*User{ + {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + }, + } + a, err := NewManager(conf) + require.Nil(t, err) + users, err := a.Users() + require.Nil(t, err) + for _, u := range users { + fmt.Println(u.ID, u.Name, u.Role) + } +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) @@ -1336,7 +1375,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager { } func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { - a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval) + conf := &Config{ + Filename: filename, + StartupQueries: startupQueries, + DefaultAccess: defaultAccess, + BcryptCost: bcryptCost, + QueueWriterInterval: statsWriterInterval, + } + a, err := NewManager(conf) require.Nil(t, err) return a }