diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 0514dd79..69d37820 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -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 +} diff --git a/cmd/handlers.go b/cmd/handlers.go index c123325a..6ee1944b 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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")) diff --git a/cmd/init.go b/cmd/init.go index 50932504..82caea42 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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.`) diff --git a/cmd/lists.go b/cmd/lists.go index 8b84da00..a3bd7af0 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -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) } } diff --git a/cmd/subscribers.go b/cmd/subscribers.go index ef9983ab..1a25eb59 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -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 } diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index b37df27b..7bcd7cd8 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -23,7 +23,7 @@
-
+
-
+

@@ -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'; diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8b9a7ab0..bbab01d4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) } diff --git a/internal/core/campaigns.go b/internal/core/campaigns.go index c586ae23..32b17902 100644 --- a/internal/core/campaigns.go +++ b/internal/core/campaigns.go @@ -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{} diff --git a/internal/core/users.go b/internal/core/users.go index 5df6353d..a2c64570 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -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) } } diff --git a/internal/migrations/v5.0.0.go b/internal/migrations/v5.0.0.go index 7b3a450b..ad799357 100644 --- a/internal/migrations/v5.0.0.go +++ b/internal/migrations/v5.0.0.go @@ -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 } diff --git a/models/models.go b/models/models.go index 42604771..989de718 100644 --- a/models/models.go +++ b/models/models.go @@ -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 -} diff --git a/models/permissions.go b/models/permissions.go index 8184e143..b2fdd0fe 100644 --- a/models/permissions.go +++ b/models/permissions.go @@ -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" diff --git a/models/queries.go b/models/queries.go index c6b1b99f..cc3c86ac 100644 --- a/models/queries.go +++ b/models/queries.go @@ -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. diff --git a/models/users.go b/models/users.go new file mode 100644 index 00000000..8aabd905 --- /dev/null +++ b/models/users.go @@ -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 +} diff --git a/permissions.json b/permissions.json index 2f86958c..dbdbd432 100644 --- a/permissions.json +++ b/permissions.json @@ -24,8 +24,10 @@ "permissions": [ "campaigns:get", + "campaigns:get_all", "campaigns:get_analytics", - "campaigns:manage" + "campaigns:manage", + "campaigns:manage_all" ] }, { diff --git a/queries.sql b/queries.sql index a0ce3341..03faf9c3 100644 --- a/queries.sql +++ b/queries.sql @@ -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