Pulse/internal/api/access_tenant_provider.go
rcourtman 54a312bebd Measure Pro feature adoption and drop the dead Patrol autofix counter
Six of the eight Pro-exclusive features had no telemetry field at all, so
there was no way to answer whether RBAC, audit logging, scheduled reporting,
agent profiles, alert-triggered AI, or Kubernetes AI were being used by the
installs paying for them. Schema v6 adds nine content-free adoption signals:

  alert_ai_enabled          AIConfig.IsAlertTriggeredAnalysisEnabled()
  rbac_custom_roles         non-built-in roles, per org
  rbac_user_assignments     user-to-role assignments, per org
  audit_logging_persistent  a persistent audit store is active, not console
  audit_events_30d          audit events retained inside the window
  report_schedules          configured scheduled reports
  report_schedules_enabled  scheduled reports switched on
  report_schedules_run_30d  schedules whose last run falls inside the window
  agent_profiles            configured agent profiles

Counts only. Role names, permissions, usernames, schedule names, delivery
recipients, report scope, profile names, and every audit event field stay on
the install. kubernetes_ai needs no field of its own: it is derivable at read
time from alert_ai_enabled combined with the existing kubernetes_clusters
count, and a dedicated field would be redundant.

Config-sourced signals are read through applyLicensedFeatureConfigSnapshot;
RBAC and audit live behind the router and are read through
Router.ApplyLicensedFeatureTelemetrySnapshot. The RBAC read goes through a new
TenantRBACProvider.PeekManager so a background telemetry read can never
provision an RBAC store for an org that has never used RBAC.

Also removes pulse_intelligence_patrol_autofixes_30d and the AutoFixCount
field behind it. patrol_run.go hardcoded AutoFixCount to 0 and no increment
site existed anywhere in the tree, so the counter was zero in all 233,364
retained production pings. That was a wiring bug, not evidence that nobody
uses Patrol fixes; governed fixes are delivered through the approved-action
pipeline, which is already instrumented. The field was plumbed through run
records, history persistence, the Assistant handoff, and telemetry while being
structurally incapable of holding a non-zero value.

Verified end to end against a running install rather than only in unit tests,
which is precisely the check the autofix counter never had: seeding three
report schedules (two enabled, one last run inside the window) and two agent
profiles produced report_schedules 3, report_schedules_enabled 2,
report_schedules_run_30d 1, agent_profiles 2 in the Settings telemetry
preview, and signing in moved audit_events_30d to 1.

The private receiver landed first in pulse-pro 78ff7dd so the new fields are
accepted on arrival.
2026-08-05 14:34:50 +01:00

209 lines
5.2 KiB
Go

