Introduce per-campaign filter permissions. Closes #2325.

This patch introduces new `campaigns:get_all` and `campaigns:manage_all`
permissions which alter the behaviour of the the old `campaigns:get` and
`campaigns:manage` permissions. This is a subtle breaking behavioural change.

Old:

- `campaigns:get` -> View all campaigns irrespective of a user's list
  permissions.
- `campaigns:manage` -> Manage all campaigns irrespective of a user's list
  permissions.

New:

- `campaigns:get_all` -> View all campaigns irrespective of a user's list
  permissions.
- `campaigns:manage_all` -> Manage all campaigns irrespective of a user's list
  permissions.
- `campaigns:get` -> View only the campaigns that have at least one list to
  which which a user has get or manage access.
- `campaigns:manage` -> Manage only the campaigns that have at list one list
  to which a user has get or manage access.

In addition, this patch refactors and cleans up certain permission related
logic and functions.
This commit is contained in:
Kailash Nadh 2025-03-30 15:25:28 +05:30
parent a5f8b28cb1
commit a271bf54d5
16 changed files with 394 additions and 191 deletions

View file

@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
@ -52,8 +53,9 @@ var (
// handleGetCampaigns handles retrieval of campaigns.
func handleGetCampaigns(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())
status = c.QueryParams()["status"]
tags = c.QueryParams()["tag"]
@ -63,17 +65,31 @@ func handleGetCampaigns(c echo.Context) error {
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
res, total, err := app.core.QueryCampaigns(query, status, tags, orderBy, order, pg.Offset, pg.Limit)
var (
hasAllPerm = user.HasPerm(models.PermCampaignsGetAll)
permittedLists []int
)
if !hasAllPerm {
// Either the user has campaigns:get_all permissions and can view all campaigns,
// or the campaigns are filtered by the lists the user has get|manage access to.
hasAllPerm, permittedLists = user.GetPermittedLists(true, true)
}
// Query and retrieve the campaigns.
res, total, err := app.core.QueryCampaigns(query, status, tags, orderBy, order, hasAllPerm, permittedLists, pg.Offset, pg.Limit)
if err != nil {
return err
}
// Remove the body from the response if requested.
if noBody {
for i := 0; i < len(res); i++ {
for i := range res {
res[i].Body = ""
}
}
// Paginate the response.
var out models.PageResults
if len(res) == 0 {
out.Results = []models.Campaign{}
@ -93,11 +109,22 @@ func handleGetCampaigns(c echo.Context) error {
// handleGetCampaign handles retrieval of campaigns.
func handleGetCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, true, c); err != nil {
return err
}
// Get the campaign from the DB.
out, err := app.core.GetCampaign(id, "", "")
if err != nil {
return err
@ -113,7 +140,8 @@ func handleGetCampaign(c echo.Context) error {
// handlePreviewCampaign renders the HTML preview of a campaign body.
func handlePreviewCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
)
@ -122,6 +150,12 @@ func handlePreviewCampaign(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, true, c); err != nil {
return err
}
// Fetch the campaign body from the DB.
camp, err := app.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
@ -243,6 +277,12 @@ func handleUpdateCampaign(c echo.Context) error {
}
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, false, c); err != nil {
return err
}
// Retrieve the campaign from the DB.
cm, err := app.core.GetCampaign(id, "", "")
if err != nil {
return err
@ -285,20 +325,26 @@ func handleUpdateCampaignStatus(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var o struct {
Status string `json:"status"`
}
if err := c.Bind(&o); err != nil {
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, false, c); err != nil {
return err
}
out, err := app.core.UpdateCampaignStatus(id, o.Status)
req := struct {
Status string `json:"status"`
}{}
if err := c.Bind(&req); err != nil {
return err
}
// Update the campaign status in the DB.
out, err := app.core.UpdateCampaignStatus(id, req.Status)
if err != nil {
return err
}
if o.Status == models.CampaignStatusPaused || o.Status == models.CampaignStatusCancelled {
// If the campaign is being stopped, send the signal to the manager to stop it in flight.
if req.Status == models.CampaignStatusPaused || req.Status == models.CampaignStatusCancelled {
app.manager.StopCampaign(id)
}
@ -312,14 +358,17 @@ func handleUpdateCampaignArchive(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id"))
)
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, false, c); err != nil {
return err
}
req := struct {
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
ArchiveSlug string `json:"archive_slug"`
}{}
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
@ -351,6 +400,12 @@ func handleDeleteCampaign(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, false, c); err != nil {
return err
}
// Delete the campaign from the DB.
if err := app.core.DeleteCampaign(id); err != nil {
return err
}
@ -401,17 +456,23 @@ func handleGetRunningCampaignStats(c echo.Context) error {
// arbitrary subscribers for testing.
func handleTestCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
campID, _ = strconv.Atoi(c.Param("id"))
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
req campaignReq
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
)
if campID < 1 {
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
}
// Check if the user has access to the campaign.
if err := checkCampaignPerm(id, false, c); err != nil {
return err
}
// Get and validate fields.
var req campaignReq
if err := c.Bind(&req); err != nil {
return err
}
@ -437,7 +498,7 @@ func handleTestCampaign(c echo.Context) error {
}
// The campaign.
camp, err := app.core.GetCampaignForPreview(campID, tplID)
camp, err := app.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
}
@ -644,3 +705,41 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
o.Body = b.String()
return o, nil
}
// checkCampaignPerm checks if the user has get or manage access to the given campaign.
func checkCampaignPerm(id int, isGet bool, c echo.Context) error {
var (
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)
)
perm := models.PermCampaignsGet
if isGet {
// It's a get request and there's a blanket get all permission.
if user.HasPerm(models.PermCampaignsGetAll) {
return nil
}
} else {
// It's a manage request and there's a blanket manage_all permission.
if user.HasPerm(models.PermCampaignsManageAll) {
return nil
}
perm = models.PermCampaignsManage
}
// There are no *_all campaign permissions. Instead, check if the user access
// blanket get_all/manage_all list permissions. If yes, then the user can access
// all campaigns. If there are no *_all permissions, then ensure that the
// campaign belongs to the lists that the user has access to.
if hasAllPerm, permittedListIDs := user.GetPermittedLists(true, true); !hasAllPerm {
if ok, err := app.core.CampaignHasLists(id, permittedListIDs); err != nil {
return err
} else if !ok {
return echo.NewHTTPError(http.StatusForbidden,
app.i18n.Ts("globals.messages.permissionDenied", "name", perm))
}
}
return nil
}

View file

@ -138,20 +138,20 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
api.PUT("/api/lists/:id", listPerm(handleUpdateList))
api.DELETE("/api/lists/:id", listPerm(handleDeleteLists))
api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get"))
api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get"))
api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get"))
api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get_all", "campaigns:get"))
api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get_all", "campaigns:get"))
api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get_all", "campaigns:get"))
api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics"))
api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage"))
api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage"))
api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage"))
api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage"))
api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage"))
api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage"))
api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage"))
api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage"))
api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get_all", "campaigns:get"))
api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get_all", "campaigns:get"))
api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage_all", "campaigns:manage"))
api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:get"))
api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage_all", "campaigns:manage"))
api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage_all", "campaigns:manage"))
api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage_all", "campaigns:manage"))
api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage_all", "campaigns:manage"))
api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage_all", "campaigns:manage"))
api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage_all", "campaigns:manage"))
api.GET("/api/media", pm(handleGetMedia, "media:get"))
api.GET("/api/media/:id", pm(handleGetMedia, "media:get"))

View file

@ -1013,7 +1013,7 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) (bool, *auth.Auth) {
Status: models.UserStatusEnabled,
Type: models.UserTypeAPI,
}
u.UserRole.ID = auth.SuperAdminRoleID
u.UserRole.ID = models.SuperAdminRoleID
a.CacheAPIUser(u)
lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`)

View file

@ -28,19 +28,12 @@ func handleGetLists(c echo.Context) error {
out models.PageResults
)
var (
permittedIDs []int
getAll = false
)
if _, ok := user.PermissionsMap[models.PermListGetAll]; ok {
getAll = true
} else {
permittedIDs = user.GetListIDs
}
// Get the list IDs (or blanket permission) the user has access to.
hasAllPerm, permittedIDs := user.GetPermittedLists(true, false)
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
if minimal {
res, err := app.core.GetLists("", getAll, permittedIDs)
res, err := app.core.GetLists("", hasAllPerm, permittedIDs)
if err != nil {
return err
}
@ -58,7 +51,7 @@ func handleGetLists(c echo.Context) error {
}
// Full list query.
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit)
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, hasAllPerm, permittedIDs, pg.Offset, pg.Limit)
if err != nil {
return err
}
@ -73,6 +66,7 @@ func handleGetLists(c echo.Context) error {
}
// handleGetList retrieves a single list by id.
// It's permission checked by the listPerm middleware.
func handleGetList(c echo.Context) error {
var (
app = c.Get("app").(*App)
@ -112,6 +106,7 @@ func handleCreateList(c echo.Context) error {
}
// handleUpdateList handles list modification.
// It's permission checked by the listPerm middleware.
func handleUpdateList(c echo.Context) error {
var (
app = c.Get("app").(*App)
@ -142,6 +137,7 @@ func handleUpdateList(c echo.Context) error {
}
// handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list.
// It's permission checked by the listPerm middleware.
func handleDeleteLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
@ -185,11 +181,12 @@ func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
}
// Check if the user has permissions for all lists or the specific list.
if _, ok := user.PermissionsMap[permAll]; ok {
if user.HasPerm(permAll) {
return next(c)
}
if id > 0 {
if _, ok := user.ListPermissionsMap[id][perm]; ok {
if user.HasListPerm(id, perm) {
return next(c)
}
}

View file

@ -672,7 +672,7 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
// 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.UserRoleID == auth.SuperAdminRoleID {
if u.UserRoleID == models.SuperAdminRoleID {
return nil
}

View file

@ -23,7 +23,7 @@
</div>
<div class="column is-6">
<div v-if="$can('campaigns:manage')" class="buttons">
<div v-if="canManage" class="buttons">
<b-field grouped v-if="isEditing && canEdit">
<b-field expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
@ -157,7 +157,7 @@
</b-field>
</form>
</div>
<div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
<div v-if="canManage" class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">
@ -620,6 +620,10 @@ export default Vue.extend({
computed: {
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
canManage() {
return this.$can('campaigns:manage_all', 'campaigns:manage');
},
canEdit() {
return this.isNew
|| this.data.status === 'draft' || this.data.status === 'scheduled' || this.data.status === 'paused';

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"
SuperAdminRoleID = 1
UserKey = "auth_user"
SessionKey = "auth_session"
)
const (
@ -282,7 +281,7 @@ func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
}
// If the current user is a Super Admin user, do no checks.
if u.UserRole.ID == SuperAdminRoleID {
if u.UserRole.ID == models.SuperAdminRoleID {
return next(c)
}

View file

@ -23,7 +23,7 @@ const (
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
// query expression. It also returns the total number of records in the DB.
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, offset, limit int) (models.Campaigns, int, error) {
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
if statuses == nil {
@ -36,7 +36,7 @@ func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy
// Unsafe to ignore scanning fields not present in models.Campaigns.
var out models.Campaigns
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, offset, limit); err != nil {
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, getAll, pq.Array(permittedLists), offset, limit); err != nil {
c.log.Printf("error fetching campaigns: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
@ -327,6 +327,18 @@ func (c *Core) DeleteCampaign(id int) error {
return nil
}
// CampaignHasLists checks if a campaign has any of the given list IDs.
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
has := false
if err := c.q.CampaignHasLists.Get(&has, id, pq.Array(listIDs)); err != nil {
c.log.Printf("error checking campaign lists: %v", err)
return false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return has, nil
}
// GetRunningCampaignStats returns the progress stats of running campaigns.
func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
out := []models.CampaignStats{}

View file

@ -19,7 +19,7 @@ func (c *Core) GetUsers() ([]models.User, error) {
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return c.formatUsers(out), nil
return c.setupUserFields(out), nil
}
// GetUser retrieves a specific user based on any one given identifier.
@ -36,7 +36,7 @@ func (c *Core) GetUser(id int, username, email string) (models.User, error) {
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return c.formatUsers([]models.User{out})[0], nil
return c.setupUserFields([]models.User{out})[0], nil
}
// CreateUser creates a new user.
@ -152,7 +152,8 @@ func (c *Core) LoginUser(username, password string) (models.User, error) {
return out, nil
}
func (c *Core) formatUsers(users []models.User) []models.User {
// setupUserFields prepares and sets up various user fields.
func (c *Core) setupUserFields(users []models.User) []models.User {
for n, u := range users {
u := u
@ -188,6 +189,7 @@ func (c *Core) formatUsers(users []models.User) []models.User {
u.ListRole = &models.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
// Iterate each list in the list permissions and setup get/manage list IDs.
for _, p := range listPerms {
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
@ -195,10 +197,10 @@ func (c *Core) formatUsers(users []models.User) []models.User {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == "list:get" {
if perm == models.PermListGet {
u.GetListIDs = append(u.GetListIDs, p.ID)
}
if perm == "list:manage" {
if perm == models.PermListManage {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
}

View file

@ -31,6 +31,7 @@ func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
return err
}
// Index of media filename lookup.
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_media_filename ON media(provider, filename);
`); err != nil {
@ -44,5 +45,13 @@ func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
return err
}
// Insert new default super admin permissions.
if _, err := db.Exec(`
UPDATE roles SET permissions = permissions || '{campaigns:get_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:get_all}';
UPDATE roles SET permissions = permissions || '{campaigns:manage_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:manage_all}';
`); err != nil {
return err
}
return nil
}

