mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-14 16:02:41 +00:00
206 lines
8.1 KiB
Go
206 lines
8.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/organization"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
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, 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 {
|
|
return &models.OrganizationMember{OrganizationID: orgID, UserID: uuid.New(), Role: "Viewer",
|
|
Permissions: models.RestrictedPermissionMask, AccessScope: models.AccessScopeRestricted}
|
|
}
|
|
|
|
// serveScoped runs one request through the gate as member, with an optional API key allowlist.
|
|
func serveScoped(t *testing.T, h *Handler, member *models.OrganizationMember, keyList []uuid.UUID, method, route, path string, gates ...gin.HandlerFunc) (*httptest.ResponseRecorder, *gin.Context) {
|
|
t.Helper()
|
|
var seen *gin.Context
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
chain := []gin.HandlerFunc{func(c *gin.Context) {
|
|
c.Set(SessionMemberKey, member)
|
|
c.Set(OrganizationIDKey, member.OrganizationID)
|
|
c.Set(UserIDKey, member.UserID.String())
|
|
if keyList != nil {
|
|
c.Set(AuthTypeKey, AuthTypeAPIKey)
|
|
c.Set(APIKeyAllowedEmailAccountsKey, keyList)
|
|
} else {
|
|
c.Set(AuthTypeKey, AuthTypeJWT)
|
|
}
|
|
c.Next()
|
|
}, h.ResourceScopeGate()}
|
|
chain = append(chain, gates...)
|
|
chain = append(chain, func(c *gin.Context) {
|
|
seen = c.Copy()
|
|
c.Status(http.StatusOK)
|
|
})
|
|
r.Handle(method, route, chain...)
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), method, path, nil))
|
|
return rec, seen
|
|
}
|
|
|
|
func TestResourceScopeGateRefusesRoutesTheScopeCannotFilter(t *testing.T) {
|
|
orgID := uuid.New()
|
|
orgs := &scopeOrgs{scope: &models.ResourceScope{}}
|
|
h := &Handler{OrganizationService: orgs}
|
|
member := restrictedMember(orgID)
|
|
|
|
rec, _ := serveScoped(t, h, member, nil, http.MethodPost, "/v1/contacts/search", "/v1/contacts/search")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("contacts search: status %d, want 403", rec.Code)
|
|
}
|
|
var body struct{ Code string }
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if body.Code != MemberAccessRestricted {
|
|
t.Fatalf("code = %q, want %q", body.Code, MemberAccessRestricted)
|
|
}
|
|
// A write on a scope-aware path is still refused: the allowlist is per method.
|
|
if rec, _ := serveScoped(t, h, member, nil, http.MethodPatch, "/v1/campaigns/:id", "/v1/campaigns/"+uuid.NewString()); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("campaign write: status %d, want 403", rec.Code)
|
|
}
|
|
if rec, _ := serveScoped(t, h, member, nil, http.MethodGet, "/v1/campaigns", "/v1/campaigns"); rec.Code != http.StatusOK {
|
|
t.Fatalf("campaign list: status %d, want 200", rec.Code)
|
|
}
|
|
if orgs.resolved != 1 {
|
|
t.Fatalf("scope resolved %d times, want once for the one allowed request", orgs.resolved)
|
|
}
|
|
}
|
|
|
|
func TestResourceScopeGateLeavesWorkspaceMembersAlone(t *testing.T) {
|
|
orgs := &scopeOrgs{}
|
|
h := &Handler{OrganizationService: orgs}
|
|
for _, m := range []*models.OrganizationMember{
|
|
{OrganizationID: uuid.New(), UserID: uuid.New(), Role: "Viewer", AccessScope: models.AccessScopeWorkspace},
|
|
// The owner is never restricted, whatever a stale row says.
|
|
{OrganizationID: uuid.New(), UserID: uuid.New(), Role: string(models.RoleOwner), AccessScope: models.AccessScopeRestricted},
|
|
} {
|
|
rec, seen := serveScoped(t, h, m, nil, http.MethodPost, "/v1/contacts/search", "/v1/contacts/search")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want 200", m.Role, rec.Code)
|
|
}
|
|
if AllowedCampaigns(seen) != nil || AllowedEmailAccounts(seen) != nil {
|
|
t.Fatalf("%s: workspace member got an allowlist", m.Role)
|
|
}
|
|
}
|
|
if orgs.resolved != 0 {
|
|
t.Fatalf("scope resolved for a workspace member")
|
|
}
|
|
}
|
|
|
|
func TestRestrictedMemberAllowlists(t *testing.T) {
|
|
orgID := uuid.New()
|
|
granted, other, keyOnly := uuid.New(), uuid.New(), uuid.New()
|
|
campaign := uuid.New()
|
|
|
|
t.Run("an empty grant matches nothing", func(t *testing.T) {
|
|
h := &Handler{OrganizationService: &scopeOrgs{scope: &models.ResourceScope{}}}
|
|
_, c := serveScoped(t, h, restrictedMember(orgID), nil, http.MethodGet, "/v1/campaigns", "/v1/campaigns")
|
|
for name, list := range map[string][]uuid.UUID{"campaigns": AllowedCampaigns(c), "mailboxes": AllowedEmailAccounts(c), "folders": AllowedFolders(c)} {
|
|
if len(list) == 0 {
|
|
t.Fatalf("%s: empty allowlist reads as unrestricted", name)
|
|
}
|
|
}
|
|
if CampaignAllowed(c, uuid.Nil) || EmailAccountAllowed(c, uuid.Nil) {
|
|
t.Fatal("the none-match sentinel was treated as a grant")
|
|
}
|
|
})
|
|
|
|
t.Run("grants are the allowlist", func(t *testing.T) {
|
|
h := &Handler{OrganizationService: &scopeOrgs{scope: &models.ResourceScope{Campaigns: []uuid.UUID{campaign}, Mailboxes: []uuid.UUID{granted}}}}
|
|
_, c := serveScoped(t, h, restrictedMember(orgID), nil, http.MethodGet, "/v1/campaigns", "/v1/campaigns")
|
|
if !CampaignAllowed(c, campaign) || CampaignAllowed(c, uuid.New()) {
|
|
t.Fatal("campaign allowlist wrong")
|
|
}
|
|
if !EmailAccountAllowed(c, granted) || EmailAccountAllowed(c, other) {
|
|
t.Fatal("mailbox allowlist wrong")
|
|
}
|
|
})
|
|
|
|
t.Run("an API key is held to its own list and its holder's grants", func(t *testing.T) {
|
|
h := &Handler{OrganizationService: &scopeOrgs{scope: &models.ResourceScope{Mailboxes: []uuid.UUID{granted, other}}}}
|
|
_, c := serveScoped(t, h, restrictedMember(orgID), []uuid.UUID{granted, keyOnly}, http.MethodGet, "/v1/emails", "/v1/emails")
|
|
got := AllowedEmailAccounts(c)
|
|
if len(got) != 1 || got[0] != granted {
|
|
t.Fatalf("allowlist = %v, want only %v", got, granted)
|
|
}
|
|
_, c = serveScoped(t, h, restrictedMember(orgID), []uuid.UUID{keyOnly}, http.MethodGet, "/v1/emails", "/v1/emails")
|
|
if got := AllowedEmailAccounts(c); len(got) == 0 || EmailAccountAllowed(c, keyOnly) {
|
|
t.Fatalf("disjoint key and grants must allow nothing, got %v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRequireCampaignParamHidesOutOfScopeCampaigns(t *testing.T) {
|
|
orgID := uuid.New()
|
|
campaign := uuid.New()
|
|
h := &Handler{OrganizationService: &scopeOrgs{scope: &models.ResourceScope{Campaigns: []uuid.UUID{campaign}}}}
|
|
for id, want := range map[uuid.UUID]int{campaign: http.StatusOK, uuid.New(): http.StatusNotFound} {
|
|
rec, _ := serveScoped(t, h, restrictedMember(orgID), nil, http.MethodGet, "/v1/campaigns/:id", "/v1/campaigns/"+id.String(), RequireCampaignParam("id"))
|
|
if rec.Code != want {
|
|
t.Fatalf("campaign %s: status %d, want %d", id, rec.Code, want)
|
|
}
|
|
}
|
|
}
|