package api
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
var tenantRBACOrgIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
// TenantRBACProvider manages per-tenant RBAC Manager instances.
// Follows the same file-based isolation pattern as MultiTenantPersistence.
type TenantRBACProvider struct {
baseDataDir string
mu sync.RWMutex
managers map[string]*auth.SQLiteManager
}
// NewTenantRBACProvider creates a new provider.
func NewTenantRBACProvider(baseDataDir string) *TenantRBACProvider {
return &TenantRBACProvider{
baseDataDir: baseDataDir,
managers: make(map[string]*auth.SQLiteManager),
}
}
// PeekManager returns an already-initialized RBAC Manager for the given org
// without creating one. Read-only callers that must not have side effects —
// notably the usage telemetry snapshot — use this so a background read never
// provisions an RBAC store for an org that has never used RBAC.
func (p *TenantRBACProvider) PeekManager(orgID string) (auth.ExtendedManager, bool) {
if p == nil {
return nil, false
}
orgID = normalizeOrgID(orgID)
p.mu.RLock()
defer p.mu.RUnlock()
manager, exists := p.managers[orgID]
if !exists || manager == nil {
return nil, false
}
return manager, true
}
// GetManager returns the RBAC Manager for the given org, creating it lazily if needed.
// For "default" org: DataDir = baseDataDir (db at {baseDataDir}/rbac/rbac.db — existing location).
// For other orgs: DataDir = {baseDataDir}/orgs/{orgID} (db at {baseDataDir}/orgs/{orgID}/rbac/rbac.db).
func (p *TenantRBACProvider) GetManager(orgID string) (auth.ExtendedManager, error) {
orgID = normalizeOrgID(orgID)
p.mu.RLock()
manager, exists := p.managers[orgID]
p.mu.RUnlock()
if exists {
return manager, nil
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check locking pattern.
if manager, exists = p.managers[orgID]; exists {
return manager, nil
}
dataDir, err := p.resolveDataDir(orgID)
if err != nil {
return nil, err
}
manager, err = auth.NewSQLiteManager(auth.SQLiteManagerConfig{
DataDir: dataDir,
MigrateFromFiles: orgID == "default",
})
if err != nil {
return nil, fmt.Errorf("failed to initialize RBAC manager for org %s: %w", orgID, err)
}
p.managers[orgID] = manager
RecordRBACManagerCreated()
return manager, nil
}
// RemoveTenant closes and removes the cached manager for the given org.
// Called when an org is deleted.
func (p *TenantRBACProvider) RemoveTenant(orgID string) error {
orgID = normalizeOrgID(orgID)
p.mu.Lock()
manager, exists := p.managers[orgID]
if exists {
delete(p.managers, orgID)
RecordRBACManagerRemoved()
}
p.mu.Unlock()
if !exists {
return nil
}
if err := manager.Close(); err != nil {
return fmt.Errorf("failed to close RBAC manager for org %s: %w", orgID, err)
}
return nil
}
// Close closes all cached managers.
func (p *TenantRBACProvider) Close() error {
p.mu.Lock()
managers := p.managers
p.managers = make(map[string]*auth.SQLiteManager)
p.mu.Unlock()
var closeErr error
for orgID, manager := range managers {
if err := manager.Close(); err != nil {
closeErr = errors.Join(closeErr, fmt.Errorf("failed to close RBAC manager for org %s: %w", orgID, err))
}
}
ensureRBACMetrics()
rbacManagersActive.Set(0)
return closeErr
}
// ManagerCount returns the number of cached managers. Used for testing.
func (p *TenantRBACProvider) ManagerCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.managers)
}
func (p *TenantRBACProvider) ownsManager(manager auth.Manager) bool {
if manager == nil {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
for _, candidate := range p.managers {
if manager == candidate {
return true
}
}
return false
}
func (p *TenantRBACProvider) resolveDataDir(orgID string) (string, error) {
baseDataDir, err := securityutil.NormalizeStorageDir(p.baseDataDir)
if err != nil {
return "", fmt.Errorf("invalid RBAC data directory: %w", err)
}
if orgID == "default" {
return baseDataDir, nil
}
if !isValidTenantOrgID(orgID) {
return "", fmt.Errorf("invalid organization ID: %s", orgID)
}
orgsDir, err := securityutil.JoinStorageLeaf(baseDataDir, "orgs")
if err != nil {
return "", fmt.Errorf("resolve organization storage root: %w", err)
}
orgDir, err := securityutil.JoinStorageLeaf(orgsDir, orgID)
if err != nil {
return "", fmt.Errorf("resolve organization directory: %w", err)
}
stat, err := os.Lstat(orgDir)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("organization directory does not exist: %s", orgID)
}
return "", fmt.Errorf("failed to read organization directory for %s: %w", orgID, err)
}
if stat.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("organization path must not be a symlink: %s", orgID)
}
if !stat.IsDir() {
return "", fmt.Errorf("organization path is not a directory: %s", orgID)
}
return orgDir, nil
}
func normalizeOrgID(orgID string) string {
if orgID == "" {
return "default"
}
return orgID
}
func isValidTenantOrgID(orgID string) bool {
if orgID == "" || orgID == "." || orgID == ".." {
return false
}
if filepath.Base(orgID) != orgID {
return false
}
return tenantRBACOrgIDPattern.MatchString(orgID)
}