Add per-list permission to list management.

- Filter lists by permitted list IDs in DB get calls.
- Split getLists() handlers into two (one, all) for clarity.
- Introduce new `subscribers:get_by_list` permission.
- Tweak UI rendering to work with new per-list permssions.
This commit is contained in:
Kailash Nadh 2024-08-03 11:28:53 +05:30
parent 982e8d8320
commit d74e067961
12 changed files with 125 additions and 58 deletions

View file

@ -1,19 +1,26 @@
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
var (
errListPerm = echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("list permission denied"))
)
// handleGetLists retrieves lists with additional metadata like subscriber counts.
func handleGetLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = app.paginator.NewFromURL(c.Request().URL.Query())
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)
pg = app.paginator.NewFromURL(c.Request().URL.Query())
query = strings.TrimSpace(c.FormValue("query"))
tags = c.QueryParams()["tag"]
@ -22,28 +29,13 @@ func handleGetLists(c echo.Context) error {
optin = c.FormValue("optin")
order = c.FormValue("order")
minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
listID, _ = strconv.Atoi(c.Param("id"))
out models.PageResults
)
// Fetch one list.
single := false
if listID > 0 {
single = true
}
if single {
out, err := app.core.GetList(listID, "")
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
if !single && minimal {
res, err := app.core.GetLists("")
if minimal {
res, err := app.core.GetLists("", user.GetListIDs)
if err != nil {
return err
}
@ -61,20 +53,11 @@ func handleGetLists(c echo.Context) error {
}
// Full list query.
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, pg.Offset, pg.Limit)
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, user.GetListIDs, pg.Offset, pg.Limit)
if err != nil {
return err
}
if single && len(res) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
if single {
return c.JSON(http.StatusOK, okResp{res[0]})
}
out.Query = query
out.Results = res
out.Total = total
@ -84,6 +67,21 @@ func handleGetLists(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetList retrieves a single list by id.
func handleGetList(c echo.Context) error {
var (
app = c.Get("app").(*App)
listID, _ = strconv.Atoi(c.Param("id"))
)
out, err := app.core.GetList(listID, "")
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateList handles list creation.
func handleCreateList(c echo.Context) error {
var (
@ -160,3 +158,36 @@ func handleDeleteLists(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// listPerm is a middleware for wrapping /list/* API calls that take a
// list :id param for validating the list ID against the user's list perms.
func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u = c.Get(auth.UserKey).(models.User)
id, _ = strconv.Atoi(c.Param("id"))
)
// Define permissions based on HTTP read/write.
var (
permAll = "lists:manage_all"
perm = "list:manage"
)
if c.Request().Method == http.MethodGet {
permAll = "lists:get_all"
perm = "list:get"
}
// Check if the user has permissions for all lists or the specific list.
if _, ok := u.PermissionsMap[permAll]; ok {
return next(c)
}
if id > 0 {
if _, ok := u.ListPermissionsMap[id][perm]; ok {
return next(c)
}
}
return errListPerm
}
}

View file

@ -115,7 +115,7 @@ func handleGetPublicLists(c echo.Context) error {
)
// Get all public lists.
lists, err := app.core.GetLists(models.ListTypePublic)
lists, err := app.core.GetLists(models.ListTypePublic, nil)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
}
@ -418,7 +418,7 @@ func handleSubscriptionFormPage(c echo.Context) error {
}
// Get all public lists.
lists, err := app.core.GetLists(models.ListTypePublic)
lists, err := app.core.GetLists(models.ListTypePublic, nil)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))

View file

