Pulse/internal/api/middleware_tenant.go
rcourtman ee8a24e14a backend and governance: MCP contract, agent capabilities, API, and release-control
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
2026-06-23 17:26:15 +01:00

296 lines
10 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
type OrganizationContextKey string
const (
OrgIDContextKey OrganizationContextKey = "org_id"
OrgContextKey OrganizationContextKey = "org_object"
subStateActive string = "active"
subStateGrace string = "grace"
subStateTrial string = "trial"
)
// TenantMiddleware extracts the organization ID from the request and
// sets up the context for multi-tenant isolation.
type TenantMiddleware struct {
persistence *config.MultiTenantPersistence
authChecker AuthorizationChecker
hostedMode bool
}
// TenantMiddlewareConfig holds configuration for the tenant middleware.
type TenantMiddlewareConfig struct {
Persistence *config.MultiTenantPersistence
AuthChecker AuthorizationChecker
HostedMode bool
}
func resolveTenantOrgID(r *http.Request) string {
orgID := ""
explicitOrg := false
if r != nil {
orgID = strings.TrimSpace(r.Header.Get("X-Pulse-Org-ID"))
explicitOrg = orgID != ""
if orgID == "" {
if cookie, err := r.Cookie(CookieNameOrgID); err == nil {
orgID = strings.TrimSpace(cookie.Value)
explicitOrg = orgID != ""
}
}
}
if orgID == "" {
orgID = "default"
}
// Cloud/agent UX: if no org was explicitly selected, prefer an org-bound token.
// This enables tenant-scoped install commands that only need --token.
if !explicitOrg && orgID == "default" && r != nil {
if tokenVal := auth.GetAPIToken(r.Context()); tokenVal != nil {
if t, ok := tokenVal.(*config.APITokenRecord); ok && t != nil {
if strings.TrimSpace(t.OrgID) != "" {
orgID = strings.TrimSpace(t.OrgID)
} else if len(t.OrgIDs) == 1 && strings.TrimSpace(t.OrgIDs[0]) != "" {
orgID = strings.TrimSpace(t.OrgIDs[0])
}
}
}
}
return orgID
}
func NewTenantMiddleware(p *config.MultiTenantPersistence) *TenantMiddleware {
return &TenantMiddleware{persistence: p}
}
// NewTenantMiddlewareWithConfig creates a new TenantMiddleware with full configuration.
func NewTenantMiddlewareWithConfig(cfg TenantMiddlewareConfig) *TenantMiddleware {
return &TenantMiddleware{
persistence: cfg.Persistence,
authChecker: cfg.AuthChecker,
hostedMode: cfg.HostedMode,
}
}
func (m *TenantMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract Org ID
// Priority:
// 1. Header: X-Pulse-Org-ID (for API clients/agents)
// 2. Cookie: pulse_org_id (for browser session)
// 3. Fallback: "default" (for backward compatibility)
orgID := resolveTenantOrgID(r)
var loadedOrg *models.Organization
// 2. Validate Organization Exists (only for non-default orgs)
// This must check existence WITHOUT creating directories to prevent DoS.
// It also must run BEFORE feature checks to ensure invalid org IDs return 400 (Bad Request)
// rather than 501/402 (feature disabled/unlicensed).
if orgID != "default" && m.persistence != nil {
if !m.persistence.OrgExists(orgID) {
writeJSONError(w, http.StatusBadRequest, "invalid_org", "Invalid Organization ID")
return
}
}
// 2b. Load org metadata and enforce lifecycle status for non-default orgs.
// If loading fails, keep backward-compatible behavior by falling back later.
if orgID != "default" && m.persistence != nil {
org, loadErr := m.persistence.LoadOrganization(orgID)
if loadErr == nil && org != nil {
loadedOrg = org
status := models.NormalizeOrgStatus(org.Status)
if status == models.OrgStatusSuspended || status == models.OrgStatusPendingDeletion {
writeJSONError(w, http.StatusForbidden, "org_suspended", "Organization is suspended")
return
}
}
}
// 3. Tenant access gating for non-default orgs.
// Hosted mode: tenant routing is infrastructure — check subscription lifecycle instead of FeatureMultiTenant.
// Self-hosted mode: non-default orgs require PULSE_MULTI_TENANT_ENABLED=true AND FeatureMultiTenant (Enterprise/MSP).
if orgID != "default" {
if m.hostedMode {
// Hosted mode: verify the org has a valid subscription or bounded trial.
checkCtx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
if !isHostedSubscriptionValid(checkCtx) {
writeHostedSubscriptionRequiredError(w)
return
}
} else {
// Self-hosted mode: multi-tenant requires feature flag + Enterprise license.
if !IsMultiTenantEnabled() {
writeMultiTenantDisabledError(w)
return
}
checkCtx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
if !hasMultiTenantFeatureForContext(checkCtx) {
writeMultiTenantRequiredError(w)
return
}
}
}
// 4. Authorization Check
// Check if the authenticated user/token is allowed to access this organization.
// Note: This runs AFTER AuthContextMiddleware, so auth context is available.
if m.authChecker != nil {
// Dev-only emergency bypass should skip tenant membership checks too.
// Use request context marker to avoid leaking global bypass state across tests.
if isAdminBypassRequest(r.Context()) {
ctx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
org := loadedOrg
if org == nil {
org = &models.Organization{ID: orgID, DisplayName: orgID}
}
ctx = context.WithValue(ctx, OrgContextKey, org)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Get API token from context (set by AuthContextMiddleware)
var token *config.APITokenRecord
if tokenVal := auth.GetAPIToken(r.Context()); tokenVal != nil {
if t, ok := tokenVal.(*config.APITokenRecord); ok {
token = t
}
}
// Get user ID from context (set by AuthContextMiddleware)
userID := auth.GetUser(r.Context())
// Only perform authorization check if we have auth context
// If no auth context, the route's RequireAuth will handle authentication errors
if token != nil || userID != "" {
// Perform authorization check using the interface method
result := m.authChecker.CheckAccess(token, userID, orgID)
if !result.Allowed {
log.Warn().
Str("org_id", orgID).
Str("user_id", userID).
Str("reason", result.Reason).
Msg("Unauthorized access attempt to organization")
writeJSONError(w, http.StatusForbidden, "access_denied", result.Reason)
return
}
}
}
// 5. Inject into Context
ctx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
org := loadedOrg
if org == nil {
org = &models.Organization{ID: orgID, DisplayName: orgID}
}
ctx = context.WithValue(ctx, OrgContextKey, org)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// writeJSONError writes the agent-stable error envelope.
//
// The shape is `{"error": "<stable_code>", "message": "<human>"}`,
// in contrast to the platform-wide APIError envelope written by
// writeErrorResponse (where the stable code lives under `code` and
// `error` carries the human message). Agent-surface endpoints emit
// this shape so external agents can branch on the `error` field
// without remembering which envelope a given capability uses.
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
writeJSONErrorWithDetails(w, status, code, message, nil)
}
// writeJSONErrorWithDetails extends writeJSONError with an optional
// `details` map for field-level failure reasons. When non-empty, the
// envelope grows a third top-level `details` field so agents see
// structured per-field reasons (e.g. which body field failed
// validation) without parsing the human message. Empty/nil details
// preserve the two-field shape so the contract for callers that
// don't pass details stays unchanged.
//
// Used by the action endpoints (actions.go) where the platform-wide
// writeErrorResponse calls historically passed a details map; the
// agent-stable envelope now carries the same information at the
// matching key. The pattern lets future agent-surface endpoints
// adopt structured field reasons without growing yet another
// envelope shape.
func writeJSONErrorWithDetails(w http.ResponseWriter, status int, code, message string, details map[string]string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
body := agentcapabilities.NewErrorEnvelope(code, message, details)
_ = json.NewEncoder(w).Encode(body)
}
// Helper to get OrgID from context
func GetOrgID(ctx context.Context) string {
if id, ok := ctx.Value(OrgIDContextKey).(string); ok {
return id
}
return "default"
}
// Helper to get Organization from context
func GetOrganization(ctx context.Context) *models.Organization {
if org, ok := ctx.Value(OrgContextKey).(*models.Organization); ok {
return org
}
return &models.Organization{ID: "default", DisplayName: "Default Organization"}
}
// isHostedSubscriptionValid checks whether a hosted Cloud tenant has a valid subscription
// or a bounded trial. This replaces the FeatureMultiTenant check for hosted mode, where
// tenant routing is infrastructure rather than a paid feature.
//
// Trial validity requires both that an end timestamp exists AND that the
// current time has not passed it. A non-nil TrialEndsAt alone is not enough —
// a stale "trial" subscription state with an expired timestamp on disk would
// otherwise grant indefinite access if lease refresh ever fails.
func isHostedSubscriptionValid(ctx context.Context) bool {
svc := getLicenseServiceForContext(ctx)
subState := strings.ToLower(strings.TrimSpace(svc.SubscriptionState()))
eval := svc.Evaluator()
switch subState {
case subStateActive, subStateGrace:
return true
case subStateTrial:
if eval == nil {
return false
}
trialEnd := eval.TrialEndsAt()
if trialEnd == nil {
return false
}
return time.Now().Unix() < *trialEnd
default:
return false
}
}
// writeHostedSubscriptionRequiredError writes a 402 response for Cloud tenants
// whose subscription is not active (expired, canceled, or missing).
func writeHostedSubscriptionRequiredError(w http.ResponseWriter) {
writePaymentRequired(w, map[string]interface{}{
"error": "subscription_required",
"message": "Your Cloud subscription is not active. Please check your billing status.",
})
}