mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
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:
parent
982e8d8320
commit
d74e067961
12 changed files with 125 additions and 58 deletions
91
cmd/lists.go
91
cmd/lists.go
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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:"-"`
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"group": "subscribers",
|
||||
"permissions":
|
||||
[
|
||||
"subscribers:get_by_list",
|
||||
"subscribers:get",
|
||||
"subscribers:manage",
|
||||
"subscribers:import",
|
||||
|
|
11
queries.sql
11
queries.sql
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue