mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Implements multi-tenant infrastructure for organization-based data isolation. Feature is gated behind PULSE_MULTI_TENANT_ENABLED env var and requires Enterprise license - no impact on existing users. Core components: - TenantMiddleware: extracts org ID, validates access, 501/402 responses - AuthorizationChecker: token/user access validation for organizations - MultiTenantChecker: WebSocket upgrade gating with license check - Per-tenant audit logging via LogAuditEventForTenant - Organization model with membership support Gating behavior: - Feature flag disabled: 501 Not Implemented for non-default orgs - Flag enabled, no license: 402 Payment Required - Default org always works regardless of flag/license Documentation added: docs/MULTI_TENANT.md
207 lines
6.1 KiB
Go
207 lines
6.1 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.
|
|
// It uses the token's CanAccessOrg method and logs warnings for legacy tokens.
|
|
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)
|
|
|
|
// Log warning for legacy tokens with wildcard access
|
|
if token.IsLegacyToken() && orgID != "default" {
|
|
log.Warn().
|
|
Str("token_id", token.ID).
|
|
Str("token_name", token.Name).
|
|
Str("org_id", orgID).
|
|
Msg("Legacy token with wildcard access used for non-default org - consider binding to specific org")
|
|
}
|
|
|
|
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 {
|
|
// Default org is always accessible
|
|
if orgID == "default" {
|
|
return true
|
|
}
|
|
|
|
// If no org loader is configured, deny access to non-default orgs
|
|
if c.orgLoader == nil {
|
|
log.Warn().
|
|
Str("user_id", userID).
|
|
Str("org_id", orgID).
|
|
Msg("No organization loader configured, denying access to non-default org")
|
|
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.CanUserAccess(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
|
|
|
|
// IsLegacyToken indicates if the access was granted via a legacy wildcard token.
|
|
IsLegacyToken bool
|
|
}
|
|
|
|
// CheckAccess performs a comprehensive authorization check for a request.
|
|
func (c *DefaultAuthorizationChecker) CheckAccess(token *config.APITokenRecord, userID, orgID string) AuthorizationResult {
|
|
// 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",
|
|
IsLegacyToken: token.IsLegacyToken(),
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Default org is always accessible
|
|
if orgID == "default" {
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|