diff --git a/auth/auth.go b/auth/auth.go index 56aa90d..42c2386 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -12,6 +12,9 @@ type Manager interface { AddUser(username, password string, role Role) error RemoveUser(username string) error ChangePassword(username, password string) error + ChangeRole(username string, role Role) error + AllowAccess(username string, topic string, read bool, write bool) error + ResetAccess(username string, topic string) error } type User struct { @@ -39,4 +42,14 @@ var Everyone = &User{ Role: RoleNone, } +var Roles = []Role{ + RoleAdmin, + RoleUser, + RoleNone, +} + +func AllowedRole(role Role) bool { + return role == RoleUser || role == RoleAdmin +} + var ErrUnauthorized = errors.New("unauthorized") diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index 65ca3cf..9674374 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -8,16 +8,16 @@ import ( /* SELECT * FROM user; -SELECT * FROM user_topic; +SELECT * FROM access; INSERT INTO user VALUES ('phil','$2a$06$.4W0LI5mcxzxhpjUvpTaNeu0MhRO0T7B.CYnmAkRnlztIy7PrSODu', 'admin'); INSERT INTO user VALUES ('ben','$2a$06$skJK/AecWCUmiCjr69ke.Ow/hFA616RdvJJPxnI221zyohsRlyXL.', 'user'); INSERT INTO user VALUES ('marian','$2a$10$8U90swQIatvHHI4sw0Wo7.OUy6dUwzMcoOABi6BsS4uF0x3zcSXRW', 'user'); -INSERT INTO user_topic VALUES ('ben','alerts',1,1); -INSERT INTO user_topic VALUES ('marian','alerts',1,0); -INSERT INTO user_topic VALUES ('','announcements',1,0); -INSERT INTO user_topic VALUES ('','write-all',1,1); +INSERT INTO access VALUES ('ben','alerts',1,1); +INSERT INTO access VALUES ('marian','alerts',1,0); +INSERT INTO access VALUES ('','announcements',1,0); +INSERT INTO access VALUES ('','write-all',1,1); */ @@ -34,7 +34,7 @@ const ( pass TEXT NOT NULL, role TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS user_topic ( + CREATE TABLE IF NOT EXISTS access ( user TEXT NOT NULL, topic TEXT NOT NULL, read INT NOT NULL, @@ -50,7 +50,7 @@ const ( selectUserQuery = `SELECT pass, role FROM user WHERE user = ?` selectTopicPermsQuery = ` SELECT read, write - FROM user_topic + FROM access WHERE user IN ('', ?) AND topic = ? ORDER BY user DESC ` @@ -58,10 +58,17 @@ const ( // Manager-related queries const ( - insertUser = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` - updateUserPass = `UPDATE user SET pass = ? WHERE user = ?` + insertUser = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` + updateUserPass = `UPDATE user SET pass = ? WHERE user = ?` + updateUserRole = `UPDATE user SET role = ? WHERE user = ?` + upsertAccess = ` + INSERT INTO access (user, topic, read, write) + VALUES (?, ?, ?, ?) + ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write + ` deleteUser = `DELETE FROM user WHERE user = ?` - deleteUserTopic = `DELETE FROM user_topic WHERE user = ?` + deleteAllAccess = `DELETE FROM access WHERE user = ?` + deleteAccess = `DELETE FROM access WHERE user = ? AND topic = ?` ) type SQLiteAuth struct { @@ -167,7 +174,7 @@ func (a *SQLiteAuth) RemoveUser(username string) error { if _, err := a.db.Exec(deleteUser, username); err != nil { return err } - if _, err := a.db.Exec(deleteUserTopic, username); err != nil { + if _, err := a.db.Exec(deleteAllAccess, username); err != nil { return err } return nil @@ -183,3 +190,30 @@ func (a *SQLiteAuth) ChangePassword(username, password string) error { } return nil } + +func (a *SQLiteAuth) ChangeRole(username string, role Role) error { + if _, err := a.db.Exec(updateUserRole, string(role), username); err != nil { + return err + } + return nil +} + +func (a *SQLiteAuth) AllowAccess(username string, topic string, read bool, write bool) error { + if _, err := a.db.Exec(upsertAccess, username, topic, read, write); err != nil { + return err + } + return nil +} + +func (a *SQLiteAuth) ResetAccess(username string, topic string) error { + if topic == "" { + if _, err := a.db.Exec(deleteAllAccess, username); err != nil { + return err + } + } else { + if _, err := a.db.Exec(deleteAccess, username, topic); err != nil { + return err + } + } + return nil +} diff --git a/cmd/app.go b/cmd/app.go index af5d8b8..0c38993 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -15,8 +15,8 @@ var ( ) const ( - categoryClient = "Client-side commands" - categoryServer = "Server-side commands" + categoryClient = "Client commands" + categoryServer = "Server commands" ) // New creates a new CLI application @@ -37,6 +37,8 @@ func New() *cli.App { // Server commands cmdServe, cmdUser, + cmdAllow, + cmdDeny, // Client commands cmdPublish, diff --git a/cmd/user.go b/cmd/user.go index 07f21b4..9de8f8d 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -29,12 +29,7 @@ dabbling for CLI */ -var flagsUser = []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), -} - +var flagsUser = userCommandFlags() var cmdUser = &cli.Command{ Name: "user", Usage: "Manage users and access to topics", @@ -60,21 +55,33 @@ var cmdUser = &cli.Command{ }, { Name: "change-pass", - Aliases: []string{"ch"}, + Aliases: []string{"chp"}, Usage: "change user password", Action: execUserChangePass, }, + { + Name: "change-role", + Aliases: []string{"chr"}, + Usage: "change user role", + Action: execUserChangeRole, + }, + { + Name: "list", + Aliases: []string{"chr"}, + Usage: "change user role", + Action: execUserChangeRole, + }, }, } func execUserAdd(c *cli.Context) error { - role := c.String("role") - if c.NArg() == 0 { + username := c.Args().Get(0) + role := auth.Role(c.String("role")) + if username == "" { return errors.New("username expected, type 'ntfy user add --help' for help") - } else if role != string(auth.RoleUser) && role != string(auth.RoleAdmin) { + } else if !auth.AllowedRole(role) { return errors.New("role must be either 'user' or 'admin'") } - username := c.Args().Get(0) password, err := readPassword(c) if err != nil { return err @@ -91,10 +98,10 @@ func execUserAdd(c *cli.Context) error { } func execUserDel(c *cli.Context) error { - if c.NArg() == 0 { + username := c.Args().Get(0) + if username == "" { return errors.New("username expected, type 'ntfy user del --help' for help") } - username := c.Args().Get(0) manager, err := createAuthManager(c) if err != nil { return err @@ -107,10 +114,10 @@ func execUserDel(c *cli.Context) error { } func execUserChangePass(c *cli.Context) error { - if c.NArg() == 0 { + username := c.Args().Get(0) + if username == "" { return errors.New("username expected, type 'ntfy user change-pass --help' for help") } - username := c.Args().Get(0) password, err := readPassword(c) if err != nil { return err @@ -126,6 +133,23 @@ func execUserChangePass(c *cli.Context) error { return nil } +func execUserChangeRole(c *cli.Context) error { + username := c.Args().Get(0) + role := auth.Role(c.Args().Get(1)) + if username == "" || !auth.AllowedRole(role) { + return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") + } + manager, err := createAuthManager(c) + if err != nil { + return err + } + if err := manager.ChangeRole(username, role); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "Changed role for user %s to %s\n", username, role) + return nil +} + func createAuthManager(c *cli.Context) (auth.Manager, error) { authFile := c.String("auth-file") authDefaultAccess := c.String("auth-default-access") @@ -158,3 +182,11 @@ func readPassword(c *cli.Context) (string, error) { } return string(password), nil } + +func userCommandFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + } +} diff --git a/cmd/user_allow.go b/cmd/user_allow.go new file mode 100644 index 0000000..a7478ee --- /dev/null +++ b/cmd/user_allow.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "errors" + "fmt" + "github.com/urfave/cli/v2" + "heckel.io/ntfy/auth" + "heckel.io/ntfy/util" +) + +var flagsAllow = append( + userCommandFlags(), + &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, +) + +var cmdAllow = &cli.Command{ + Name: "allow", + Usage: "Grant a user access to a topic", + UsageText: "ntfy allow USERNAME TOPIC [read-write|read-only|write-only]", + Flags: flagsAllow, + Before: initConfigFileInputSource("config", flagsAllow), + Action: execUserAllow, + Category: categoryServer, +} + +func execUserAllow(c *cli.Context) error { + username := c.Args().Get(0) + topic := c.Args().Get(1) + perms := c.Args().Get(2) + reset := c.Bool("reset") + if username == "" { + return errors.New("username expected, type 'ntfy allow --help' for help") + } else if !reset && topic == "" { + return errors.New("topic expected, type 'ntfy allow --help' for help") + } else if !util.InStringList([]string{"", "read-write", "read-only", "read", "ro", "write-only", "write", "wo", "none"}, perms) { + return errors.New("permission must be one of: read-write, read-only, write-only, or none (or the aliases: read, ro, write, wo)") + } + if username == "everyone" { + username = "" + } + read := util.InStringList([]string{"", "read-write", "read-only", "read", "ro"}, perms) + write := util.InStringList([]string{"", "read-write", "write-only", "write", "wo"}, perms) + manager, err := createAuthManager(c) + if err != nil { + return err + } + if reset { + return doAccessReset(c, manager, username, topic) + } + return doAccessAllow(c, manager, username, topic, read, write) +} + +func doAccessAllow(c *cli.Context, manager auth.Manager, username string, topic string, read bool, write bool) error { + if err := manager.AllowAccess(username, topic, read, write); err != nil { + return err + } + if username == "" { + if read && write { + fmt.Fprintf(c.App.ErrWriter, "Anonymous users granted full access to topic %s\n", topic) + } else if read { + fmt.Fprintf(c.App.ErrWriter, "Anonymous users granted read-only access to topic %s\n", topic) + } else if write { + fmt.Fprintf(c.App.ErrWriter, "Anonymous users granted write-only access to topic %s\n", topic) + } else { + fmt.Fprintf(c.App.ErrWriter, "Revoked all access to topic %s for all anonymous users\n", topic) + } + } else { + if read && write { + fmt.Fprintf(c.App.ErrWriter, "User %s now has read-write access to topic %s\n", username, topic) + } else if read { + fmt.Fprintf(c.App.ErrWriter, "User %s now has read-only access to topic %s\n", username, topic) + } else if write { + fmt.Fprintf(c.App.ErrWriter, "User %s now has write-only access to topic %s\n", username, topic) + } else { + fmt.Fprintf(c.App.ErrWriter, "Revoked all access to topic %s for user %s\n", topic, username) + } + } + return nil +} + +func doAccessReset(c *cli.Context, manager auth.Manager, username, topic string) error { + if err := manager.ResetAccess(username, topic); err != nil { + return err + } + if username == "" { + if topic == "" { + fmt.Fprintln(c.App.ErrWriter, "Reset access for all anonymous users and all topics") + } else { + fmt.Fprintf(c.App.ErrWriter, "Reset access to topic %s for all anonymous users\n", topic) + } + } else { + if topic == "" { + fmt.Fprintf(c.App.ErrWriter, "Reset access for user %s to all topics\n", username) + } else { + fmt.Fprintf(c.App.ErrWriter, "Reset access for user %s and topic %s\n", username, topic) + } + } + return nil +} diff --git a/cmd/user_deny.go b/cmd/user_deny.go new file mode 100644 index 0000000..d757bf6 --- /dev/null +++ b/cmd/user_deny.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "errors" + "github.com/urfave/cli/v2" +) + +var flagsDeny = userCommandFlags() +var cmdDeny = &cli.Command{ + Name: "deny", + Usage: "Revoke user access from a topic", + UsageText: "ntfy deny USERNAME TOPIC", + Flags: flagsDeny, + Before: initConfigFileInputSource("config", flagsDeny), + Action: execUserDeny, + Category: categoryServer, +} + +func execUserDeny(c *cli.Context) error { + username := c.Args().Get(0) + topic := c.Args().Get(1) + if username == "" { + return errors.New("username expected, type 'ntfy allow --help' for help") + } else if topic == "" { + return errors.New("topic expected, type 'ntfy allow --help' for help") + } + if username == "everyone" { + username = "" + } + manager, err := createAuthManager(c) + if err != nil { + return err + } + return doAccessAllow(c, manager, username, topic, false, false) +}