Pulse/internal/api/authorization_test.go
rcourtman 96001d134e Harden MSP tenant isolation: scope org-bound tokens away from default org, propagate webhook allowlist to all tenants
Two gaps found by exercising the MSP pilot path live on a throwaway
multi-tenant instance:

1. CheckAccess granted any authenticated principal access to the default
   org, so a token bound to a client org could read the provider's own
   default-org estate if it leaked from a client site. Org-bound tokens
   now fall through to the explicit binding check for the default org;
   authenticated users and legacy unbound tokens keep default-org access,
   and binding "default" explicitly still grants it.

2. The webhook private-target allowlist (instance-wide system setting)
   only ever reached the default org's notification manager on
   startup/reload, and only the request-context org on settings update.
   Tenant orgs' webhooks to private targets (per-client Gotify over VPN,
   the canonical MSP alert route) failed SSRF validation with no org-side
   remedy, and any allowlist died with a restart. Settings updates and
   reloads now fan out to every live tenant manager via the new
   MultiTenantMonitor.ForEachMonitor, and tenant monitors inherit the
   persisted allowlist and public URL at creation.

Both fixes verified live: org-bound token vs default org returns 403;
client-org webhooks to a private target succeed after restart and for
orgs created after the allowlist was saved. MSP.md validation checklist
gains the default-org probe and the allowlist guidance; MULTI_TENANT.md
documents the binding semantics. Contracts updated for api-contracts,
security-privacy, and monitoring with adjacency notes for
agent-lifecycle, storage-recovery, and performance-and-scalability.
2026-06-10 11:37:39 +01:00

172 lines
5.8 KiB
Go

