From f57ac201ff35dee146e38da76fc1766b272224dc Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sat, 15 Jun 2024 15:14:55 +0530 Subject: [PATCH] Add granular permissions and role management to backend and admin UI. --- Makefile | 2 +- cmd/admin.go | 15 ++- cmd/init.go | 26 +++++ cmd/roles.go | 113 ++++++++++++++++++ frontend/src/api/index.js | 22 ++++ frontend/src/assets/style.scss | 16 +++ frontend/src/components/Navigation.vue | 10 +- frontend/src/constants.js | 1 + frontend/src/router/index.js | 10 +- frontend/src/store/index.js | 1 + frontend/src/views/RoleForm.vue | 156 +++++++++++++++++++++++++ frontend/src/views/Roles.vue | 134 +++++++++++++++++++++ i18n/en.json | 5 + internal/auth/auth.go | 6 + internal/core/roles.go | 58 +++++++++ internal/migrations/v3.1.0.go | 9 ++ models/models.go | 7 ++ models/queries.go | 5 + permissions.json | 74 ++++++++++++ queries.sql | 12 ++ schema.sql | 10 ++ 21 files changed, 681 insertions(+), 11 deletions(-) create mode 100644 cmd/roles.go create mode 100644 frontend/src/views/RoleForm.vue create mode 100644 frontend/src/views/Roles.vue create mode 100644 internal/core/roles.go create mode 100644 permissions.json diff --git a/Makefile b/Makefile index e876313d..f3d65206 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ FRONTEND_DEPS = \ BIN := listmonk STATIC := config.toml.sample \ - schema.sql queries.sql \ + schema.sql queries.sql permissions.json \ static/public:/public \ static/email-templates \ frontend/dist:/admin \ diff --git a/cmd/admin.go b/cmd/admin.go index 444eb650..2171bc41 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "net/http" "sort" @@ -11,12 +12,13 @@ import ( ) type serverConfig struct { - Messengers []string `json:"messengers"` - Langs []i18nLang `json:"langs"` - Lang string `json:"lang"` - Update *AppUpdate `json:"update"` - NeedsRestart bool `json:"needs_restart"` - Version string `json:"version"` + Messengers []string `json:"messengers"` + Langs []i18nLang `json:"langs"` + Lang string `json:"lang"` + Permissions json.RawMessage `json:"permissions"` + Update *AppUpdate `json:"update"` + NeedsRestart bool `json:"needs_restart"` + Version string `json:"version"` } // handleGetServerConfig returns general server config. @@ -34,6 +36,7 @@ func handleGetServerConfig(c echo.Context) error { } out.Langs = langList out.Lang = app.constants.Lang + out.Permissions = app.constants.PermissionsRaw // Sort messenger names with `email` always as the first item. var names []string diff --git a/cmd/init.go b/cmd/init.go index 389f6a3d..b1ab3205 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -115,6 +115,9 @@ type constants struct { BounceSESEnabled bool BounceSendgridEnabled bool BouncePostmarkEnabled bool + + PermissionsRaw json.RawMessage + Permissions map[string]struct{} } type notifTpls struct { @@ -176,6 +179,7 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem "./config.toml.sample:config.toml.sample", "./queries.sql:queries.sql", "./schema.sql:schema.sql", + "./permissions.json:permissions.json", } frontendFiles = []string{ @@ -430,6 +434,28 @@ func initConstants() *constants { b := md5.Sum([]byte(time.Now().String())) c.AssetVersion = fmt.Sprintf("%x", b)[0:10] + pm, err := fs.Read("/permissions.json") + if err != nil { + lo.Fatalf("error reading permissions file: %v", err) + } + c.PermissionsRaw = pm + + // Make a lookup map of permissions. + permGroups := []struct { + Group string `json:"group"` + Permissions []string `json:"permissions"` + }{} + if err := json.Unmarshal(pm, &permGroups); err != nil { + lo.Fatalf("error loading permissions file: %v", err) + } + + c.Permissions = map[string]struct{}{} + for _, group := range permGroups { + for _, g := range group.Permissions { + c.Permissions[g] = struct{}{} + } + } + return &c } diff --git a/cmd/roles.go b/cmd/roles.go new file mode 100644 index 00000000..e579a9fb --- /dev/null +++ b/cmd/roles.go @@ -0,0 +1,113 @@ +package main + +import ( + "net/http" + "strconv" + "strings" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// handleGetRoles retrieves roles. +func handleGetRoles(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all roles. + out, err := app.core.GetRoles() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateRole handles role creation. +func handleCreateRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + r = models.Role{} + ) + + if err := c.Bind(&r); err != nil { + return err + } + + if err := validatePerms(r, app); err != nil { + return err + } + + out, err := app.core.CreateRole(r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleUpdateRole handles role modification. +func handleUpdateRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var r models.Role + if err := c.Bind(&r); err != nil { + return err + } + + if err := validatePerms(r, app); err != nil { + return err + } + + // Validate. + r.Name = strings.TrimSpace(r.Name) + + // Validate fields. + if !strHasLen(r.Name, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + + out, err := app.core.UpdateRole(id, r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleDeleteRole handles role deletion. +func handleDeleteRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.ParseInt(c.Param("id"), 10, 64) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if err := app.core.DeleteRole(int(id)); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validatePerms(r models.Role, app *App) error { + for _, p := range r.Permissions { + if _, ok := app.constants.Permissions[p]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "permission")) + } + } + + return nil +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 8c0a1bfa..492e8a5d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -488,3 +488,25 @@ export const updateUserProfile = (data) => http.put( data, { loading: models.users }, ); + +export const getRoles = async () => http.get( + '/api/roles', + { loading: models.roles, store: models.roles }, +); + +export const createRole = (data) => http.post( + '/api/roles', + data, + { loading: models.roles }, +); + +export const updateRole = (data) => http.put( + `/api/roles/${data.id}`, + data, + { loading: models.roles }, +); + +export const deleteRole = (id) => http.delete( + `/api/roles/${id}`, + { loading: models.roles }, +); diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 94a3d7a5..0450e3f5 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -931,6 +931,22 @@ section.users { color: $green; } +.permissions-group { + display: flex; + flex-wrap: wrap; + gap: 10px; + + label { + flex: 1 1 45%; + max-width: 45%; + display: flex; + } +} + +th.role-toggle-select a { + font-weight: normal; +} + /* C3 charting lib */ .c3 { .c3-text.c3-empty { diff --git a/frontend/src/components/Navigation.vue b/frontend/src/components/Navigation.vue index 660cb3bf..0b4f3a5b 100644 --- a/frontend/src/components/Navigation.vue +++ b/frontend/src/components/Navigation.vue @@ -38,10 +38,16 @@ data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" /> - + + + + + import('../views/Logs.vue'), }, { - path: '/settings/users', + path: '/users', name: 'users', - meta: { title: 'globals.terms.users', group: 'settings' }, + meta: { title: 'globals.terms.users', group: 'users' }, component: () => import('../views/Users.vue'), }, + { + path: '/users/roles', + name: 'roles', + meta: { title: 'users.roles', group: 'users' }, + component: () => import('../views/Roles.vue'), + }, { path: '/settings/maintenance', name: 'maintenance', diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 2b28422e..416badd0 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -42,6 +42,7 @@ export default new Vuex.Store({ [models.media]: (state) => state[models.media], [models.templates]: (state) => state[models.templates], [models.users]: (state) => state[models.users], + [models.roles]: (state) => state[models.roles], [models.settings]: (state) => state[models.settings], [models.serverConfig]: (state) => state[models.serverConfig], [models.logs]: (state) => state[models.logs], diff --git a/frontend/src/views/RoleForm.vue b/frontend/src/views/RoleForm.vue new file mode 100644 index 00000000..c3c22ae0 --- /dev/null +++ b/frontend/src/views/RoleForm.vue @@ -0,0 +1,156 @@ + + + diff --git a/frontend/src/views/Roles.vue b/frontend/src/views/Roles.vue new file mode 100644 index 00000000..38531151 --- /dev/null +++ b/frontend/src/views/Roles.vue @@ -0,0 +1,134 @@ + + + diff --git a/i18n/en.json b/i18n/en.json index 7adbd41c..1cbf0746 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -130,6 +130,7 @@ "forms.title": "Forms", "globals.buttons.add": "Add", "globals.buttons.addNew": "Add new", + "globals.buttons.toggleSelect": "Toggle selection", "globals.buttons.back": "Back", "globals.buttons.cancel": "Cancel", "globals.buttons.clear": "Clear", @@ -206,6 +207,7 @@ "globals.months.9": "Sep", "globals.states.off": "Off", "globals.terms.all": "All", + "globals.terms.admin": "Admin", "globals.terms.analytics": "Analytics", "globals.terms.bounce": "Bounce | Bounces", "globals.terms.bounces": "Bounces", @@ -595,6 +597,9 @@ "templates.rawHTML": "Raw HTML", "templates.subject": "Subject", "users.login": "Login", + "users.role": "Role | Roles", + "users.roles": "Roles", + "users.newRole": "New role", "users.loginOIDC": "Login with {name}", "users.logout": "Logout", "users.profile": "Profile", diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 4b111fc7..67807365 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -234,6 +234,12 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { } } +func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } +} + // SetSession creates and sets a session (post successful login/auth). func (o *Auth) SetSession(u models.User, oidcToken string, c echo.Context) error { // sess, err := o.sess.Acquire(nil, c, c) diff --git a/internal/core/roles.go b/internal/core/roles.go new file mode 100644 index 00000000..1c74791f --- /dev/null +++ b/internal/core/roles.go @@ -0,0 +1,58 @@ +package core + +import ( + "net/http" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +// GetRoles retrieves all roles. +func (c *Core) GetRoles() ([]models.Role, error) { + out := []models.Role{} + if err := c.q.GetRoles.Select(&out); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// CreateRole creates a new role. +func (c *Core) CreateRole(r models.Role) (models.Role, error) { + var out models.Role + + if err := c.q.CreateRole.Get(&out, r.Name, pq.Array(r.Permissions)); err != nil { + return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// UpdateRole updates a given role. +func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) { + var out models.Role + + if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil { + return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.role}", "error", pqErrMsg(err))) + } + + if out.ID == 0 { + return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}")) + } + + return out, nil +} + +// DeleteRole deletes a given role. +func (c *Core) DeleteRole(id int) error { + if _, err := c.q.DeleteRole.Exec(id); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err))) + } + + return nil +} diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go index 9d0f7786..561d86e8 100644 --- a/internal/migrations/v3.1.0.go +++ b/internal/migrations/v3.1.0.go @@ -37,6 +37,15 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + permissions TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON roles(LOWER(name)); + CREATE TABLE IF NOT EXISTS sessions ( id TEXT NOT NULL PRIMARY KEY, data jsonb DEFAULT '{}'::jsonb NOT NULL, diff --git a/models/models.go b/models/models.go index 3e7f6c69..278869d1 100644 --- a/models/models.go +++ b/models/models.go @@ -165,6 +165,13 @@ type User struct { HasPassword bool `db:"-" json:"-"` } +type Role struct { + Base + + Name string `db:"name" json:"name"` + Permissions pq.StringArray `db:"permissions" json:"permissions"` +} + // Subscriber represents an e-mail subscriber. type Subscriber struct { Base diff --git a/models/queries.go b/models/queries.go index 53a6771e..d396fb04 100644 --- a/models/queries.go +++ b/models/queries.go @@ -116,6 +116,11 @@ type Queries struct { GetUser *sqlx.Stmt `query:"get-user"` GetAPITokens *sqlx.Stmt `query:"get-api-tokens"` LoginUser *sqlx.Stmt `query:"login-user"` + + CreateRole *sqlx.Stmt `query:"create-role"` + GetRoles *sqlx.Stmt `query:"get-roles"` + UpdateRole *sqlx.Stmt `query:"update-role"` + DeleteRole *sqlx.Stmt `query:"delete-role"` } // CompileSubscriberQueryTpl takes an arbitrary WHERE expressions diff --git a/permissions.json b/permissions.json new file mode 100644 index 00000000..b2106bb3 --- /dev/null +++ b/permissions.json @@ -0,0 +1,74 @@ +[ + { + "group": "lists", + "permissions": + [ + "lists:get", + "lists:manage" + ] + }, + { + "group": "subscribers", + "permissions": + [ + "subscribers:get", + "subscribers:manage", + "subscribers:import", + "subscribers:sql_query", + "tx:send" + ] + }, + { + "group": "campaigns", + "permissions": + [ + "campaigns:get", + "campaigns:get_analytics", + "campaigns:manage" + ] + }, + { + "group": "bounces", + "permissions": + [ + "bounces:get", + "bounces:manage", + "webhooks:post_bounce" + ] + }, + { + "group": "media", + "permissions": + [ + "media:get", + "media:manage" + ] + }, + { + "group": "templates", + "permissions": + [ + "templates:get", + "templates:manage" + ] + }, + { + "group": "users", + "permissions": + [ + "users:get", + "users:manage", + "roles:get", + "roles:manage" + ] + }, + { + "group": "admin", + "permissions": + [ + "settings:get", + "settings:manage", + "maintenance:manage" + ] + } +] diff --git a/queries.sql b/queries.sql index 564c8780..d623dc12 100644 --- a/queries.sql +++ b/queries.sql @@ -1083,3 +1083,15 @@ SELECT * FROM u WHERE CRYPT($2, password) = password; UPDATE users SET name=$2, email=$3, password=(CASE WHEN $4 = TRUE THEN (CASE WHEN $5 != '' THEN CRYPT($5, GEN_SALT('bf')) ELSE password END) ELSE NULL END) WHERE id=$1; + +-- name: get-roles +SELECT * FROM roles ORDER BY created_at; + +-- name: create-role +INSERT INTO roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *; + +-- name: update-role +UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *; + +-- name: delete-role +DELETE FROM roles WHERE id=$1; diff --git a/schema.sql b/schema.sql index c930b344..dba48d99 100644 --- a/schema.sql +++ b/schema.sql @@ -316,6 +316,16 @@ CREATE TABLE users ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +DROP TABLE IF EXISTS roles CASCADE; +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + permissions TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +DROP INDEX IF EXISTS idx_roles_name; CREATE UNIQUE INDEX idx_roles_name ON roles(LOWER(name)); + -- user sessions DROP TABLE IF EXISTS sessions CASCADE; CREATE TABLE sessions (