View file

@ -150,88 +150,6 @@ type Base struct {
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
}
// User represents an admin user.
type User struct {
Base
Username string `db:"username" json:"username"`
// For API users, this is the plaintext API token.
Password null.String `db:"password" json:"password,omitempty"`
PasswordLogin bool `db:"password_login" json:"password_login"`
Email null.String `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
Avatar null.String `db:"avatar" json:"avatar"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
// Role struct {
// ID int `db:"-" json:"id"`
// Name string `db:"-" json:"name"`
// Permissions []string `db:"-" json:"permissions"`
// Lists []ListPermission `db:"-" json:"lists"`
// } `db:"-" json:"role"`
// Filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
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:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
// Subscriber represents an e-mail subscriber.
type Subscriber struct {
Base
@ -816,45 +734,3 @@ func (h Headers) Value() (driver.Value, error) {
return "[]", nil
}
func (u *User) HasPerm(perm string) bool {
_, ok := u.PermissionsMap[perm]
return ok
}
// FilterListsByPerm returns list IDs filtered by either of the given perms.
func (u *User) FilterListsByPerm(listIDs []int, get, manage bool) []int {
// If the user has full list management permission,
// no further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return listIDs
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return listIDs
}
}
out := make([]int, 0, len(listIDs))
// Go through every list ID.
for _, id := range listIDs {
// Check if it exists in the map.
if l, ok := u.ListPermissionsMap[id]; ok {
// Check if any of the given permission exists for it.
if get {
if _, ok := l[PermListGet]; ok {
out = append(out, id)
}
} else if manage {
if _, ok := l[PermListManage]; ok {
out = append(out, id)
}
}
}
}
return out
}

