mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 08:02:42 +00:00
feat: enforce grant-scoped organization summaries and account-wide workspace creation with target membership checks for limit requests
This commit is contained in:
committed by
Devin AI
parent
3ececd2934
commit
8d4e1de4e4
@@ -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 := ¤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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user