Skip to content

Commit

Permalink
Add list permission check to subscriber calls.
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Oct 13, 2024
1 parent d74e067 commit 12a6451
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 39 deletions.
23 changes: 12 additions & 11 deletions cmd/lists.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"fmt"
"net/http"
"strconv"
"strings"
Expand All @@ -11,10 +10,6 @@ import (
"github.com/labstack/echo/v4"
)

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 (
Expand All @@ -33,9 +28,14 @@ func handleGetLists(c echo.Context) error {
out models.PageResults
)

var permittedIDs []int
if _, ok := user.PermissionsMap["lists:get_all"]; !ok {
permittedIDs = user.GetListIDs
}

// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
if minimal {
res, err := app.core.GetLists("", user.GetListIDs)
res, err := app.core.GetLists("", permittedIDs)
if err != nil {
return err
}
Expand All @@ -53,7 +53,7 @@ func handleGetLists(c echo.Context) error {
}

// Full list query.
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, user.GetListIDs, pg.Offset, pg.Limit)
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, permittedIDs, pg.Offset, pg.Limit)
if err != nil {
return err
}
Expand Down Expand Up @@ -164,7 +164,8 @@ func handleDeleteLists(c echo.Context) error {
func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u = c.Get(auth.UserKey).(models.User)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)
id, _ = strconv.Atoi(c.Param("id"))
)

Expand All @@ -179,15 +180,15 @@ func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
}

// Check if the user has permissions for all lists or the specific list.
if _, ok := u.PermissionsMap[permAll]; ok {
if _, ok := user.PermissionsMap[permAll]; ok {
return next(c)
}
if id > 0 {
if _, ok := u.ListPermissionsMap[id][perm]; ok {
if _, ok := user.ListPermissionsMap[id][perm]; ok {
return next(c)
}
}

return errListPerm
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.globals.messages.permissionDenied", "name", "list"))
}
}
5 changes: 3 additions & 2 deletions cmd/roles.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -115,14 +116,14 @@ func validateRole(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 echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("permission: %s", p)))
}
}

for _, l := range r.Lists {
for _, p := range l.Permissions {
if p != "list:get" && p != "list:manage" {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "list permissions"))
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("list permission: %s", p)))
}
}
}
Expand Down
135 changes: 114 additions & 21 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -68,12 +69,17 @@ func handleGetSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
user = c.Get(auth.UserKey).(models.User)
)

if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}

if err := hasSubPerm(user, []int{id}, app); err != nil {
return err
}

out, err := app.core.GetSubscriber(id, "", "")
if err != nil {
return err
Expand All @@ -85,8 +91,9 @@ func handleGetSubscriber(c echo.Context) error {
// handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression.
func handleQuerySubscribers(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())

// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
Expand All @@ -96,10 +103,10 @@ func handleQuerySubscribers(c echo.Context) error {
out models.PageResults
)

// Limit the subscribers to specific lists?
listIDs, err := getQueryInts("list_id", c.QueryParams())
// Filter list IDs by permission.
listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
return err
}

res, total, err := app.core.QuerySubscribers(query, listIDs, subStatus, order, orderBy, pg.Offset, pg.Limit)
Expand All @@ -119,16 +126,17 @@ func handleQuerySubscribers(c echo.Context) error {
// handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression.
func handleExportSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)

// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
)

// Limit the subscribers to specific lists?
listIDs, err := getQueryInts("list_id", c.QueryParams())
// Filter list IDs by permission.
listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
return err
}

// Export only specific subscriber IDs?
Expand Down Expand Up @@ -187,7 +195,9 @@ loop:
// handleCreateSubscriber handles the creation of a new subscriber.
func handleCreateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)

req subimporter.SubReq
)

Expand All @@ -202,8 +212,11 @@ func handleCreateSubscriber(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}

// Filter lists against the current user's permitted lists.
listIDs := filterListsByPerm(req.Lists, user)

// Insert the subscriber into the DB.
sub, _, err := app.core.InsertSubscriber(req.Subscriber, req.Lists, req.ListUUIDs, req.PreconfirmSubs)
sub, _, err := app.core.InsertSubscriber(req.Subscriber, listIDs, nil, req.PreconfirmSubs)
if err != nil {
return err
}
Expand All @@ -214,7 +227,9 @@ func handleCreateSubscriber(c echo.Context) error {
// handleUpdateSubscriber handles modification of a subscriber.
func handleUpdateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)

id, _ = strconv.Atoi(c.Param("id"))
req struct {
models.Subscriber
Expand Down Expand Up @@ -242,7 +257,10 @@ func handleUpdateSubscriber(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}

out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true)
// Filter lists against the current user's permitted lists.
listIDs := filterListsByPerm(req.Lists, user)

out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, listIDs, nil, req.PreconfirmSubs, true)
if err != nil {
return err
}
Expand Down Expand Up @@ -318,7 +336,9 @@ func handleBlocklistSubscribers(c echo.Context) error {
// It takes either an ID in the URI, or a list of IDs in the request body.
func handleManageSubscriberLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)

