Add granular permissions and role management to backend and admin UI.

This commit is contained in:
Kailash Nadh 2024-06-15 15:14:55 +05:30
parent 2bb4e19b74
commit f57ac201ff
21 changed files with 681 additions and 11 deletions

View file

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

View file

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

View file

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

113
cmd/roles.go Normal file
View file

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

View file

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

View file

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

View file

@ -38,10 +38,16 @@
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
</b-menu-item><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings"
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :expanded="activeGroup.users" :active="activeGroup.users" data-cy="users"
@update:active="(state) => toggleGroup('users', state)" icon="account-multiple" :label="$t('globals.terms.users')">
<b-menu-item :to="{ name: 'users' }" tag="router-link" :active="activeItem.users" data-cy="users"
icon="account-multiple" :label="$t('globals.terms.users')" />
<b-menu-item :to="{ name: 'roles' }" tag="router-link" :active="activeItem.roles" data-cy="roles"
icon="newspaper-variant-outline" :label="$t('users.roles')" />
</b-menu-item>
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings"
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings"
icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance"

View file

@ -9,6 +9,7 @@ export const models = Object.freeze({
media: 'media',
bounces: 'bounces',
users: 'users',
roles: 'roles',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',

View file

@ -114,11 +114,17 @@ const routes = [
component: () => 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',

View file

@ -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],

View file

@ -0,0 +1,156 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
</p>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('users.newRole') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
</b-field>
<p class="has-text-right">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
</p>
<b-table :data="serverConfig.permissions">
<b-table-column v-slot="props" field="group" label="Group">
{{ $tc(`globals.terms.${props.row.group}`) }}
</b-table-column>
<b-table-column v-slot="props" field="permissions" label="Permissions">
<div v-for="p in props.row.permissions" :key="p">
<b-checkbox v-model="form.permissions[p]">
{{ p }}
</b-checkbox>
</div>
</b-table-column>
</b-table>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'RoleForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
},
data() {
return {
// Binds form input values.
form: {
name: '',
permissions: {},
},
hasToggle: false,
};
},
methods: {
onSubmit() {
if (this.isEditing) {
this.updateRole();
return;
}
this.createRole();
},
onToggleSelect() {
if (this.hasToggle) {
this.form.permissions = {};
} else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => {
acc[p] = true;
});
return acc;
}, {});
}
this.hasToggle = !this.hasToggle;
},
createRole() {
const form = { ...this.form, permissions: Object.keys(this.form.permissions) };
this.$api.createRole(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
this.$parent.close();
});
},
updateRole() {
const form = { id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions) };
this.$api.updateRole(form).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
});
},
},
computed: {
...mapState(['loading', 'serverConfig']),
},
mounted() {
this.form = { ...this.form, name: this.$props.data.name };
if (this.isEditing) {
this.form.permissions = this.$props.data.permissions.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
} else {
const skip = ['admin', 'users'];
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
if (skip.includes(item.group)) {
return acc;
}
item.permissions.forEach((p) => {
if (p !== 'subscribers:sql_query') {
acc[p] = true;
}
});
return acc;
}, {});
}
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View file

@ -0,0 +1,134 @@
<template>
<section class="roles">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('users.roles') }}
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="roles" :loading="loading.roles" hoverable>
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
<a href="#" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="deleteRole(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.users">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="700" @close="onFormClose">
<role-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import RoleForm from './RoleForm.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
RoleForm,
},
data() {
return {
curItem: null,
isEditing: false,
isFormVisible: false,
};
},
methods: {
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.$api.getRoles();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'users' });
}
},
deleteRole(item) {
this.$utils.confirm(
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteRole(item.id).then(() => {
this.$api.getRoles();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
},
);
},
},
computed: {
...mapState(['loading', 'roles']),
},
mounted() {
this.$api.getRoles();
},
});
</script>

View file

@ -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",

View file

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

58
internal/core/roles.go Normal file
View file

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

View file

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

View file

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

View file

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

74
permissions.json Normal file
View file

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

View file

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

View file

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