feat: enforce grant-scoped organization summaries and account-wide workspace creation with target membership checks for limit requests

This commit is contained in:
Matthew Meszaros
2026-10-10 10:50:44 +00:00
committed by Devin AI
parent 3ececd2934
commit 8d4e1de4e4
9 changed files with 864 additions and 19 deletions
+54 -3
View File
@@ -105,13 +105,35 @@ func (h *Handler) SwitchOrganization(c *gin.Context) {
})
}
// GetCurrentOrganization returns the current organization from session
type scopedOrganizationCounts struct {
TotalCampaigns int `json:"total_campaigns"`
ActiveCampaigns int `json:"active_campaigns"`
TotalContacts int `json:"total_contacts"`
EmailAccounts int `json:"email_accounts"`
EmailsSentToday int `json:"emails_sent_today"`
}
type scopedOrganizationWithLimits struct {
models.OrganizationWithLimits
// Shadow workspace-only fields so they are absent, not merely empty.
ProductDescription *string `json:"product_description,omitempty"`
ICPNotes *string `json:"icp_notes,omitempty"`
VoiceProfile *string `json:"voice_profile,omitempty"`
Counts scopedOrganizationCounts `json:"counts"`
}
// GetCurrentOrganization returns the current organization from session.
func (h *Handler) GetCurrentOrganization(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
restricted := middleware.IsScopeRestricted(c)
if restricted && middleware.GetResourceScope(c) == nil {
errx.JSON(c, errx.InternalError())
return
}
org, xerr := h.OrganizationService.Get(c.Request.Context(), *orgID)
if xerr != nil {
@@ -120,14 +142,43 @@ func (h *Handler) GetCurrentOrganization(c *gin.Context) {
}
// Get counts and limits
counts, _ := h.OrganizationService.GetOrganizationCounts(c.Request.Context(), *orgID)
limits, _ := h.OrganizationService.GetOrganizationLimits(c.Request.Context(), *orgID)
var counts *models.OrganizationCounts
if restricted {
scope := &models.ResourceScope{Campaigns: middleware.AllowedCampaigns(c), Mailboxes: middleware.AllowedEmailAccounts(c)}
counts, xerr = h.OrganizationService.GetScopedOrganizationCounts(c.Request.Context(), *orgID, scope)
} else {
counts, xerr = h.OrganizationService.GetOrganizationCounts(c.Request.Context(), *orgID)
}
if xerr != nil {
errx.JSON(c, xerr)
return
}
if counts == nil || org == nil {
errx.JSON(c, errx.InternalError())
return
}
limits, xerr := h.OrganizationService.GetOrganizationLimits(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
result := models.OrganizationWithLimits{
Organization: *org,
Limits: limits,
Counts: counts,
}
if restricted {
c.JSON(http.StatusOK, scopedOrganizationWithLimits{
OrganizationWithLimits: result,
Counts: scopedOrganizationCounts{
TotalCampaigns: counts.TotalCampaigns, ActiveCampaigns: counts.ActiveCampaigns,
TotalContacts: counts.TotalContacts, EmailAccounts: counts.EmailAccounts,
EmailsSentToday: counts.EmailsSentToday,
},
})
return
}
c.JSON(http.StatusOK, result)
}
@@ -0,0 +1,320 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/audit"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type currentOrganizationService struct {
organization.OrganizationService
org *models.Organization
counts *models.OrganizationCounts
countsErr *errx.Error
getErr *errx.Error
limits *models.OrganizationLimits
scope *models.ResourceScope
countOrg uuid.UUID
scopedCalls int
wideCalls int
}
func (s *currentOrganizationService) Get(context.Context, uuid.UUID) (*models.Organization, *errx.Error) {
return s.org, s.getErr
}
func (s *currentOrganizationService) GetScopedOrganizationCounts(_ context.Context, org uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, *errx.Error) {
s.scopedCalls++
s.countOrg, s.scope = org, scope
return s.counts, s.countsErr
}
func (s *currentOrganizationService) GetOrganizationCounts(context.Context, uuid.UUID) (*models.OrganizationCounts, *errx.Error) {
s.wideCalls++
return s.counts, s.countsErr
}
func (s *currentOrganizationService) GetOrganizationLimits(context.Context, uuid.UUID) (*models.OrganizationLimits, *errx.Error) {
return s.limits, nil
}
func TestCurrentOrganizationScopesCountsAndRedactsWorkspaceOnlyFields(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, restricted := range []bool{false, true} {
name := "workspace"
if restricted {
name = "restricted"
}
t.Run(name, func(t *testing.T) {
orgID, campaignID, mailboxID := uuid.New(), uuid.New(), uuid.New()
s := &currentOrganizationService{
org: &models.Organization{ID: orgID, Name: "Acme", Timezone: "Europe/Budapest", ProductDescription: "Private product", ICPNotes: "Private ICP", VoiceProfile: "Private voice"},
counts: &models.OrganizationCounts{TotalCampaigns: 3, ActiveCampaigns: 2, TotalContacts: 4, TotalMembers: 99, EmailAccounts: 1, EmailsSentToday: 5},
limits: &models.OrganizationLimits{},
}
h := &Handler{OrganizationService: s}
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(middleware.OrganizationIDKey, orgID)
if restricted {
c.Set(middleware.SessionMemberKey, &models.OrganizationMember{Role: "Viewer", AccessScope: models.AccessScopeRestricted})
c.Set(middleware.ResourceScopeKey, &models.ResourceScope{Campaigns: []uuid.UUID{campaignID}, Mailboxes: []uuid.UUID{mailboxID, uuid.New()}})
c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey)
c.Set(middleware.APIKeyAllowedEmailAccountsKey, []uuid.UUID{mailboxID})
}
})
r.GET("/v1/organization/current", h.GetCurrentOrganization)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/organization/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var body map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
for _, key := range []string{"product_description", "icp_notes", "voice_profile"} {
_, present := body[key]
if present == restricted {
t.Fatalf("%s presence=%v for restricted=%v", key, present, restricted)
}
}
var counts map[string]int
if err := json.Unmarshal(body["counts"], &counts); err != nil {
t.Fatal(err)
}
_, membersPresent := counts["total_members"]
if membersPresent == restricted {
t.Fatalf("member count presence=%v for restricted=%v", membersPresent, restricted)
}
for key, want := range map[string]int{"total_campaigns": 3, "active_campaigns": 2, "total_contacts": 4, "email_accounts": 1, "emails_sent_today": 5} {
if value, present := counts[key]; !present || value != want {
t.Fatalf("%s=%d present=%v, want %d", key, value, present, want)
}
}
for _, key := range []string{"id", "name", "timezone", "limits"} {
if _, present := body[key]; !present {
t.Fatalf("compatible workspace field %s missing", key)
}
}
if restricted {
if s.wideCalls != 0 || s.scopedCalls != 1 || s.countOrg != orgID || len(s.scope.Campaigns) != 1 || s.scope.Campaigns[0] != campaignID || len(s.scope.Mailboxes) != 1 || s.scope.Mailboxes[0] != mailboxID {
t.Fatalf("wrong scoped count query: %+v", s)
}
} else if s.wideCalls != 1 || s.scopedCalls != 0 {
t.Fatalf("unrestricted query changed: %+v", s)
}
})
}
}
func TestCurrentOrganizationRestrictedFailuresDoNotReturnWorkspaceData(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
scope *models.ResourceScope
counts *models.OrganizationCounts
err *errx.Error
}{
{name: "unresolved grants", counts: &models.OrganizationCounts{}},
{name: "count error", scope: &models.ResourceScope{}, err: errx.InternalError()},
{name: "nil counts", scope: &models.ResourceScope{}},
} {
t.Run(tc.name, func(t *testing.T) {
s := &currentOrganizationService{org: &models.Organization{ID: uuid.New(), VoiceProfile: "private"}, counts: tc.counts, countsErr: tc.err}
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(middleware.OrganizationIDKey, s.org.ID)
c.Set(middleware.SessionMemberKey, &models.OrganizationMember{AccessScope: models.AccessScopeRestricted})
if tc.scope != nil {
c.Set(middleware.ResourceScopeKey, tc.scope)
}
})
r.GET("/v1/organization/current", (&Handler{OrganizationService: s}).GetCurrentOrganization)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/organization/current", nil))
if w.Code != http.StatusInternalServerError || strings.Contains(w.Body.String(), "voice_profile") || strings.Contains(w.Body.String(), "total_members") || s.wideCalls != 0 {
t.Fatalf("failure returned workspace data: HTTP %d %s", w.Code, w.Body.String())
}
})
}
}
type creationPolicyRepo struct {
repository.OrganizationRepository
members []models.OrganizationMember
admin uint32
created bool
user uuid.UUID
}
func (r *creationPolicyRepo) GetUserOrganizations(_ context.Context, user uuid.UUID) ([]models.OrganizationMember, error) {
r.user = user
return r.members, nil
}
func (r *creationPolicyRepo) GetUserAdminPermissions(context.Context, uuid.UUID) (uint32, error) {
return r.admin, nil
}
func (r *creationPolicyRepo) Create(context.Context, *models.Organization) error {
r.created = true
return nil
}
func (*creationPolicyRepo) AddMember(context.Context, *models.OrganizationMember) error { return nil }
func (*creationPolicyRepo) CreateRole(context.Context, *models.OrganizationRole) error { return nil }
type creationPolicyUsers struct{ repository.UserRepository }
func (creationPolicyUsers) GetBanState(context.Context, uuid.UUID) (uint32, error) { return 0, nil }
func (creationPolicyUsers) GetUser(_ context.Context, id uuid.UUID) (*models.User, error) {
return &models.User{ID: id, MaxOrganizations: 5}, nil
}
type organizationScopeAudit struct{ audit.AuditService }
func (organizationScopeAudit) LogAction(context.Context, uuid.UUID, uuid.UUID, models.AuditAction, models.AuditEntityType, *uuid.UUID, string, string, map[string]string, map[string]string) {
}
func TestOrganizationCreationPolicyAcrossCredentialAndSelectionContexts(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("DEPLOYMENT_MODE", "self_hosted")
for _, auth := range []string{middleware.AuthTypeJWT, middleware.AuthTypeAPIKey, middleware.AuthTypeOAuth} {
for _, selected := range []bool{false, true} {
for _, admin := range []bool{false, true} {
name := auth + "/unselected/member"
if selected {
name = auth + "/selected/member"
}
if admin {
name += "/admin"
}
t.Run(name, func(t *testing.T) {
user := uuid.New()
repo := &creationPolicyRepo{members: []models.OrganizationMember{{UserID: user, Role: "Viewer", AccessScope: models.AccessScopeRestricted}}}
if admin {
repo.admin = uint32(models.AdminPermViewUsers)
}
service := organization.NewService(repo, nil, creationPolicyUsers{}, nil, nil)
h := &Handler{OrganizationService: service, AuditService: organizationScopeAudit{}}
gate := &middleware.Handler{OrganizationService: service}
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(middleware.AuthTypeKey, auth)
c.Set(middleware.UserIDKey, user.String())
if selected {
c.Set(middleware.OrganizationIDKey, uuid.New())
c.Set(middleware.SessionMemberKey, &repo.members[0])
}
})
r.POST("/v1/organization", gate.ResourceScopeGate(), h.CreateOrganization)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/organization", strings.NewReader(`{"name":"Acme","timezone":"UTC"}`)))
want := http.StatusForbidden
if admin {
want = http.StatusCreated
}
if w.Code != want || repo.created != admin || repo.user != user {
t.Fatalf("HTTP %d %s; want %d created=%v checked creator=%v", w.Code, w.Body.String(), want, repo.created, repo.user == user)
}
})
}
}
}
}
type organizationLimitPolicyRepo struct {
repository.OrganizationRepository
request *models.LimitIncreaseRequest
member *models.OrganizationMember
checkedOrg, checkedUser uuid.UUID
listed, updated bool
}
func (r *organizationLimitPolicyRepo) GetMember(_ context.Context, org, user uuid.UUID) (*models.OrganizationMember, error) {
r.checkedOrg, r.checkedUser = org, user
return r.member, nil
}
func (r *organizationLimitPolicyRepo) GetLimitRequest(context.Context, uuid.UUID) (*models.LimitIncreaseRequest, error) {
return r.request, nil
}
func (r *organizationLimitPolicyRepo) ListLimitRequestsForOrg(context.Context, uuid.UUID) ([]models.LimitIncreaseRequest, error) {
r.listed = true
return []models.LimitIncreaseRequest{*r.request}, nil
}
func (r *organizationLimitPolicyRepo) UpdateLimitRequestStatus(context.Context, uuid.UUID, models.LimitRequestStatus, uuid.UUID, string) error {
r.updated = true
return nil
}
func TestOrganizationLimitHandlersAuthorizeTheTargetNotTheSelectedWorkspace(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, selected := range []string{"none", "workspace", "restricted"} {
for _, targetRestricted := range []bool{false, true} {
for _, operation := range []string{"submit", "list", "cancel"} {
if operation == "submit" && !targetRestricted {
continue
}
name := selected + "/workspace-target/" + operation
if targetRestricted {
name = selected + "/restricted-target/" + operation
}
t.Run(name, func(t *testing.T) {
user, org, selectedOrg := uuid.New(), uuid.New(), uuid.New()
repo := &organizationLimitPolicyRepo{member: &models.OrganizationMember{OrganizationID: org, UserID: user, AccessScope: models.AccessScopeWorkspace}, request: &models.LimitIncreaseRequest{ID: uuid.New(), OrganizationID: org, SubmittedBy: user, Status: models.LimitRequestStatusPending}}
if targetRestricted {
repo.member.AccessScope = models.AccessScopeRestricted
}
service := organization.NewService(repo, nil, nil, nil, nil)
h := &Handler{OrganizationService: service}
gate := &middleware.Handler{OrganizationService: service}
r := gin.New()
r.Use(func(c *gin.Context) {
session := &models.Session{UserID: user}
if selected != "none" {
session.CurrentOrganizationID = &selectedOrg
member := &models.OrganizationMember{OrganizationID: selectedOrg, UserID: user, AccessScope: models.AccessScopeWorkspace}
if selected == "restricted" {
member.AccessScope = models.AccessScopeRestricted
}
c.Set(middleware.OrganizationIDKey, selectedOrg)
c.Set(middleware.SessionMemberKey, member)
}
c.Set(middleware.SessionKey, session)
})
method, route, path := http.MethodGet, "/v1/organization/:orgId/limit-requests", "/v1/organization/"+org.String()+"/limit-requests"
fn, want := h.ListOrgLimitRequests, http.StatusOK
if operation == "cancel" {
method, route, path, fn, want = http.MethodDelete, "/v1/limit-requests/:id", "/v1/limit-requests/"+repo.request.ID.String(), h.CancelLimitRequest, http.StatusNoContent
}
if operation == "submit" {
method, fn = http.MethodPost, h.SubmitLimitIncreaseRequest
}
if targetRestricted {
want = http.StatusForbidden
}
r.Handle(method, route, gate.ResourceScopeGate(), fn)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(`{"field":"max_campaigns","requested":100,"reason":"Additional campaign capacity is needed"}`)))
if w.Code != want || repo.checkedOrg != org || repo.checkedUser != user {
t.Fatalf("HTTP %d %s; want %d checked target=%v caller=%v", w.Code, w.Body.String(), want, repo.checkedOrg == org, repo.checkedUser == user)
}
if targetRestricted && (repo.listed || repo.updated) {
t.Fatal("restricted target reached limit data")
}
})
}
}
}
}
+22 -3
View File
@@ -16,12 +16,19 @@ const MemberAccessRestricted = "member_access_restricted"
var errMemberAccessRestricted = errx.NewWithIdentifier(errx.Forbidden, MemberAccessRestricted,
"Your access to this workspace is limited to selected campaigns and mailboxes, and this is outside it.")
// These services authorize the account or target workspace, not the currently selected membership.
var organizationPolicyRoutes = map[string]bool{
"POST /v1/organization": true,
"POST /v1/organization/:orgId/limit-requests": true,
"GET /v1/organization/:orgId/limit-requests": true,
"DELETE /v1/limit-requests/:id": true,
}
// scopeAwareRoutes is every route a restricted member may call; each one filters to the member's grants.
var scopeAwareRoutes = map[string]bool{
// The member's own account, workspace list and socket.
"GET /v1/me": true,
"GET /v1/organization": true,
"POST /v1/organization": true,
"POST /v1/organization/switch/:id": true,
"GET /v1/organization/current": true,
"GET /v1/me/views/:view": true,
@@ -60,18 +67,25 @@ var scopeAwareRoutes = map[string]bool{
"GET /v1/unibox/:id": true,
}
// ScopeAwareRoutes lists the routes a restricted member may call, for the route-table test.
// ScopeAwareRoutes lists grant-aware and target-authorized routes for the route-table test.
func ScopeAwareRoutes() []string {
out := make([]string, 0, len(scopeAwareRoutes))
out := make([]string, 0, len(scopeAwareRoutes)+len(organizationPolicyRoutes))
for r := range scopeAwareRoutes {
out = append(out, r)
}
for r := range organizationPolicyRoutes {
out = append(out, r)
}
return out
}
// ResourceScopeGate holds a restricted member, and any key or app acting for one, to scope-aware routes and their grants.
func (h *Handler) ResourceScopeGate() gin.HandlerFunc {
return func(c *gin.Context) {
if organizationPolicyRoutes[c.Request.Method+" "+c.FullPath()] {
c.Next()
return
}
m := GetAuthMember(c)
if !m.IsRestricted() {
c.Next()
@@ -93,6 +107,11 @@ func (h *Handler) ResourceScopeGate() gin.HandlerFunc {
c.Abort()
return
}
if scope == nil {
errx.JSON(c, errx.InternalError())
c.Abort()
return
}
c.Set(ResourceScopeKey, scope)
c.Next()
}
+42 -1
View File
@@ -19,11 +19,52 @@ type scopeOrgs struct {
organization.OrganizationService
scope *models.ResourceScope
resolved int
err *errx.Error
}
func (f *scopeOrgs) ResolveMemberScope(context.Context, uuid.UUID, uuid.UUID) (*models.ResourceScope, *errx.Error) {
f.resolved++
return f.scope, nil
return f.scope, f.err
}
func TestResourceScopeGateFailsClosedOnResolutionFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
err *errx.Error
}{
{"nil scope", nil},
{"resolver error", errx.InternalError()},
} {
t.Run(tc.name, func(t *testing.T) {
orgs := &scopeOrgs{err: tc.err}
rec, seen := serveScoped(t, &Handler{OrganizationService: orgs}, restrictedMember(uuid.New()), nil, http.MethodGet, "/v1/organization/current", "/v1/organization/current")
if rec.Code != http.StatusInternalServerError || seen != nil || orgs.resolved != 1 {
t.Fatalf("resolution failure proceeded: HTTP %d seen=%v resolved=%d", rec.Code, seen != nil, orgs.resolved)
}
})
}
}
func TestResourceScopeGateDefersCreationToAccountPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, auth := range []string{AuthTypeJWT, AuthTypeAPIKey, AuthTypeOAuth} {
t.Run(auth, func(t *testing.T) {
orgs := &scopeOrgs{err: errx.InternalError()}
h := &Handler{OrganizationService: orgs}
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(AuthTypeKey, auth)
c.Set(SessionMemberKey, restrictedMember(uuid.New()))
})
r.POST("/v1/organization", h.ResourceScopeGate(), func(c *gin.Context) { c.Status(http.StatusAccepted) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/organization", nil))
if w.Code != http.StatusAccepted || orgs.resolved != 0 {
t.Fatalf("creation was tied to selected member resolution: HTTP %d resolved=%d", w.Code, orgs.resolved)
}
})
}
}
func restrictedMember(orgID uuid.UUID) *models.OrganizationMember {
@@ -18,6 +18,10 @@ type createRepo struct {
owned int
}
func (*createRepo) GetUserOrganizations(context.Context, uuid.UUID) ([]models.OrganizationMember, error) {
return nil, nil
}
func (r *createRepo) GetUserOwnedOrganizationCount(context.Context, uuid.UUID) (int, error) {
return r.owned, nil
}
@@ -0,0 +1,222 @@
package organization
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type workspaceCreationRepo struct {
createRepo
members []models.OrganizationMember
membershipErr error
admin uint32
adminErr error
checkedUser uuid.UUID
adminLookups int
}
func (r *workspaceCreationRepo) GetUserOrganizations(_ context.Context, userID uuid.UUID) ([]models.OrganizationMember, error) {
r.checkedUser = userID
return r.members, r.membershipErr
}
func (r *workspaceCreationRepo) GetUserAdminPermissions(_ context.Context, userID uuid.UUID) (uint32, error) {
if userID != r.checkedUser {
panic("administrator lookup must match account memberships")
}
r.adminLookups++
return r.admin, r.adminErr
}
func TestWorkspaceCreationChecksAllAccountMemberships(t *testing.T) {
restricted := models.OrganizationMember{Role: "Viewer", AccessScope: models.AccessScopeRestricted}
workspace := models.OrganizationMember{Role: "Viewer", AccessScope: models.AccessScopeWorkspace}
for _, mode := range []string{"self_hosted", "cloud"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("DEPLOYMENT_MODE", mode)
for _, tc := range []struct {
name string
members []models.OrganizationMember
admin uint32
want errx.Code
}{
{name: "new account"},
{name: "unrestricted", members: []models.OrganizationMember{workspace}},
{name: "legacy unrestricted", members: []models.OrganizationMember{{Role: "Viewer"}}},
{name: "only restricted", members: []models.OrganizationMember{restricted, restricted}, want: errx.Forbidden},
{name: "mixed memberships", members: []models.OrganizationMember{restricted, workspace}},
{name: "owner is unrestricted", members: []models.OrganizationMember{{Role: string(models.RoleOwner), AccessScope: models.AccessScopeRestricted}}},
{name: "instance admin", members: []models.OrganizationMember{restricted}, admin: uint32(models.AdminPermViewUsers)},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &workspaceCreationRepo{members: tc.members, admin: tc.admin}
s := &organizationService{orgRepo: repo, userRepo: createUsers{}}
userID := uuid.New()
org, xerr := s.Create(context.Background(), userID, "Acme", "UTC")
if repo.checkedUser != userID {
t.Fatal("creation did not check the creator's account memberships")
}
if tc.want != 0 {
if xerr == nil || xerr.Code != tc.want || org != nil || repo.created != nil {
t.Fatalf("restricted creation: org=%+v err=%v written=%+v", org, xerr, repo.created)
}
return
}
if xerr != nil || org == nil || repo.created == nil {
t.Fatalf("eligible creation refused: %v", xerr)
}
})
}
})
}
}
func TestWorkspaceCreationFailsClosedOnPolicyLookupErrors(t *testing.T) {
for _, tc := range []struct {
name string
repo workspaceCreationRepo
}{
{"memberships", workspaceCreationRepo{membershipErr: errors.New("database unavailable")}},
{"admin exemption", workspaceCreationRepo{members: []models.OrganizationMember{{AccessScope: models.AccessScopeRestricted}}, adminErr: errors.New("database unavailable")}},
} {
t.Run(tc.name, func(t *testing.T) {
s := &organizationService{orgRepo: &tc.repo, userRepo: createUsers{}}
org, xerr := s.Create(context.Background(), uuid.New(), "Acme", "UTC")
if xerr == nil || xerr.Code != errx.Internal || org != nil || tc.repo.created != nil {
t.Fatalf("lookup failure did not fail closed: org=%+v err=%v written=%+v", org, xerr, tc.repo.created)
}
})
}
}
type scopedCountsRepo struct {
repository.OrganizationRepository
counts *models.OrganizationCounts
err error
org uuid.UUID
scope *models.ResourceScope
calls int
}
func (r *scopedCountsRepo) GetScopedOrganizationCounts(_ context.Context, org uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, error) {
r.org, r.scope = org, scope
r.calls++
return r.counts, r.err
}
func TestScopedOrganizationCountsRequireResolvedScope(t *testing.T) {
for _, tc := range []struct {
name string
scope *models.ResourceScope
counts *models.OrganizationCounts
err error
fail bool
}{
{name: "unresolved", fail: true},
{name: "no grants", scope: &models.ResourceScope{}, counts: &models.OrganizationCounts{}},
{name: "grants", scope: &models.ResourceScope{Campaigns: []uuid.UUID{uuid.New()}, Mailboxes: []uuid.UUID{uuid.New()}}, counts: &models.OrganizationCounts{TotalCampaigns: 1, EmailAccounts: 1}},
{name: "query failure", scope: &models.ResourceScope{}, err: errors.New("database unavailable"), fail: true},
{name: "missing result", scope: &models.ResourceScope{}, fail: true},
} {
t.Run(tc.name, func(t *testing.T) {
r := &scopedCountsRepo{counts: tc.counts, err: tc.err}
s := &organizationService{orgRepo: r}
orgID := uuid.New()
counts, xerr := s.GetScopedOrganizationCounts(context.Background(), orgID, tc.scope)
if tc.fail {
if counts != nil || xerr == nil || xerr.Code != errx.Internal {
t.Fatalf("counts=%+v err=%v; want fail closed", counts, xerr)
}
} else if xerr != nil || counts != tc.counts || r.scope != tc.scope || r.org != orgID {
t.Fatalf("scope/counts changed: %+v, %v", counts, xerr)
}
if tc.scope == nil && r.calls != 0 {
t.Fatal("unresolved scope reached repository")
}
})
}
}
type targetLimitRepo struct {
repository.OrganizationRepository
member *models.OrganizationMember
memberErr error
org, user uuid.UUID
request *models.LimitIncreaseRequest
listed bool
updated bool
}
func (r *targetLimitRepo) GetMember(_ context.Context, org, user uuid.UUID) (*models.OrganizationMember, error) {
r.org, r.user = org, user
return r.member, r.memberErr
}
func (r *targetLimitRepo) ListLimitRequestsForOrg(context.Context, uuid.UUID) ([]models.LimitIncreaseRequest, error) {
r.listed = true
return []models.LimitIncreaseRequest{*r.request}, nil
}
func (r *targetLimitRepo) GetLimitRequest(context.Context, uuid.UUID) (*models.LimitIncreaseRequest, error) {
return r.request, nil
}
func (r *targetLimitRepo) UpdateLimitRequestStatus(context.Context, uuid.UUID, models.LimitRequestStatus, uuid.UUID, string) error {
r.updated = true
return nil
}
func TestLimitRequestsRequireUnrestrictedTargetMembership(t *testing.T) {
for _, tc := range []struct {
name string
member *models.OrganizationMember
err error
want errx.Code
}{
{name: "restricted target", member: &models.OrganizationMember{AccessScope: models.AccessScopeRestricted}, want: errx.Forbidden},
{name: "nonmember", want: errx.Forbidden},
{name: "lookup failure", err: errors.New("database unavailable"), want: errx.Internal},
{name: "workspace target", member: &models.OrganizationMember{AccessScope: models.AccessScopeWorkspace}},
{name: "owner", member: &models.OrganizationMember{Role: string(models.RoleOwner), AccessScope: models.AccessScopeRestricted}},
} {
for _, operation := range []string{"submit", "list", "cancel"} {
t.Run(tc.name+"/"+operation, func(t *testing.T) {
org, user := uuid.New(), uuid.New()
r := &targetLimitRepo{member: tc.member, memberErr: tc.err, request: &models.LimitIncreaseRequest{
ID: uuid.New(), OrganizationID: org, SubmittedBy: user, Status: models.LimitRequestStatusPending,
}}
s := &organizationService{orgRepo: r}
var xerr *errx.Error
switch operation {
case "submit":
if tc.want == 0 {
// Submission's later entitlement checks are covered separately.
xerr = s.requireWorkspaceMember(context.Background(), org, user)
} else {
_, xerr = s.SubmitLimitIncreaseRequest(context.Background(), org, user, &models.CreateLimitIncreaseRequest{Field: "max_campaigns", Requested: 100})
}
case "list":
_, xerr = s.ListLimitRequestsForOrg(context.Background(), org, user)
case "cancel":
xerr = s.CancelLimitRequest(context.Background(), r.request.ID, user)
}
if r.org != org || r.user != user {
t.Fatalf("checked %s/%s instead of target %s/%s", r.org, r.user, org, user)
}
if tc.want != 0 {
if xerr == nil || xerr.Code != tc.want || r.listed || r.updated {
t.Fatalf("target refused incorrectly: err=%v listed=%v updated=%v", xerr, r.listed, r.updated)
}
} else if xerr != nil || (operation == "list" && !r.listed) || (operation == "cancel" && !r.updated) {
t.Fatalf("unrestricted target refused: %v", xerr)
}
})
}
}
}
+56 -11
View File
@@ -135,6 +135,7 @@ type OrganizationService interface {
GetCampaignCounts(ctx context.Context, orgID uuid.UUID) (total int, active int, err *errx.Error)
GetOrganizationLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error)
GetOrganizationCounts(ctx context.Context, orgID uuid.UUID) (*models.OrganizationCounts, *errx.Error)
GetScopedOrganizationCounts(ctx context.Context, orgID uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, *errx.Error)
// Enterprise inquiries
CreateEnterpriseInquiry(ctx context.Context, inquiry *models.EnterpriseInquiry) (*models.EnterpriseInquiry, *errx.Error)
@@ -295,6 +296,9 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name
if timezone != "" && !tz.Valid(timezone) {
return nil, errx.ErrTimezone
}
if xerr := s.requireWorkspaceCreation(ctx, userID); xerr != nil {
return nil, xerr
}
// Ban-scope enforcement (migration 000045). Block new workspace
// creation when the admin's set the BanScopeOrgCreate bit, even
@@ -660,11 +664,8 @@ func (s *organizationService) GetMembership(ctx context.Context, orgID, userID u
return member, nil
}
// requireMember is the membership gate for handlers that carry an org id in
// the path rather than taking it from the session. GetMembership answers
// (nil, nil) for a non-member, so callers that only test the error let
// everyone through; this is the form that fails closed.
func (s *organizationService) requireMember(ctx context.Context, orgID, userID uuid.UUID) *errx.Error {
// requireWorkspaceMember authorizes workspace-wide operations against the target membership.
func (s *organizationService) requireWorkspaceMember(ctx context.Context, orgID, userID uuid.UUID) *errx.Error {
member, xerr := s.GetMembership(ctx, orgID, userID)
if xerr != nil {
return xerr
@@ -672,9 +673,38 @@ func (s *organizationService) requireMember(ctx context.Context, orgID, userID u
if member == nil {
return errx.New(errx.Forbidden, "not a member of this organization")
}
if member.IsRestricted() {
return errx.NewWithIdentifier(errx.Forbidden, "member_access_restricted", "Your access to this workspace is limited to selected campaigns and mailboxes, and this is outside it.")
}
return nil
}
// requireWorkspaceCreation checks account memberships independently of the selected workspace or credential.
func (s *organizationService) requireWorkspaceCreation(ctx context.Context, userID uuid.UUID) *errx.Error {
members, err := s.orgRepo.GetUserOrganizations(ctx, userID)
if err != nil {
errs.CaptureException(err)
return errx.New(errx.Internal, "failed to load workspace memberships")
}
if len(members) == 0 {
return nil
}
for i := range members {
if !members[i].IsRestricted() {
return nil
}
}
permissions, err := s.orgRepo.GetUserAdminPermissions(ctx, userID)
if err != nil {
errs.CaptureException(err)
return errx.New(errx.Internal, "failed to load administrator permissions")
}
if models.AdminPermission(permissions).IsAdmin() {
return nil
}
return errx.NewWithIdentifier(errx.Forbidden, "member_access_restricted", "Accounts with only restricted workspace memberships cannot create workspaces.")
}
// InviteMember invites a new member to the organization
func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, inviterID uuid.UUID, req *models.InviteMemberRequest) (*models.OrganizationInvitation, *errx.Error) {
// Refuse to mint an invitation the recipient could never redeem. With
@@ -1374,6 +1404,21 @@ func (s *organizationService) GetOrganizationCounts(ctx context.Context, orgID u
}, nil
}
// GetScopedOrganizationCounts reports only granted campaigns, their contacts and granted mailbox sends.
func (s *organizationService) GetScopedOrganizationCounts(ctx context.Context, orgID uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, *errx.Error) {
if scope == nil {
return nil, errx.New(errx.Internal, "member access was not resolved")
}
counts, err := s.orgRepo.GetScopedOrganizationCounts(ctx, orgID, scope)
if err != nil || counts == nil {
if err != nil {
errs.CaptureException(err)
}
return nil, errx.New(errx.Internal, "failed to get scoped organization counts")
}
return counts, nil
}
// CreateEnterpriseInquiry creates an enterprise inquiry
func (s *organizationService) CreateEnterpriseInquiry(ctx context.Context, inquiry *models.EnterpriseInquiry) (*models.EnterpriseInquiry, *errx.Error) {
inquiry.ID = uuid.New()
@@ -1667,11 +1712,8 @@ func (s *organizationService) SubmitLimitIncreaseRequest(ctx context.Context, or
return nil, errx.New(errx.BadRequest, "requested value must be positive")
}
// Membership check — only members of the org can submit requests
// on its behalf. Owner check happens at the per-org-permission
// layer for org-config writes; for limit requests any active
// member is acceptable.
if xerr := s.requireMember(ctx, orgID, submitterID); xerr != nil {
// Limit requests require workspace-wide access to the target organization.
if xerr := s.requireWorkspaceMember(ctx, orgID, submitterID); xerr != nil {
return nil, xerr
}
@@ -1740,7 +1782,7 @@ func (s *organizationService) SubmitLimitIncreaseRequest(ctx context.Context, or
}
func (s *organizationService) ListLimitRequestsForOrg(ctx context.Context, orgID, requesterID uuid.UUID) ([]models.LimitIncreaseRequest, *errx.Error) {
if xerr := s.requireMember(ctx, orgID, requesterID); xerr != nil {
if xerr := s.requireWorkspaceMember(ctx, orgID, requesterID); xerr != nil {
return nil, xerr
}
rows, err := s.orgRepo.ListLimitRequestsForOrg(ctx, orgID)
@@ -1766,6 +1808,9 @@ func (s *organizationService) CancelLimitRequest(ctx context.Context, id, userID
if lr.SubmittedBy != userID {
return errx.ErrForbidden
}
if xerr := s.requireWorkspaceMember(ctx, lr.OrganizationID, userID); xerr != nil {
return xerr
}
if lr.Status != models.LimitRequestStatusPending {
return errx.New(errx.BadRequest, "only pending requests can be cancelled")
}
@@ -0,0 +1,109 @@
package repository
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveOrganizationScopedCountsIntersectGrantsAndOwnership(t *testing.T) {
_, pool := liveContactDB(t)
ctx := context.Background()
user, org, foreignOrg := uuid.New(), uuid.New(), uuid.New()
active, paused, outside, foreign := uuid.New(), uuid.New(), uuid.New(), uuid.New()
mailbox, deniedMailbox, foreignMailbox := uuid.New(), uuid.New(), uuid.New()
sharedContact, scopedContact, outsideContact, foreignContact := uuid.New(), uuid.New(), uuid.New(), uuid.New()
orgs := []uuid.UUID{org, foreignOrg}
mailboxes := []uuid.UUID{mailbox, deniedMailbox, foreignMailbox}
exec := func(query string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, query, args...); err != nil {
t.Fatalf("fixture: %v", err)
}
}
t.Cleanup(func() {
for _, step := range []struct {
query string
arg any
}{
{`DELETE FROM campaigns WHERE organization_id = ANY($1)`, orgs},
{`DELETE FROM tasks WHERE email_account_id = ANY($1)`, mailboxes},
{`DELETE FROM contacts WHERE organization_id = ANY($1)`, orgs},
{`DELETE FROM email_accounts WHERE id = ANY($1)`, mailboxes},
{`DELETE FROM organizations WHERE id = ANY($1)`, orgs},
{`DELETE FROM users WHERE id = $1`, user},
} {
if _, err := pool.Exec(context.Background(), step.query, step.arg); err != nil {
t.Errorf("cleanup: %v", err)
}
}
})
exec(`INSERT INTO users(id, first_name, last_name, email) VALUES($1, 'Counts', 'Scope', $2)`, user, user.String()+"@example.test")
exec(`INSERT INTO organizations(id, name, owner_user_id) VALUES($1, 'Scope', $3), ($2, 'Foreign', $3)`, org, foreignOrg, user)
for _, c := range []struct {
id, org uuid.UUID
status string
}{
{active, org, "active"}, {paused, org, "paused"}, {outside, org, "active"}, {foreign, foreignOrg, "active"},
} {
exec(`INSERT INTO campaigns(id, user_id, organization_id, name, description, days, status, created_at, updated_at) VALUES($1, $2, $3, 'Counts', '', 62, $4, NOW(), NOW())`, c.id, user, c.org, c.status)
}
for _, m := range []struct{ id, org uuid.UUID }{{mailbox, org}, {deniedMailbox, org}, {foreignMailbox, foreignOrg}} {
exec(`INSERT INTO email_accounts(id, user_id, organization_id, email, name, signature_plain, signature_html, provider) VALUES($1, $2, $3, $4, 'Scope', '', '', 'smtp_imap')`, m.id, user, m.org, m.id.String()+"@example.test")
}
for _, c := range []struct{ id, org uuid.UUID }{{sharedContact, org}, {scopedContact, org}, {outsideContact, org}, {foreignContact, foreignOrg}} {
exec(`INSERT INTO contacts(id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, created_at, updated_at) VALUES($1, $2, $3, $4, 'Lead', '', '', '', '{}', NOW(), NOW())`, c.id, user, c.org, c.id.String()+"@example.test")
}
exec(`INSERT INTO campaign_leads(campaign_id, contact_id, position) VALUES($1, $5, 0), ($2, $5, 0), ($1, $6, 1), ($3, $7, 0), ($4, $8, 0)`, active, paused, outside, foreign, sharedContact, scopedContact, outsideContact, foreignContact)
for _, send := range []struct {
campaign, mailbox uuid.UUID
status, message, kind string
days int
}{
{active, mailbox, "completed", "<active@example.test>", "campaign", 0},
{paused, mailbox, "completed", "<paused@example.test>", "campaign", 0},
{active, deniedMailbox, "completed", "<denied-mailbox@example.test>", "campaign", 0},
{outside, mailbox, "completed", "<denied-campaign@example.test>", "campaign", 0},
{foreign, foreignMailbox, "completed", "<foreign@example.test>", "campaign", 0},
{active, mailbox, "completed", "<yesterday@example.test>", "campaign", -1},
{active, mailbox, "pending", "<pending@example.test>", "campaign", 0},
{active, mailbox, "completed", "", "campaign", 0},
{active, mailbox, "completed", "<warmup@example.test>", "warmup", 0},
} {
id := uuid.New()
exec(`INSERT INTO tasks(id, task_type, email_account_id, status, message_id, completed_at) VALUES($1, $2, $3, $4, $5, NOW()+$6::int*INTERVAL '1 day')`, id, send.kind, send.mailbox, send.status, send.message, send.days)
exec(`INSERT INTO campaign_tasks(task_id, campaign_id) VALUES($1, $2)`, id, send.campaign)
}
step, dispatch := uuid.New(), uuid.New()
exec(`INSERT INTO sequences(id, campaign_id, organization_id, name, subject, body_plain, body_html, wait_after, position, kind) VALUES($1, $2, $3, 'Email', '', '', '', 0, 1, 'email')`, step, active, org)
exec(`INSERT INTO tasks(id, task_type, email_account_id, status, message_id, completed_at) VALUES($1, 'campaign', $2, 'completed', '', NOW())`, dispatch, mailbox)
exec(`INSERT INTO campaign_tasks(task_id, campaign_id) VALUES($1, $2)`, dispatch, active)
exec(`INSERT INTO campaign_contact_progress(campaign_id, contact_id, sequence_id, dispatch_task_id, dispatched_at) VALUES($1, $2, $3, $4, NOW())`, active, scopedContact, step, dispatch)
r := NewOrganizationRepository(pool)
for _, tc := range []struct {
name string
scope *models.ResourceScope
want models.OrganizationCounts
}{
{"direct and foreign IDs", &models.ResourceScope{Campaigns: []uuid.UUID{active, paused, active, foreign}, Mailboxes: []uuid.UUID{mailbox, foreignMailbox}}, models.OrganizationCounts{TotalCampaigns: 2, ActiveCampaigns: 1, TotalContacts: 2, EmailAccounts: 1, EmailsSentToday: 3}},
{"nil grant lists", &models.ResourceScope{}, models.OrganizationCounts{}},
{"empty grant lists", &models.ResourceScope{Campaigns: []uuid.UUID{}, Mailboxes: []uuid.UUID{}}, models.OrganizationCounts{}},
{"campaigns only", &models.ResourceScope{Campaigns: []uuid.UUID{active}}, models.OrganizationCounts{TotalCampaigns: 1, ActiveCampaigns: 1, TotalContacts: 2}},
{"mailboxes only", &models.ResourceScope{Mailboxes: []uuid.UUID{mailbox}}, models.OrganizationCounts{EmailAccounts: 1}},
{"foreign grants", &models.ResourceScope{Campaigns: []uuid.UUID{foreign}, Mailboxes: []uuid.UUID{foreignMailbox}}, models.OrganizationCounts{}},
} {
t.Run(tc.name, func(t *testing.T) {
counts, err := r.GetScopedOrganizationCounts(ctx, org, tc.scope)
if err != nil || counts == nil || *counts != tc.want {
t.Fatalf("counts=%+v err=%v; want %+v", counts, err, tc.want)
}
})
}
if counts, err := r.GetScopedOrganizationCounts(ctx, org, nil); err == nil || counts != nil {
t.Fatalf("unresolved scope returned %+v err=%v", counts, err)
}
}
+35 -1
View File
@@ -94,6 +94,7 @@ type OrganizationRepository interface {
GetEmailAccountCount(ctx context.Context, orgID uuid.UUID) (int, error)
GetContactCount(ctx context.Context, orgID uuid.UUID) (int, error)
GetEmailsSentTodayCount(ctx context.Context, orgID uuid.UUID) (int, error)
GetScopedOrganizationCounts(ctx context.Context, orgID uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, error)
// Ownership counts
GetUserOwnedOrganizationCount(ctx context.Context, userID uuid.UUID) (int, error)
@@ -276,7 +277,7 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI
m.Organization = &org
members = append(members, m)
}
return members, nil
return members, rows.Err()
}
// GetUserDefaultOrganization retrieves the first organization a user owns
@@ -674,6 +675,39 @@ func (r *organizationRepository) GetCampaignCounts(ctx context.Context, orgID uu
return
}
// GetScopedOrganizationCounts intersects every aggregate with grants and organization ownership.
func (r *organizationRepository) GetScopedOrganizationCounts(ctx context.Context, orgID uuid.UUID, scope *models.ResourceScope) (*models.OrganizationCounts, error) {
if scope == nil {
return nil, errors.New("member access was not resolved")
}
counts := &models.OrganizationCounts{}
err := r.db.QueryRow(ctx, `
WITH campaigns_in_scope AS (
SELECT id, status FROM campaigns WHERE organization_id = $1 AND id = ANY($2::uuid[])
), mailboxes_in_scope AS (
SELECT id FROM email_accounts WHERE organization_id = $1 AND id = ANY($3::uuid[])
)
SELECT
(SELECT COUNT(*) FROM campaigns_in_scope),
(SELECT COUNT(*) FROM campaigns_in_scope WHERE status = 'active'),
(SELECT COUNT(*) FROM contacts c WHERE c.organization_id = $1 AND EXISTS (
SELECT 1 FROM campaign_leads cl JOIN campaigns_in_scope cs ON cs.id = cl.campaign_id
WHERE cl.contact_id = c.id)),
(SELECT COUNT(*) FROM mailboxes_in_scope),
(SELECT COUNT(*) FROM tasks t
JOIN campaign_tasks ct ON ct.task_id = t.id
JOIN campaigns_in_scope cs ON cs.id = ct.campaign_id
JOIN mailboxes_in_scope ms ON ms.id = t.email_account_id
WHERE `+campaignSendToday+`)
`, orgID, nonNilUUIDs(scope.Campaigns), nonNilUUIDs(scope.Mailboxes)).Scan(
&counts.TotalCampaigns, &counts.ActiveCampaigns, &counts.TotalContacts,
&counts.EmailAccounts, &counts.EmailsSentToday)
if err != nil {
return nil, err
}
return counts, nil
}
// GetMemberCounts returns the member count for an organization
func (r *organizationRepository) GetMemberCounts(ctx context.Context, orgID uuid.UUID) (int, error) {
var count int