feat: scope the tag, category and folder registries plus unibox conversation labels to the organization instead of the creating user (migration 000147), so a teammate sees and can edit the labels the owner made (#436), and guard every label write in email/campaign/contact/form/unibox repositories against ids belonging to another workspace

This commit is contained in:
Matthew Meszaros
2026-09-12 03:30:36 -07:00
parent 5869a2148f
commit ade86d1b86
60 changed files with 2332 additions and 457 deletions
+21 -13
View File
@@ -10,6 +10,7 @@ import (
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/auth"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
const authRequestTimeout = 15 * time.Second
@@ -151,20 +152,27 @@ func (h *Handler) GetUser(c *gin.Context) {
return
}
// Populate the per-user label groups so the frontend can render
// Populate the workspace's label registries so the frontend can render
// folder/tag pickers on initial page load without three extra
// round-trips. Without this, anything the user created in a
// previous session would disappear after a refresh: the cache
// would optimistic-update from a Create response, but on reload
// the /auth/me payload had empty folders/tags/categories.
if folders, ferr := h.FolderService.List(ctx, uid); ferr == nil {
u.Folders = folders
}
if tags, terr := h.TagService.List(ctx, uid); terr == nil {
u.Tags = tags
}
if cats, cerr := h.CategoryService.List(ctx, uid); cerr == nil {
u.Categories = cats
// round-trips. Without this, anything created in a previous session
// would disappear after a refresh: the cache would optimistic-update
// from a Create response, but on reload the /auth/me payload had empty
// folders/tags/categories.
//
// Scoped to the session's current organization, not the caller: labels
// are workspace assets, so a teammate must see what the owner created
// (issue #436). A session with no workspace selected gets empty lists.
u.Folders, u.Tags, u.Categories = []models.Group{}, []models.Group{}, []models.Group{}
if orgID := middleware.GetOrganizationID(c); orgID != nil {
if folders, ferr := h.FolderService.List(ctx, *orgID); ferr == nil {
u.Folders = folders
}
if tags, terr := h.TagService.List(ctx, *orgID); terr == nil {
u.Tags = tags
}
if cats, cerr := h.CategoryService.List(ctx, *orgID); cerr == nil {
u.Categories = cats
}
}
c.JSON(http.StatusOK, u)
+21 -3
View File
@@ -54,6 +54,11 @@ func (h *Handler) GetEmail(c *gin.Context) {
}
func (h *Handler) UpdateEmail(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
userIDStr := middleware.GetUserID(c)
emailAccountID := c.Param("id")
@@ -65,7 +70,7 @@ func (h *Handler) UpdateEmail(c *gin.Context) {
return
}
resp, err := h.EmailService.Update(c.Request.Context(), userIDStr, emailAccountID, &data)
resp, err := h.EmailService.Update(c.Request.Context(), orgID.String(), userIDStr, emailAccountID, &data)
if err != nil {
errx.Handle(c, err)
return
@@ -84,7 +89,11 @@ func (h *Handler) UpdateEmail(c *gin.Context) {
// are safe without an Idempotency-Key.
// PATCH /emails/tags
func (h *Handler) BulkTagEmails(c *gin.Context) {
userIDStr := middleware.GetUserID(c)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
var data models.BulkEmailTags
if err := c.ShouldBindJSON(&data); err != nil {
@@ -114,6 +123,15 @@ func (h *Handler) BulkTagEmails(c *gin.Context) {
errx.Handle(c, errx.ErrUuid)
return
}
// The mailbox scope here is the workspace, so a restricted API key needs
// the same allowlist check the per-id routes get from
// RequireAPIKeyEmailAccountParam; there is no path param to gate on.
for _, id := range emailIDs {
if !middleware.APIKeyAllowsEmailAccount(c, id) {
errx.Handle(c, errx.New(errx.Forbidden, "email account is not allowed for this API key"))
return
}
}
addTags, ok := parse(data.AddTags)
if !ok {
errx.Handle(c, errx.ErrUuid)
@@ -125,7 +143,7 @@ func (h *Handler) BulkTagEmails(c *gin.Context) {
return
}
updated, err := h.EmailService.BulkUpdateTags(c.Request.Context(), userIDStr, emailIDs, addTags, removeTags)
updated, err := h.EmailService.BulkUpdateTags(c.Request.Context(), orgID.String(), emailIDs, addTags, removeTags)
if err != nil {
errx.Handle(c, err)
return
+36 -38
View File
@@ -14,6 +14,27 @@ func GetGroupID(c *gin.Context) string {
return c.Param("gid")
}
// scope resolves the workspace every group call is keyed on, plus the group id
// on the path for the routes that carry one. Labels belong to the organization,
// not to whoever created them, so a request without one is refused rather than
// run against a single member's rows.
func scope(c *gin.Context, withID bool) (uuid.UUID, uuid.UUID, bool) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return uuid.Nil, uuid.Nil, false
}
if !withID {
return *orgID, uuid.Nil, true
}
gid, err := uuid.Parse(GetGroupID(c))
if err != nil {
errx.Handle(c, errx.ErrUuid)
return uuid.Nil, uuid.Nil, false
}
return *orgID, gid, true
}
// entityType maps the group's name ("folders"/"tags"/"categories") to the
// matching audit entity type.
func (h *Handler) entityType() models.AuditEntityType {
@@ -45,12 +66,13 @@ func (h *Handler) logAudit(c *gin.Context, action models.AuditAction, entityID *
}
func (h *Handler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID, _, ok := scope(c, false)
if !ok {
return
}
// Attribution only; an API key has no human behind it, so a failed parse
// records a nil creator rather than refusing the write.
uid, _ := middleware.GetUserUUID(c)
var data models.GroupCreate
@@ -59,7 +81,7 @@ func (h *Handler) Create(c *gin.Context) {
return
}
group, xerr := h.service.Create(c.Request.Context(), uid, &data)
group, xerr := h.service.Create(c.Request.Context(), orgID, uid, &data)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -71,16 +93,8 @@ func (h *Handler) Create(c *gin.Context) {
}
func (h *Handler) Update(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
@@ -91,7 +105,7 @@ func (h *Handler) Update(c *gin.Context) {
return
}
group, xerr := h.service.Update(c.Request.Context(), uid, gid, &data)
group, xerr := h.service.Update(c.Request.Context(), orgID, gid, &data)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -103,16 +117,8 @@ func (h *Handler) Update(c *gin.Context) {
}
func (h *Handler) Move(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
@@ -123,7 +129,7 @@ func (h *Handler) Move(c *gin.Context) {
return
}
orders, xerr := h.service.Move(c.Request.Context(), uid, gid, data.Position)
orders, xerr := h.service.Move(c.Request.Context(), orgID, gid, data.Position)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -135,20 +141,12 @@ func (h *Handler) Move(c *gin.Context) {
}
func (h *Handler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
if xerr := h.service.Delete(c.Request.Context(), uid, gid); xerr != nil {
if xerr := h.service.Delete(c.Request.Context(), orgID, gid); xerr != nil {
errx.Handle(c, xerr)
return
}
+12 -19
View File
@@ -33,13 +33,6 @@ func (h *Handler) gateUnibox(c *gin.Context) bool {
}
func (h *Handler) GetUniboxIncoming(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
@@ -187,7 +180,7 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
}
}
resp, xerr := h.UniboxService.Search(c.Request.Context(), *orgID, uid, params)
resp, xerr := h.UniboxService.Search(c.Request.Context(), *orgID, params)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -302,10 +295,9 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
@@ -318,7 +310,7 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
return
}
labels, xerr := h.UniboxService.ListThreadLabels(c.Request.Context(), uid, threadID)
labels, xerr := h.UniboxService.ListThreadLabels(c.Request.Context(), *orgID, threadID)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -328,19 +320,20 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
// SetUniboxThreadLabels replaces the full conversation-label set on a
// thread. Idempotent (PUT semantics): the body's category_ids is the
// desired set, so retries are naturally safe. Only the user's own
// desired set, so retries are naturally safe. Only the workspace's own
// categories are attached.
// PUT /unibox/thread/labels
func (h *Handler) SetUniboxThreadLabels(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
// Attribution only; a label an API key applies has no human behind it.
uid, _ := middleware.GetUserUUID(c)
var req models.UniboxThreadLabels
if err := c.ShouldBindJSON(&req); err != nil {
@@ -348,7 +341,7 @@ func (h *Handler) SetUniboxThreadLabels(c *gin.Context) {
return
}
labels, xerr := h.UniboxService.SetThreadLabels(c.Request.Context(), uid, req.ThreadID, req.CategoryIDs)
labels, xerr := h.UniboxService.SetThreadLabels(c.Request.Context(), *orgID, uid, req.ThreadID, req.CategoryIDs)
if xerr != nil {
errx.Handle(c, xerr)
return