Files
pulse/internal/monitoring/multi_tenant_monitor.go
T
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

342 lines
9.7 KiB
Go

package monitoring
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
// MultiTenantMonitor manages a dedicated Monitor instance for each organization.
type MultiTenantMonitor struct {
mu sync.RWMutex
monitors map[string]*Monitor
tenantCancel map[string]context.CancelFunc
tenantDone map[string]chan struct{}
persistence *config.MultiTenantPersistence
baseConfig *config.Config
wsHub *websocket.Hub
recoveryMgr *recoverymanager.Manager
initializer func(*Monitor)
globalCtx context.Context
globalCancel context.CancelFunc
}
// NewMultiTenantMonitor creates a new multi-tenant monitor manager.
func NewMultiTenantMonitor(baseCfg *config.Config, persistence *config.MultiTenantPersistence, wsHub *websocket.Hub) *MultiTenantMonitor {
ctx, cancel := context.WithCancel(context.Background())
return &MultiTenantMonitor{
monitors: make(map[string]*Monitor),
tenantCancel: make(map[string]context.CancelFunc),
tenantDone: make(map[string]chan struct{}),
persistence: persistence,
baseConfig: baseCfg, // Used as a template or for global settings
wsHub: wsHub,
globalCtx: ctx,
globalCancel: cancel,
}
}
const tenantMonitorShutdownTimeout = 2 * time.Second
// SetRecoveryManager wires a recovery store manager into all existing and future tenant monitors.
func (mtm *MultiTenantMonitor) SetRecoveryManager(manager *recoverymanager.Manager) {
mtm.mu.Lock()
defer mtm.mu.Unlock()
mtm.recoveryMgr = manager
for _, monitor := range mtm.monitors {
if monitor == nil {
continue
}
monitor.SetRecoveryManager(manager)
}
}
// SetMonitorInitializer configures a callback that is applied to all existing
// and future tenant monitors after creation.
func (mtm *MultiTenantMonitor) SetMonitorInitializer(initializer func(*Monitor)) {
if mtm == nil {
return
}
mtm.mu.Lock()
mtm.initializer = initializer
monitors := make([]*Monitor, 0, len(mtm.monitors))
for _, monitor := range mtm.monitors {
if monitor == nil {
continue
}
monitors = append(monitors, monitor)
}
mtm.mu.Unlock()
if initializer == nil {
return
}
for _, monitor := range monitors {
initializer(monitor)
}
}
// ForEachMonitor runs fn against every live tenant monitor. Use it to
// propagate instance-wide runtime settings (e.g. webhook security allowlist)
// that every org's monitor must observe.
func (mtm *MultiTenantMonitor) ForEachMonitor(fn func(*Monitor)) {
if mtm == nil || fn == nil {
return
}
mtm.mu.RLock()
monitors := make([]*Monitor, 0, len(mtm.monitors))
for _, monitor := range mtm.monitors {
if monitor != nil {
monitors = append(monitors, monitor)
}
}
mtm.mu.RUnlock()
for _, monitor := range monitors {
fn(monitor)
}
}
// GetMonitor returns the monitor instance for a specific organization.
// It lazily initializes the monitor if it doesn't exist.
func (mtm *MultiTenantMonitor) GetMonitor(orgID string) (*Monitor, error) {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return nil, fmt.Errorf("organization ID is required")
}
mtm.mu.RLock()
monitor, exists := mtm.monitors[orgID]
mtm.mu.RUnlock()
if exists {
return monitor, nil
}
mtm.mu.Lock()
defer mtm.mu.Unlock()
if mtm.monitors == nil {
mtm.monitors = make(map[string]*Monitor)
}
if mtm.tenantCancel == nil {
mtm.tenantCancel = make(map[string]context.CancelFunc)
}
if mtm.tenantDone == nil {
mtm.tenantDone = make(map[string]chan struct{})
}
// Double-check locking pattern
if monitor, exists = mtm.monitors[orgID]; exists {
return monitor, nil
}
if mtm.persistence == nil {
return nil, fmt.Errorf("tenant persistence is not configured")
}
if orgID != "default" && !mtm.persistence.OrgExists(orgID) {
return nil, fmt.Errorf("organization %q is not provisioned", orgID)
}
// Initialize new monitor for this tenant
log.Info().Str("org_id", orgID).Msg("initializing tenant monitor")
// 1. Load Tenant Config
// Deep copy the base config to ensure tenant isolation.
// Each tenant gets its own independent config that won't share
// credential slices or other mutable state with other tenants.
tenantConfig := mtm.baseConfig.DeepCopy()
// Clear inherited credentials - tenants must load their own
// This prevents credential leakage between tenants
tenantConfig.PVEInstances = nil
tenantConfig.PBSInstances = nil
tenantConfig.PMGInstances = nil
// Ensure the DataPath is correct for this tenant to isolate storage (sqlite, etc)
tenantPersistence, err := mtm.persistence.GetPersistence(orgID)
if err != nil {
return nil, fmt.Errorf("failed to get persistence for org %s: %w", orgID, err)
}
tenantConfig.DataPath = tenantPersistence.GetConfigDir()
// Load tenant-specific nodes from <orgDir>/nodes.enc
nodesConfig, err := tenantPersistence.LoadNodesConfig()
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("failed to load tenant nodes config, starting with empty config")
// Not a fatal error - tenant may not have configured any nodes yet
} else if nodesConfig != nil {
tenantConfig.PVEInstances = nodesConfig.PVEInstances
tenantConfig.PBSInstances = nodesConfig.PBSInstances
tenantConfig.PMGInstances = nodesConfig.PMGInstances
log.Info().
Str("org_id", orgID).
Int("pve_count", len(nodesConfig.PVEInstances)).
Int("pbs_count", len(nodesConfig.PBSInstances)).
Int("pmg_count", len(nodesConfig.PMGInstances)).
Msg("Loaded tenant nodes config")
}
// 2. Create Monitor
// Usage of internal New constructor
monitor, err = New(tenantConfig)
if err != nil {
return nil, fmt.Errorf("failed to create monitor for org %s: %w", orgID, err)
}
// Set org ID for tenant isolation
// This enables tenant-scoped WebSocket broadcasts
monitor.SetOrgID(orgID)
// Stamp this org's identity into webhook payloads so external receivers
// (PSA/ticketing bridges) can route by tenant. Resolved lazily so a
// display-name rename is picked up without restarting the monitor. The
// default org keeps the PULSE_TENANT_ID/PULSE_TENANT_NAME env defaults.
if nm := monitor.GetNotificationManager(); nm != nil && orgID != "default" {
persistence := mtm.persistence
nm.SetTenantIdentityResolver(func() (string, string) {
name := ""
if org, err := persistence.LoadOrganization(orgID); err == nil && org != nil {
name = org.DisplayName
}
return orgID, name
})
}
if mtm.recoveryMgr != nil {
monitor.SetRecoveryManager(mtm.recoveryMgr)
}
if mtm.initializer != nil {
mtm.initializer(monitor)
}
// 3. Start Monitor with a tenant-scoped runtime so RemoveTenant can stop it cleanly.
tenantCtx, tenantCancel := context.WithCancel(mtm.globalCtx)
tenantDone := make(chan struct{})
go func() {
defer close(tenantDone)
monitor.Start(tenantCtx, mtm.wsHub)
}()
mtm.monitors[orgID] = monitor
mtm.tenantCancel[orgID] = tenantCancel
mtm.tenantDone[orgID] = tenantDone
return monitor, nil
}
// PeekMonitor returns the tenant monitor instance if it is already initialized.
// It does not create a new monitor.
func (mtm *MultiTenantMonitor) PeekMonitor(orgID string) (*Monitor, bool) {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return nil, false
}
mtm.mu.RLock()
defer mtm.mu.RUnlock()
monitor, exists := mtm.monitors[orgID]
return monitor, exists
}
// Stop stops all tenant monitors.
func (mtm *MultiTenantMonitor) Stop() {
log.Info().Msg("stopping MultiTenantMonitor and all tenant instances")
mtm.mu.Lock()
mtm.globalCancel()
monitors := make([]*Monitor, 0, len(mtm.monitors))
cancels := make([]context.CancelFunc, 0, len(mtm.tenantCancel))
doneSignals := make([]chan struct{}, 0, len(mtm.tenantDone))
for orgID, monitor := range mtm.monitors {
if cancel := mtm.tenantCancel[orgID]; cancel != nil {
cancels = append(cancels, cancel)
}
if done := mtm.tenantDone[orgID]; done != nil {
doneSignals = append(doneSignals, done)
}
monitors = append(monitors, monitor)
}
mtm.tenantCancel = make(map[string]context.CancelFunc)
mtm.tenantDone = make(map[string]chan struct{})
mtm.monitors = make(map[string]*Monitor)
mtm.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
for _, done := range doneSignals {
waitForTenantMonitorShutdown("", done)
}
for _, monitor := range monitors {
monitor.Stop()
}
}
// RemoveTenant stops and removes a specific tenant's monitor.
// Useful for offboarding or manual reloading.
func (mtm *MultiTenantMonitor) RemoveTenant(orgID string) {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return
}
mtm.mu.Lock()
monitor, exists := mtm.monitors[orgID]
cancel := mtm.tenantCancel[orgID]
done := mtm.tenantDone[orgID]
delete(mtm.monitors, orgID)
delete(mtm.tenantCancel, orgID)
delete(mtm.tenantDone, orgID)
mtm.mu.Unlock()
if !exists {
return
}
log.Info().Str("org_id", orgID).Msg("stopping and removing tenant monitor")
if cancel != nil {
cancel()
}
waitForTenantMonitorShutdown(orgID, done)
monitor.Stop()
}
func waitForTenantMonitorShutdown(orgID string, done <-chan struct{}) {
if done == nil {
return
}
timer := time.NewTimer(tenantMonitorShutdownTimeout)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
logger := log.Warn().Dur("timeout", tenantMonitorShutdownTimeout)
if orgID != "" {
logger = logger.Str("org_id", orgID)
}
logger.Msg("timed out waiting for tenant monitor loop to exit")
}
}
// OrgExists checks if an organization exists (directory exists) without creating it.
func (mtm *MultiTenantMonitor) OrgExists(orgID string) bool {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return false
}
if mtm.persistence == nil {
return false
}
return mtm.persistence.OrgExists(orgID)
}