View file

@ -12,8 +12,10 @@ const (
PermSubscribersSqlQuery = "subscribers:sql_query"
PermTxSend = "tx:send"
PermCampaignsGet = "campaigns:get"
PermCampaignsGetAll = "campaigns:get_all"
PermCampaignsGetAnalytics = "campaigns:get_analytics"
PermCampaignsManage = "campaigns:manage"
PermCampaignsManageAll = "campaigns:manage_all"
PermBouncesGet = "bounces:get"
PermBouncesManage = "bounces:manage"
PermWebhooksPostBounce = "webhooks:post_bounce"

View file

@ -65,6 +65,7 @@ type Queries struct {
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
GetArchivedCampaigns *sqlx.Stmt `query:"get-archived-campaigns"`
CampaignHasLists *sqlx.Stmt `query:"campaign-has-lists"`
// These two queries are read as strings and based on settings.individual_tracking=on/off,
// are interpolated and copied to view and click counts. Same query, different tables.

190
models/users.go Normal file
View file

@ -0,0 +1,190 @@
package models
import (
"encoding/json"
"github.com/lib/pq"
null "gopkg.in/volatiletech/null.v6"
)
// SuperAdminRoleID is the database ID of the primordial super admin role.
const SuperAdminRoleID = 1
// User represents an admin user.
type User struct {
Base
Username string `db:"username" json:"username"`
// For API users, this is the plaintext API token.
Password null.String `db:"password" json:"password,omitempty"`
PasswordLogin bool `db:"password_login" json:"password_login"`
Email null.String `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
Avatar null.String `db:"avatar" json:"avatar"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
// Non-DB fields filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
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:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
// HasPerm checks if the user has a specific permission.
func (u *User) HasPerm(perm string) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
_, ok := u.PermissionsMap[perm]
return ok
}
func (u *User) HasListPerm(listID int, perm string) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
if _, ok := u.ListPermissionsMap[listID]; !ok {
return false
}
_, ok := u.ListPermissionsMap[listID][perm]
return ok
}
// GetPermittedLists returns a list of IDs the user has access to based on
// the given get / manage permissions. If the user has the blanket "*_all"
// permission (or the user is a super admin), then the bool is set to true and
// the list is nil as all lists are permitted.
func (u *User) GetPermittedLists(get, manage bool) (bool, []int) {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true, nil
}
// If the user has the list:get_all or list:manage_all permission, no
// further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return true, nil
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return true, nil
}
}
if get {
// If the user has per-list permissions, return that. Otherwise, let the
// 'manage' permission check run.
if len(u.GetListIDs) > 0 {
out := make([]int, len(u.GetListIDs))
copy(out, u.GetListIDs)
return false, out
}
}
if manage {
// User has per-list permissions.
out := make([]int, len(u.ManageListIDs))
copy(out, u.ManageListIDs)
return false, out
}
return false, nil
}
// FilterListsByPerm returns list IDs filtered by either of the given perms.
func (u *User) FilterListsByPerm(listIDs []int, get, manage bool) []int {
// If the user has full list management permission,
// no further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return listIDs
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return listIDs
}
}
out := make([]int, 0, len(listIDs))
// Go through every list ID.
for _, id := range listIDs {
// Check if it exists in the map.
if l, ok := u.ListPermissionsMap[id]; ok {
// Check if any of the given permission exists for it.
if get {
if _, ok := l[PermListGet]; ok {
out = append(out, id)
}
} else if manage {
if _, ok := l[PermListManage]; ok {
out = append(out, id)
}
}
}
}
return out
}

