mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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.
172 lines
5.8 KiB
Go
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)
|
|
})
|
|
}
|