mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
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.
209 lines
5.2 KiB
Go
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)
|
|
}
|