mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 00:03:42 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user