pID = c.Param("id")
subIDs []int
)
Expand Down Expand Up @@ -347,15 +367,18 @@ func handleManageSubscriberLists(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
}

// Filter lists against the current user's permitted lists.
listIDs := filterListsByPerm(req.TargetListIDs, user)

// Action.
var err error
switch req.Action {
case "add":
err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status)
err = app.core.AddSubscriptions(subIDs, listIDs, req.Status)
case "remove":
err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
err = app.core.DeleteSubscriptions(subIDs, listIDs)
case "unsubscribe":
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil)
err = app.core.UnsubscribeLists(subIDs, listIDs, nil)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
Expand Down Expand Up @@ -446,7 +469,9 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
// from one or more lists based on an arbitrary SQL expression.
func handleManageSubscriberListsByQuery(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)

req subQueryReq
)

Expand All @@ -458,15 +483,19 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
app.i18n.T("subscribers.errorNoListsGiven"))
}

// Filter lists against the current user's permitted lists.
sourceListIDs := filterListsByPerm(req.ListIDs, user)
targetListIDs := filterListsByPerm(req.TargetListIDs, user)

// Action.
var err error
switch req.Action {
case "add":
err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.Status, req.SubscriptionStatus)
err = app.core.AddSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.Status, req.SubscriptionStatus)
case "remove":
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus)
err = app.core.DeleteSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus)
case "unsubscribe":
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus)
err = app.core.UnsubscribeListsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
Expand Down Expand Up @@ -632,3 +661,67 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
return len(lists), nil
}
}

// hasSubPerm checks whether the current user has permission to access the given list
// of subscriber IDs.
func hasSubPerm(u models.User, subIDs []int, app *App) error {
if u.RoleID == auth.SuperAdminRoleID {
return nil
}

if _, ok := u.PermissionsMap["subscribers:get_all"]; ok {
return nil
}

res, err := app.core.HasSubscriberLists(subIDs, u.GetListIDs)
if err != nil {
return err
}

for id, has := range res {
if !has {
return echo.NewHTTPError(http.StatusForbidden, app.i18n.Ts("globals.messages.permissionDenied", "name", fmt.Sprintf("subscriber: %d", id)))
}
}

return nil
}

func filterListQeryByPerm(qp url.Values, user models.User, app *App) ([]int, error) {
var listIDs []int

// If there are incoming list query params, filter them by permission.
if qp.Has("list_id") {
ids, err := getQueryInts("list_id", qp)
if err != nil {
return nil, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}

listIDs = []int{}
for _, id := range ids {
if _, ok := user.ListPermissionsMap[id]; ok {
listIDs = append(listIDs, id)
}
}
} else {
// There are no incoming params. If the user doesn't have permission to get all subscribers,
// filter by the lists they have access to.
if _, ok := user.PermissionsMap["subscribers:get_all"]; !ok {
listIDs = user.GetListIDs
}
}

return listIDs, nil
}

// filterListsByPerm filters the given list IDs against the given user's permitted lists.
func filterListsByPerm(listIDs []int, user models.User) []int {
out := make([]int, 0, len(listIDs))
for _, id := range listIDs {
if _, ok := user.ListPermissionsMap[id]; ok {
listIDs = append(listIDs, id)
}
}

return out
}
2 changes: 1 addition & 1 deletion frontend/src/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<b-menu-item v-if="$can('subscribers:*')" :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
data-cy="subscribers" @update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
:label="$t('globals.terms.subscribers')">
<b-menu-item v-if="$can('subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
<b-menu-item v-if="$can('subscribers:get_all', 'subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
:active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
:label="$t('menu.allSubscribers')" />
<b-menu-item v-if="$can('subscribers:import')" :to="{ name: 'import' }" tag="router-link"
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/views/Lists.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,15 @@

<b-table-column v-slot="props" field="subscriber_count" :label="$t('globals.terms.subscribers')"
header-class="cy-subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
<template v-if="$can('subscribers:get_all', 'subscribers:get')">
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ $utils.formatNumber(props.row.subscriberCount) }}
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
</router-link>
</template>
<template v-else>
{{ $utils.formatNumber(props.row.subscriberCount) }}
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
</router-link>
</template>
</b-table-column>

<b-table-column v-slot="props" field="subscriber_counts" header-class="cy-subscribers" width="10%">
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
"globals.messages.errorCreating": "Error creating {name}: {error}",
"globals.messages.errorDeleting": "Error deleting {name}: {error}",
"globals.messages.errorFetching": "Error fetching {name}: {error}",
"globals.messages.permissionDenied": "Permission denied: {name}",
"globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
"globals.messages.errorUUID": "Error generating UUID: {error}",
"globals.messages.errorUpdating": "Error updating {name}: {error}",
Expand Down
Loading

0 comments on commit 12a6451

Please sign in to comment.