Pulse/internal/api/authorization.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

216 lines
6.5 KiB
Go

package api
import (
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
// AuthorizationChecker provides methods to check if a user or token can access an organization.
type AuthorizationChecker interface {
// TokenCanAccessOrg checks if an API token is authorized to access the specified organization.
TokenCanAccessOrg(token *config.APITokenRecord, orgID string) bool
// UserCanAccessOrg checks if a user is a member of the specified organization.
UserCanAccessOrg(userID, orgID string) bool
// CheckAccess performs a comprehensive authorization check for a request.
CheckAccess(token *config.APITokenRecord, userID, orgID string) AuthorizationResult
}
// DefaultAuthorizationChecker implements AuthorizationChecker with the default logic.
type DefaultAuthorizationChecker struct {
// orgLoader is used to load organization data for membership checks.
orgLoader OrganizationLoader
}
// OrganizationLoader provides methods to load organization data.
type OrganizationLoader interface {
// GetOrganization returns the organization with the specified ID.
GetOrganization(orgID string) (*models.Organization, error)
}
// NewAuthorizationChecker creates a new DefaultAuthorizationChecker.
func NewAuthorizationChecker(loader OrganizationLoader) *DefaultAuthorizationChecker {
return &DefaultAuthorizationChecker{
orgLoader: loader,
}
}
// MultiTenantOrganizationLoader implements OrganizationLoader using MultiTenantPersistence.
type MultiTenantOrganizationLoader struct {
persistence *config.MultiTenantPersistence
}
// NewMultiTenantOrganizationLoader creates a new organization loader.
func NewMultiTenantOrganizationLoader(persistence *config.MultiTenantPersistence) *MultiTenantOrganizationLoader {
return &MultiTenantOrganizationLoader{
persistence: persistence,
}
}
// GetOrganization loads the organization with the specified ID.
func (l *MultiTenantOrganizationLoader) GetOrganization(orgID string) (*models.Organization, error) {
if l.persistence == nil {
return nil, fmt.Errorf("no persistence configured")
}
return l.persistence.LoadOrganization(orgID)
}
// TokenCanAccessOrg checks if an API token is authorized to access the specified organization.
func (c *DefaultAuthorizationChecker) TokenCanAccessOrg(token *config.APITokenRecord, orgID string) bool {
if token == nil {
// No token means session-based auth - defer to user membership check
return true
}
// Check if token can access the org
canAccess := token.CanAccessOrg(orgID)
if !canAccess {
log.Debug().
Str("token_id", token.ID).
Str("token_name", token.Name).
Str("org_id", orgID).
Strs("bound_orgs", token.GetBoundOrgs()).
Msg("Token denied access to organization")
}
return canAccess
}
// UserCanAccessOrg checks if a user is a member of the specified organization.
func (c *DefaultAuthorizationChecker) UserCanAccessOrg(userID, orgID string) bool {
if userID == "" {
return false
}
// The default organization is always accessible to any authenticated user.
// This ensures single-tenant deployments work without org membership data.
if orgID == "default" {
return true
}
// Fail closed when membership data cannot be resolved.
if c.orgLoader == nil {
log.Warn().
Str("user_id", userID).
Str("org_id", orgID).
Msg("No organization loader configured, denying access")
return false
}
org, err := c.orgLoader.GetOrganization(orgID)
if err != nil {
log.Error().
Err(err).
Str("user_id", userID).
Str("org_id", orgID).
Msg("Failed to load organization for access check")
return false
}
if org == nil {
log.Debug().
Str("user_id", userID).
Str("org_id", orgID).
Msg("Organization not found for access check")
return false
}
canAccess := org.CanUserIDAccess(userID)
if !canAccess {
log.Debug().
Str("user_id", userID).
Str("org_id", orgID).
Msg("User is not a member of the organization")
}
return canAccess
}
// AuthorizationResult contains the result of an authorization check.
type AuthorizationResult struct {
// Allowed indicates if access is allowed.
Allowed bool
// Reason provides a human-readable reason for the decision.
Reason string
}
// CheckAccess performs a comprehensive authorization check for a request.
func (c *DefaultAuthorizationChecker) CheckAccess(token *config.APITokenRecord, userID, orgID string) AuthorizationResult {
// The default organization stays accessible to authenticated users and to
// legacy unbound tokens. A token explicitly bound to organizations is
// scoped to those organizations only — binding expresses intent to limit,
// so it must not implicitly reach the default org's data (bind "default"
// explicitly to grant that). Without this, a client-bound MSP token that
// leaks from a client site could read the provider's own default-org
// estate.
if orgID == "default" {
if token != nil {
if token.IsLegacyToken() {
return AuthorizationResult{
Allowed: true,
Reason: "Default organization is accessible to unbound tokens",
}
}
// Org-bound tokens fall through to the explicit binding check.
} else if userID != "" {
return AuthorizationResult{
Allowed: true,
Reason: "Default organization is accessible to all authenticated users",
}
}
}
// Check token-based access first
if token != nil {
if !token.CanAccessOrg(orgID) {
return AuthorizationResult{
Allowed: false,
Reason: "Token is not authorized for this organization",
}
}
return AuthorizationResult{
Allowed: true,
Reason: "Token authorized for organization",
}
}
// Fall back to user-based access
if userID != "" {
if c.UserCanAccessOrg(userID, orgID) {
return AuthorizationResult{
Allowed: true,
Reason: "User is a member of the organization",
}
}
return AuthorizationResult{
Allowed: false,
Reason: "User is not a member of the organization",
}
}
// No token and no user - deny access
return AuthorizationResult{
Allowed: false,
Reason: "No authentication context provided",
}
}
// CanAccessOrg implements websocket.OrgAuthChecker for use with the WebSocket hub.
func (c *DefaultAuthorizationChecker) CanAccessOrg(userID string, tokenInterface interface{}, orgID string) bool {
// Convert token interface to APITokenRecord
var token *config.APITokenRecord
if tokenInterface != nil {
if t, ok := tokenInterface.(*config.APITokenRecord); ok {
token = t
}
}
result := c.CheckAccess(token, userID, orgID)
return result.Allowed
}