From fe545423c518b42534652aebf4f127a062f09eb3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 12:10:16 +0200 Subject: [PATCH] Change to auth-(users|access), upgrade manually added users to provision users --- cmd/serve.go | 40 ++++++++++++++++++++-------------------- server/config.go | 4 ++-- server/server.go | 4 ++-- user/manager.go | 38 ++++++++++++++++++++++++++++---------- user/manager_test.go | 20 ++++++++++---------- 5 files changed, 62 insertions(+), 44 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 7e7e56e1..dc503ccc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,8 +48,8 @@ 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-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}), - altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), + 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.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)"}), @@ -156,8 +156,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authProvisionUsersRaw := c.StringSlice("auth-provision-users") - authProvisionAccessRaw := c.StringSlice("auth-provision-access") + authUsersRaw := c.StringSlice("auth-users") + authAccessRaw := c.StringSlice("auth-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -353,11 +353,11 @@ func execServe(c *cli.Context) error { if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw) + authUsers, err := parseUsers(authUsersRaw) if err != nil { return err } - authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw) + authAccess, err := parseAccess(authUsers, authAccessRaw) if err != nil { return err } @@ -416,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionUsers = authProvisionUsers - conf.AuthProvisionAccess = authProvisionAccess + conf.AuthUsers = authUsers + conf.AuthAccess = authAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -531,22 +531,22 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } -func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { +func parseUsers(usersRaw []string) ([]*user.User, error) { provisionUsers := make([]*user.User, 0) for _, userLine := range usersRaw { parts := strings.Split(userLine, ":") if len(parts) != 3 { - return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine) + return nil, fmt.Errorf("invalid auth-users: %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 nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine) + return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) } else if err := user.AllowedPasswordHash(passwordHash); err != nil { - return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error()) + return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) } else if !user.AllowedRole(role) { - return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } provisionUsers = append(provisionUsers, &user.User{ Name: username, @@ -558,12 +558,12 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { return provisionUsers, nil } -func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { +func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { access := make(map[string][]*user.Grant) for _, accessLine := range provisionAccessRaw { parts := strings.Split(accessLine, ":") if len(parts) != 3 { - return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine) + return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) } username := strings.TrimSpace(parts[0]) if username == userEveryone { @@ -574,20 +574,20 @@ func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []stri }) if username != user.Everyone { if !exists { - return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username) } else if !user.AllowedUsername(username) { - return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username) } else if provisionUser.Role != user.RoleUser { - return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) } } topic := strings.TrimSpace(parts[1]) if !user.AllowedTopicPattern(topic) { - return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic) + return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic) } permission, err := user.ParsePermission(strings.TrimSpace(parts[2])) if err != nil { - return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) + return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) } if _, exists := access[username]; !exists { access[username] = make([]*user.Grant, 0) diff --git a/server/config.go b/server/config.go index 5cf0b035..99d829b2 100644 --- a/server/config.go +++ b/server/config.go @@ -95,8 +95,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthProvisionUsers []*user.User - AuthProvisionAccess map[string][]*user.Grant + AuthUsers []*user.User + AuthAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index dbe61905..55fa3af7 100644 --- a/server/server.go +++ b/server/server.go @@ -201,8 +201,8 @@ func New(conf *Config) (*Server, error) { StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access - ProvisionUsers: conf.AuthProvisionUsers, - ProvisionAccess: conf.AuthProvisionAccess, + Users: conf.AuthUsers, + Access: conf.AuthAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 2e176450..5418f534 100644 --- a/user/manager.go +++ b/user/manager.go @@ -184,6 +184,7 @@ const ( selectUserCountQuery = `SELECT COUNT(*) FROM user` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` @@ -535,8 +536,8 @@ type Config struct { StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - ProvisionUsers []*User // Predefined users to create on startup - ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup 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 } @@ -1374,6 +1375,21 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { return nil } +// ChangeProvisioned changes the provisioned status of a user. This is used to mark users as +// provisioned. A provisioned user is a user defined in the config file. +func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeProvisionedTx(tx, username, provisioned) + }) +} + +func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { + if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil { + return err + } + return nil +} + // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, // or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. func (a *Manager) ChangeTier(username, tier string) error { @@ -1669,7 +1685,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err != nil { return err } - provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string { + provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) return execTx(a.db, func(tx *sql.Tx) error { @@ -1678,14 +1694,13 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if user.Name == Everyone { continue } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - log.Tag(tag).Info("Removing previously provisioned user %s", user.Name) if err := a.removeUserTx(tx, user.Name); err != nil { return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) } } } // Add or update provisioned users - for _, user := range a.config.ProvisionUsers { + for _, user := range a.config.Users { if user.Name == Everyone { continue } @@ -1693,18 +1708,21 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { return u.Name == user.Name }) if !exists { - log.Tag(tag).Info("Adding provisioned user %s", user.Name) if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } } else { if !existingUser.Provisioned { - log.Tag(tag).Warn("Refusing to update manually user %s", user.Name) - } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { - log.Tag(tag).Info("Updating provisioned user %s", user.Name) + if err := a.changeProvisionedTx(tx, user.Name, true); err != nil { + return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) + } + } + if existingUser.Hash != user.Hash { if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) } + } + if existingUser.Role != user.Role { if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) } @@ -1715,7 +1733,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil { return err } - for username, grants := range a.config.ProvisionAccess { + for username, grants := range a.config.Access { for _, grant := range grants { if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err diff --git a/user/manager_test.go b/user/manager_test.go index 2ce078f3..d55726a3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1102,11 +1102,11 @@ func TestManager_WithProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{ + Users: []*User{ {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, }, - ProvisionAccess: map[string][]*Grant{ + Access: map[string][]*Grant{ "philuser": { {TopicPattern: "stats", Permission: PermissionReadWrite}, {TopicPattern: "secret", Permission: PermissionRead}, @@ -1144,10 +1144,10 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } - conf.ProvisionAccess = map[string][]*Grant{ + conf.Access = map[string][]*Grant{ "philuser": { {TopicPattern: "stats12", Permission: PermissionReadWrite}, {TopicPattern: "secret12", Permission: PermissionRead}, @@ -1178,8 +1178,8 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB again (third app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{} - conf.ProvisionAccess = map[string][]*Grant{} + conf.Users = []*User{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err) @@ -1199,8 +1199,8 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{}, - ProvisionAccess: map[string][]*Grant{}, + Users: []*User{}, + Access: map[string][]*Grant{}, } a, err := NewManager(conf) require.Nil(t, err) @@ -1210,10 +1210,10 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, } - conf.ProvisionAccess = map[string][]*Grant{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err)