From 8d4e1de4e47bf6e6576aac2e85531508e643cdba Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 10 Oct 2026 10:50:08 +0000 Subject: [PATCH] feat: enforce grant-scoped organization summaries and account-wide workspace creation with target membership checks for limit requests --- internal/api/handler/organization.go | 57 +++- .../api/handler/organization_scope_test.go | 320 ++++++++++++++++++ internal/api/middleware/resource_scope.go | 25 +- .../api/middleware/resource_scope_test.go | 43 ++- .../app/organization/create_timezone_test.go | 4 + .../organization/restricted_security_test.go | 222 ++++++++++++ internal/app/organization/service.go | 67 +++- .../organization_scoped_counts_live_test.go | 109 ++++++ internal/repository/pg_organization.go | 36 +- 9 files changed, 864 insertions(+), 19 deletions(-) create mode 100644 internal/api/handler/organization_scope_test.go create mode 100644 internal/app/organization/restricted_security_test.go create mode 100644 internal/repository/organization_scoped_counts_live_test.go diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index 70dd93bc8..c832964bd 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -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) } diff --git a/internal/api/handler/organization_scope_test.go b/internal/api/handler/organization_scope_test.go new file mode 100644 index 000000000..56293b744 --- /dev/null +++ b/internal/api/handler/organization_scope_test.go @@ -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 := ¤tOrganizationService{ + 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 := ¤tOrganizationService{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") + } + }) + } + } + } +} diff --git a/internal/api/middleware/resource_scope.go b/internal/api/middleware/resource_scope.go index 23ac2d6f2..a62626202 100644 --- a/internal/api/middleware/resource_scope.go +++ b/internal/api/middleware/resource_scope.go @@ -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() } diff --git a/internal/api/middleware/resource_scope_test.go b/internal/api/middleware/resource_scope_test.go index 60046ef66..1e0425245 100644 --- a/internal/api/middleware/resource_scope_test.go +++ b/internal/api/middleware/resource_scope_test.go @@ -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 { diff --git a/internal/app/organization/create_timezone_test.go b/internal/app/organization/create_timezone_test.go index 5197367ab..20c7857ef 100644 --- a/internal/app/organization/create_timezone_test.go +++ b/internal/app/organization/create_timezone_test.go @@ -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 } diff --git a/internal/app/organization/restricted_security_test.go b/internal/app/organization/restricted_security_test.go new file mode 100644 index 000000000..0f7f9c876 --- /dev/null +++ b/internal/app/organization/restricted_security_test.go @@ -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) + } + }) + } + } +} diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 4fffc4ba0..702fb1c9c 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -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") } diff --git a/internal/repository/organization_scoped_counts_live_test.go b/internal/repository/organization_scoped_counts_live_test.go new file mode 100644 index 000000000..97ec8c521 --- /dev/null +++ b/internal/repository/organization_scoped_counts_live_test.go @@ -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", "", "campaign", 0}, + {paused, mailbox, "completed", "", "campaign", 0}, + {active, deniedMailbox, "completed", "", "campaign", 0}, + {outside, mailbox, "completed", "", "campaign", 0}, + {foreign, foreignMailbox, "completed", "", "campaign", 0}, + {active, mailbox, "completed", "", "campaign", -1}, + {active, mailbox, "pending", "", "campaign", 0}, + {active, mailbox, "completed", "", "campaign", 0}, + {active, mailbox, "completed", "", "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) + } +} diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 788690a7a..1e9eb84fe 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -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