Files
warmbly/internal/api/middleware/resource_scope_test.go
T

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)
}
}
}