More CLI for access control

This commit is contained in:
Philipp Heckel 2022-01-23 15:30:30 -05:00
parent 243d549975
commit 03a4e3e8e9
6 changed files with 243 additions and 28 deletions

View file

@ -12,6 +12,9 @@ type Manager interface {
AddUser(username, password string, role Role) error AddUser(username, password string, role Role) error
RemoveUser(username string) error RemoveUser(username string) error
ChangePassword(username, password 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 { type User struct {
@ -39,4 +42,14 @@ var Everyone = &User{
Role: RoleNone, Role: RoleNone,
} }
var Roles = []Role{
RoleAdmin,
RoleUser,
RoleNone,
}
func AllowedRole(role Role) bool {
return role == RoleUser || role == RoleAdmin
}
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")

View file

@ -8,16 +8,16 @@ import (
/* /*
SELECT * FROM user; SELECT * FROM user;
SELECT * FROM user_topic; SELECT * FROM access;
INSERT INTO user VALUES ('phil','$2a$06$.4W0LI5mcxzxhpjUvpTaNeu0MhRO0T7B.CYnmAkRnlztIy7PrSODu', 'admin'); 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 ('ben','$2a$06$skJK/AecWCUmiCjr69ke.Ow/hFA616RdvJJPxnI221zyohsRlyXL.', 'user');
INSERT INTO user VALUES ('marian','$2a$10$8U90swQIatvHHI4sw0Wo7.OUy6dUwzMcoOABi6BsS4uF0x3zcSXRW', 'user'); INSERT INTO user VALUES ('marian','$2a$10$8U90swQIatvHHI4sw0Wo7.OUy6dUwzMcoOABi6BsS4uF0x3zcSXRW', 'user');
INSERT INTO user_topic VALUES ('ben','alerts',1,1); INSERT INTO access VALUES ('ben','alerts',1,1);
INSERT INTO user_topic VALUES ('marian','alerts',1,0); INSERT INTO access VALUES ('marian','alerts',1,0);
INSERT INTO user_topic VALUES ('','announcements',1,0); INSERT INTO access VALUES ('','announcements',1,0);
INSERT INTO user_topic VALUES ('','write-all',1,1); INSERT INTO access VALUES ('','write-all',1,1);
*/ */
@ -34,7 +34,7 @@ const (
pass TEXT NOT NULL, pass TEXT NOT NULL,
role TEXT NOT NULL role TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS user_topic ( CREATE TABLE IF NOT EXISTS access (
user TEXT NOT NULL, user TEXT NOT NULL,
topic TEXT NOT NULL, topic TEXT NOT NULL,
read INT NOT NULL, read INT NOT NULL,
@ -50,7 +50,7 @@ const (
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?` selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
selectTopicPermsQuery = ` selectTopicPermsQuery = `
SELECT read, write SELECT read, write
FROM user_topic FROM access
WHERE user IN ('', ?) AND topic = ? WHERE user IN ('', ?) AND topic = ?
ORDER BY user DESC ORDER BY user DESC
` `
@ -58,10 +58,17 @@ const (
// Manager-related queries // Manager-related queries
const ( const (
insertUser = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` insertUser = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
updateUserPass = `UPDATE user SET pass = ? WHERE user = ?` 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 = ?` 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 { type SQLiteAuth struct {
@ -167,7 +174,7 @@ func (a *SQLiteAuth) RemoveUser(username string) error {
if _, err := a.db.Exec(deleteUser, username); err != nil { if _, err := a.db.Exec(deleteUser, username); err != nil {
return err return err
} }
if _, err := a.db.Exec(deleteUserTopic, username); err != nil { if _, err := a.db.Exec(deleteAllAccess, username); err != nil {
return err return err
} }
return nil return nil
@ -183,3 +190,30 @@ func (a *SQLiteAuth) ChangePassword(username, password string) error {
} }
return nil 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
}

View file

@ -15,8 +15,8 @@ var (
) )
const ( const (
categoryClient = "Client-side commands" categoryClient = "Client commands"
categoryServer = "Server-side commands" categoryServer = "Server commands"
) )
// New creates a new CLI application // New creates a new CLI application
@ -37,6 +37,8 @@ func New() *cli.App {
// Server commands // Server commands
cmdServe, cmdServe,
cmdUser, cmdUser,
cmdAllow,
cmdDeny,
// Client commands // Client commands
cmdPublish, cmdPublish,

View file

@ -29,12 +29,7 @@ dabbling for CLI
*/ */
var flagsUser = []cli.Flag{ var flagsUser = userCommandFlags()
&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 cmdUser = &cli.Command{ var cmdUser = &cli.Command{
Name: "user", Name: "user",
Usage: "Manage users and access to topics", Usage: "Manage users and access to topics",
@ -60,21 +55,33 @@ var cmdUser = &cli.Command{
}, },
{ {
Name: "change-pass", Name: "change-pass",
Aliases: []string{"ch"}, Aliases: []string{"chp"},
Usage: "change user password", Usage: "change user password",
Action: execUserChangePass, 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 { func execUserAdd(c *cli.Context) error {
role := c.String("role") username := c.Args().Get(0)
if c.NArg() == 0 { role := auth.Role(c.String("role"))
if username == "" {
return errors.New("username expected, type 'ntfy user add --help' for help") 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'") return errors.New("role must be either 'user' or 'admin'")
} }
username := c.Args().Get(0)
password, err := readPassword(c) password, err := readPassword(c)
if err != nil { if err != nil {
return err return err
@ -91,10 +98,10 @@ func execUserAdd(c *cli.Context) error {
} }
func execUserDel(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") return errors.New("username expected, type 'ntfy user del --help' for help")
} }
username := c.Args().Get(0)
manager, err := createAuthManager(c) manager, err := createAuthManager(c)
if err != nil { if err != nil {
return err return err
@ -107,10 +114,10 @@ func execUserDel(c *cli.Context) error {
} }
func execUserChangePass(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") return errors.New("username expected, type 'ntfy user change-pass --help' for help")
} }
username := c.Args().Get(0)
password, err := readPassword(c) password, err := readPassword(c)
if err != nil { if err != nil {
return err return err
@ -126,6 +133,23 @@ func execUserChangePass(c *cli.Context) error {
return nil 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) { func createAuthManager(c *cli.Context) (auth.Manager, error) {
authFile := c.String("auth-file") authFile := c.String("auth-file")
authDefaultAccess := c.String("auth-default-access") authDefaultAccess := c.String("auth-default-access")
@ -158,3 +182,11 @@ func readPassword(c *cli.Context) (string, error) {
} }
return string(password), nil 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"}),
}
}

99
cmd/user_allow.go Normal file
View file

@ -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
}

35
cmd/user_deny.go Normal file
View file

@ -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)
}