package api
import (
"errors"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type mockOrgLoader struct {
mock.Mock
}
func (m *mockOrgLoader) GetOrganization(orgID string) (*models.Organization, error) {
args := m.Called(orgID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Organization), args.Error(1)
}
func TestDefaultAuthorizationChecker_TokenCanAccessOrg(t *testing.T) {
checker := NewAuthorizationChecker(nil)
t.Run("nil token", func(t *testing.T) {
assert.True(t, checker.TokenCanAccessOrg(nil, "any"))
})
t.Run("valid access single", func(t *testing.T) {
token := &config.APITokenRecord{OrgID: "acme"}
assert.True(t, checker.TokenCanAccessOrg(token, "acme"))
})
t.Run("valid access multi", func(t *testing.T) {
token := &config.APITokenRecord{OrgIDs: []string{"acme", "other"}}
assert.True(t, checker.TokenCanAccessOrg(token, "acme"))
})
t.Run("denied access", func(t *testing.T) {
token := &config.APITokenRecord{OrgID: "other"}
assert.False(t, checker.TokenCanAccessOrg(token, "acme"))
})
t.Run("wildcard legacy access denied", func(t *testing.T) {
token := &config.APITokenRecord{} // empty orgs = legacy/unbound
assert.False(t, checker.TokenCanAccessOrg(token, "tenant1"))
assert.False(t, checker.TokenCanAccessOrg(token, "default"))
})
}
func TestDefaultAuthorizationChecker_UserCanAccessOrg(t *testing.T) {
ml := new(mockOrgLoader)
checker := NewAuthorizationChecker(ml)
t.Run("default org allowed without metadata", func(t *testing.T) {
// Default org is always accessible to any authenticated user.
assert.True(t, checker.UserCanAccessOrg("user1", "default"))
})
t.Run("default org accessible regardless of membership", func(t *testing.T) {
// Default org is always accessible, even if membership data exists.
assert.True(t, checker.UserCanAccessOrg("user1", "default"))
assert.True(t, checker.UserCanAccessOrg("other", "default"))
})
t.Run("missing loader", func(t *testing.T) {
badChecker := NewAuthorizationChecker(nil)
// Default org is always accessible, even without a loader.
assert.True(t, badChecker.UserCanAccessOrg("user1", "default"))
// Non-default orgs are denied without a loader (fail closed).
assert.False(t, badChecker.UserCanAccessOrg("user1", "acme"))
})
t.Run("authorized member", func(t *testing.T) {
org := &models.Organization{
ID: "acme",
Members: []models.OrganizationMember{
{UserID: "user1", Role: models.OrgRoleAdmin},
},
}
ml.On("GetOrganization", "acme").Return(org, nil).Once()
assert.True(t, checker.UserCanAccessOrg("user1", "acme"))
})
t.Run("unauthorized user", func(t *testing.T) {
org := &models.Organization{
ID: "acme",
Members: []models.OrganizationMember{
{UserID: "other", Role: models.OrgRoleViewer},
},
}
ml.On("GetOrganization", "acme").Return(org, nil).Once()
assert.False(t, checker.UserCanAccessOrg("user1", "acme"))
})
t.Run("loader error", func(t *testing.T) {
ml.On("GetOrganization", "fail").Return(nil, errors.New("db error")).Once()
assert.False(t, checker.UserCanAccessOrg("user1", "fail"))
})
t.Run("not found", func(t *testing.T) {
ml.On("GetOrganization", "missing").Return(nil, nil).Once()
assert.False(t, checker.UserCanAccessOrg("user1", "missing"))
})
}
func TestDefaultAuthorizationChecker_CheckAccess(t *testing.T) {
ml := new(mockOrgLoader)
checker := NewAuthorizationChecker(ml)
t.Run("token takes precedence", func(t *testing.T) {
token := &config.APITokenRecord{OrgID: "acme"}
res := checker.CheckAccess(token, "user1", "acme")
assert.True(t, res.Allowed)
tokenLegacy := &config.APITokenRecord{OrgID: ""} // Legacy/unbound
res = checker.CheckAccess(tokenLegacy, "user1", "acme")
assert.False(t, res.Allowed)
tokenDenied := &config.APITokenRecord{OrgID: "other"}
res = checker.CheckAccess(tokenDenied, "user1", "acme")
assert.False(t, res.Allowed)
assert.Equal(t, "Token is not authorized for this organization", res.Reason)
})
t.Run("user fallback", func(t *testing.T) {
org := &models.Organization{
ID: "acme",
Members: []models.OrganizationMember{
{UserID: "user1", Role: models.OrgRoleAdmin},
},
}
ml.On("GetOrganization", "acme").Return(org, nil).Once()
res := checker.CheckAccess(nil, "user1", "acme")
assert.True(t, res.Allowed)
assert.Equal(t, "User is a member of the organization", res.Reason)
})
t.Run("no context", func(t *testing.T) {
res := checker.CheckAccess(nil, "", "acme")
assert.False(t, res.Allowed)
assert.Equal(t, "No authentication context provided", res.Reason)
})
t.Run("default org access by principal kind", func(t *testing.T) {
// Users keep implicit default-org access.
res := checker.CheckAccess(nil, "user1", "default")
assert.True(t, res.Allowed)
// Legacy unbound tokens keep implicit default-org access.
res = checker.CheckAccess(&config.APITokenRecord{}, "", "default")
assert.True(t, res.Allowed)
// A token bound to a client org must NOT implicitly reach the
// default org — binding expresses intent to limit. This is the MSP
// isolation boundary: a leaked client-site token cannot read the
// provider's own estate.
res = checker.CheckAccess(&config.APITokenRecord{OrgID: "client-acme"}, "", "default")
assert.False(t, res.Allowed)
res = checker.CheckAccess(&config.APITokenRecord{OrgIDs: []string{"client-acme", "client-beta"}}, "", "default")
assert.False(t, res.Allowed)
// Binding "default" explicitly still grants it.
res = checker.CheckAccess(&config.APITokenRecord{OrgID: "default"}, "", "default")
assert.True(t, res.Allowed)
res = checker.CheckAccess(&config.APITokenRecord{OrgIDs: []string{"default", "client-acme"}}, "", "default")
assert.True(t, res.Allowed)
})
}