View file

@ -24,8 +24,10 @@
"permissions":
[
"campaigns:get",
"campaigns:get_all",
"campaigns:get_analytics",
"campaigns:manage"
"campaigns:manage",
"campaigns:manage_all"
]
},
{

View file

@ -557,7 +557,13 @@ WHERE ($1 = 0 OR id = $1)
AND (CARDINALITY($2::campaign_status[]) = 0 OR status = ANY($2))
AND (CARDINALITY($3::VARCHAR(100)[]) = 0 OR $3 <@ tags)
AND ($4 = '' OR TO_TSVECTOR(CONCAT(name, ' ', subject)) @@ TO_TSQUERY($4) OR CONCAT(c.name, ' ', c.subject) ILIKE $4)
ORDER BY %order% OFFSET $5 LIMIT (CASE WHEN $6 < 1 THEN NULL ELSE $6 END);
-- Get all campaigns or filter by list IDs.
AND (
$5 OR EXISTS (
SELECT 1 FROM campaign_lists WHERE campaign_id = c.id AND list_id = ANY($6::INT[])
)
)
ORDER BY %order% OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END);
-- name: get-campaign
SELECT campaigns.*,
@ -640,9 +646,13 @@ LEFT JOIN templates ON (templates.id = (CASE WHEN $2=0 THEN campaigns.template_i
WHERE campaigns.id = $1;
-- name: get-campaign-status
SELECT id, status, to_send, sent, started_at, updated_at
FROM campaigns
WHERE status=$1;
SELECT id, status, to_send, sent, started_at, updated_at FROM campaigns WHERE status=$1;
-- name: campaign-has-lists
-- Returns TRUE if the campaign $1 has any of the lists given in $2.
SELECT EXISTS (
SELECT TRUE FROM campaign_lists WHERE campaign_id = $1 AND list_id = ANY($2::INT[])
);
-- name: next-campaigns
-- Retreives campaigns that are running (or scheduled and the time's up) and need