@ -200,9 +200,7 @@ export default Vue.extend({
mounted() {
// Lists is required across different views. On app load, fetch the lists
// and have them in the store.
if (this.$can('lists:get_all')) {
this.$api.getLists({ minimal: true, per_page: 'all' });
}
this.$api.getLists({ minimal: true, per_page: 'all' });
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth;

View file

@ -3,7 +3,7 @@
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard -->
<b-menu-item v-if="$can('lists:get_all')" :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
<b-menu-item :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
@update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')">
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"

View file

@ -44,7 +44,7 @@ async function initConfig(app) {
// $can('permission:name') is used in the UI to chekc whether the logged in user
// has a certain permission to toggle visibility of UI objects and UI functionality.
Vue.prototype.$can = (perm) => {
Vue.prototype.$can = (...perms) => {
if (profile.role_id === 1) {
return true;
}
@ -52,12 +52,14 @@ async function initConfig(app) {
// If the perm ends with a wildcard, check whether at least one permission
// in the group is present. Eg: campaigns:* will return true if at least
// one of campaigns:get, campaigns:manage etc. are present.
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.role.permissions.some((p) => p.startsWith(group));
}
return perms.some((perm) => {
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.role.permissions.some((p) => p.startsWith(group));
}
return profile.role.permissions.includes(perm);
return profile.role.permissions.includes(perm);
});
};
// Set the page title after i18n has loaded.

View file

@ -58,8 +58,7 @@
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('lists:manage_all')" native-type="submit" type="is-primary" :loading="loading.lists"
data-cy="btn-save">
<b-button v-if="canManage" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
@ -124,7 +123,16 @@ export default Vue.extend({
},
computed: {
...mapState(['loading']),
...mapState(['loading', 'profile']),
canManage() {
if (this.$can('lists:manage_all')) {
return true;
}
const list = this.profile.role.lists.find((l) => l.id === this.$props.data.id);
return list && list.permissions.includes('list:manage');
},
},
mounted() {

View file

@ -23,9 +23,8 @@ import (
const (
// UserKey is the key on which the User profile is set on echo handlers.
UserKey = "auth_user"
SessionKey = "auth_session"
UserKey = "auth_user"
SessionKey = "auth_session"
SuperAdminRoleID = 1
)
@ -259,7 +258,7 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
}
}
func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc {
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
return func(c echo.Context) error {
u, ok := c.Get(UserKey).(models.User)
if !ok {
@ -273,8 +272,19 @@ func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc {
}
// Check if the current handler's permission is in the user's permission map.
if _, ok := u.PermissionsMap[perm]; !ok {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied (%s)", perm))
var (
has = false
perm = ""
)
for _, perm = range perms {
if _, ok := u.PermissionsMap[perm]; ok {
has = true
break
}
}
if !has {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
}
return next(c)

View file

@ -10,10 +10,10 @@ import (
)
// GetLists gets all lists optionally filtered by type.
func (c *Core) GetLists(typ string) ([]models.List, error) {
func (c *Core) GetLists(typ string, permittedIDs []int) ([]models.List, error) {
out := []models.List{}
if err := c.q.GetLists.Select(&out, typ, "id"); err != nil {
if err := c.q.GetLists.Select(&out, typ, "id", pq.Array(permittedIDs)); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
@ -36,7 +36,7 @@ func (c *Core) GetLists(typ string) ([]models.List, error) {
// QueryLists gets multiple lists based on multiple query params. Along with the paginated and sliced
// results, the total number of lists in the DB is returned.
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, offset, limit int) ([]models.List, int, error) {
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
_ = c.refreshCache(matListSubStats, false)
if tags == nil {
@ -47,7 +47,7 @@ func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy,
out = []models.List{}
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
)
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), offset, limit); err != nil {
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), pq.Array(permittedIDs), offset, limit); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
@ -82,7 +82,7 @@ func (c *Core) GetList(id int, uuid string) (models.List, error) {
var res []models.List
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, nil)
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, 0, 1); err != nil {
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, nil, 0, 1); err != nil {
c.log.Printf("error fetching lists: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))

View file

@ -184,6 +184,14 @@ func (c *Core) getUsers(id int, username, email string) ([]models.User, error) {
for _, perm := range p.Permissions {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == "list:get" {
u.GetListIDs = append(u.GetListIDs, p.ID)
}
if perm == "list:manage" {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
}
}

View file

@ -175,6 +175,8 @@ type User struct {
PermissionsMap map[string]struct{} `db:"-" json:"-"`
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
GetListIDs []int `db:"-" json:"-"`
ManageListIDs []int `db:"-" json:"-"`
HasPassword bool `db:"-" json:"-"`
}

View file

@ -11,6 +11,7 @@
"group": "subscribers",
"permissions":
[
"subscribers:get_by_list",
"subscribers:get",
"subscribers:manage",
"subscribers:import",

View file

@ -1,4 +1,3 @@
-- subscribers
-- name: get-subscriber
-- Get a single subscriber by id or UUID or email.
@ -413,6 +412,10 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
-- lists
-- name: get-lists
SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type END)
AND CASE
-- Optional list IDs based on user permission.
WHEN $3::INT[] IS NULL THEN TRUE ELSE id = ANY($3)
END
ORDER BY CASE WHEN $2 = 'id' THEN id END, CASE WHEN $2 = 'name' THEN name END;
-- name: query-lists
@ -427,7 +430,11 @@ WITH ls AS (
AND ($4 = '' OR type = $4::list_type)
AND ($5 = '' OR optin = $5::list_optin)
AND (CARDINALITY($6::VARCHAR(100)[]) = 0 OR $6 <@ tags)
OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END)
AND CASE
-- Optional list IDs based on user permission.
WHEN $7::INT[] IS NULL THEN TRUE ELSE id = ANY($7)
END
OFFSET $8 LIMIT (CASE WHEN $9 < 1 THEN NULL ELSE $9 END)
),
statuses AS (
SELECT