diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index ee1a5fd22..d42215899 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -35,6 +35,9 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req return } + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) + query := r.URL.Query() filter := audit.QueryFilter{ @@ -78,8 +81,6 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req filter.Success = &success } - logger := audit.GetLogger() - // Query events from the current logger events, err := logger.Query(filter) if err != nil { @@ -102,7 +103,7 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req response := map[string]interface{}{ "events": events, "total": totalCount, - "persistentLogging": len(events) > 0 || isPersistentLogger(), + "persistentLogging": len(events) > 0 || isPersistentLogger(logger), } w.Header().Set("Content-Type", "application/json") @@ -116,6 +117,9 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re return } + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) + eventID := r.PathValue("id") if eventID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Missing event ID", nil) @@ -129,7 +133,7 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re } // For OSS, return not_available - if !isPersistentLogger() { + if !isPersistentLogger(logger) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "available": false, @@ -138,7 +142,6 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re return } - logger := audit.GetLogger() verifier, ok := logger.(interface { VerifySignature(event audit.Event) bool }) @@ -181,7 +184,8 @@ func (h *AuditHandlers) HandleGetWebhooks(w http.ResponseWriter, r *http.Request return } - logger := audit.GetLogger() + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "urls": logger.GetWebhookURLs(), @@ -216,7 +220,8 @@ func (h *AuditHandlers) HandleUpdateWebhooks(w http.ResponseWriter, r *http.Requ validatedURLs = append(validatedURLs, rawURL) } - logger := audit.GetLogger() + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) if err := logger.UpdateWebhookURLs(validatedURLs); err != nil { writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update webhooks", nil) return @@ -324,8 +329,7 @@ func isPrivateOrReservedIP(ip net.IP) bool { } // isPersistentLogger checks if we're using a persistent audit logger (enterprise). -func isPersistentLogger() bool { - logger := audit.GetLogger() +func isPersistentLogger(logger audit.Logger) bool { _, isConsole := logger.(*audit.ConsoleLogger) return !isConsole } @@ -337,8 +341,11 @@ func (h *AuditHandlers) HandleExportAuditEvents(w http.ResponseWriter, r *http.R return } + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) + // Check if persistent logger is available - if !isPersistentLogger() { + if !isPersistentLogger(logger) { writeErrorResponse(w, http.StatusNotImplemented, "export_unavailable", "Export requires Pulse Pro with enterprise audit logging", nil) return @@ -381,8 +388,6 @@ func (h *AuditHandlers) HandleExportAuditEvents(w http.ResponseWriter, r *http.R // Parse verification flag includeVerification := query.Get("verify") == "true" - // Get the logger and check if it's persistent - logger := audit.GetLogger() persistentLogger, ok := logger.(audit.PersistentLogger) if !ok { writeErrorResponse(w, http.StatusNotImplemented, "export_unavailable", @@ -413,8 +418,11 @@ func (h *AuditHandlers) HandleAuditSummary(w http.ResponseWriter, r *http.Reques return } + orgID := GetOrgID(r.Context()) + logger := getLoggerForOrg(orgID) + // Check if persistent logger is available - if !isPersistentLogger() { + if !isPersistentLogger(logger) { writeErrorResponse(w, http.StatusNotImplemented, "summary_unavailable", "Summary requires Pulse Pro with enterprise audit logging", nil) return @@ -445,8 +453,6 @@ func (h *AuditHandlers) HandleAuditSummary(w http.ResponseWriter, r *http.Reques // Parse verification flag verifySignatures := query.Get("verify") == "true" - // Get the logger and check if it's persistent - logger := audit.GetLogger() persistentLogger, ok := logger.(audit.PersistentLogger) if !ok { writeErrorResponse(w, http.StatusNotImplemented, "summary_unavailable", @@ -466,3 +472,10 @@ func (h *AuditHandlers) HandleAuditSummary(w http.ResponseWriter, r *http.Reques w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(summary) } + +func getLoggerForOrg(orgID string) audit.Logger { + if mgr := GetTenantAuditManager(); mgr != nil { + return mgr.GetLogger(orgID) + } + return audit.GetLogger() +} diff --git a/internal/api/audit_reporting_scope_test.go b/internal/api/audit_reporting_scope_test.go index c515ebd77..b8e5cd41a 100644 --- a/internal/api/audit_reporting_scope_test.go +++ b/internal/api/audit_reporting_scope_test.go @@ -15,11 +15,13 @@ func TestAuditEndpointsRequireSettingsReadScope(t *testing.T) { rawToken := "audit-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) - router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0", nil) paths := []string{ "/api/audit", "/api/audit/event-1/verify", + "/api/audit/export", + "/api/audit/summary", } for _, path := range paths { @@ -42,7 +44,7 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) { rawToken := "reports-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) - router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0", nil) paths := []string{ "/api/admin/reports/generate", @@ -70,7 +72,7 @@ func TestAuditWebhooksRequireSettingsScopes(t *testing.T) { rawToken := "audit-webhooks-read-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) - router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0", nil) req := httptest.NewRequest(http.MethodGet, "/api/admin/webhooks/audit", nil) req.Header.Set("X-API-Token", rawToken) @@ -88,7 +90,7 @@ func TestAuditWebhooksRequireSettingsScopes(t *testing.T) { rawToken := "audit-webhooks-write-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) - router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0", nil) req := httptest.NewRequest(http.MethodPost, "/api/admin/webhooks/audit", strings.NewReader(`{"urls":[]}`)) req.Header.Set("X-API-Token", rawToken) diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 1f98c00e2..26c67a58a 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -11,7 +11,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/license/entitlements" - "github.com/rcourtman/pulse-go-rewrite/pkg/audit" "github.com/rs/zerolog/log" ) @@ -21,8 +20,6 @@ type LicenseHandlers struct { mtPersistence *config.MultiTenantPersistence hostedMode bool services sync.Map // map[string]*license.Service - configDir string // Base config dir, though we use mtPersistence for tenants - auditOnce sync.Once } // NewLicenseHandlers creates a new license handlers instance. @@ -79,11 +76,6 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*license.Ser lic.GracePeriodEnd = &gracePeriodEnd } log.Info().Str("org_id", orgID).Msg("Loaded saved Pulse Pro license") - - // Initialize audit logger (globally) if licensed - // This is a trade-off: if ANY tenant is licensed, we enable audit logging globally (or for that path?) - // Since audit logger is global, we do this once. - h.initAuditLoggerIfLicensed(service, persistence) } } } @@ -100,45 +92,6 @@ func (h *LicenseHandlers) getPersistenceForOrg(orgID string) (*license.Persisten return license.NewPersistence(configPersistence.GetConfigDir()) } -// initAuditLoggerIfLicensed initializes the SQLite audit logger if the license -// includes the audit_logging feature. This enables persistent audit logs with -// HMAC signing for Pro users. -func (h *LicenseHandlers) initAuditLoggerIfLicensed(service *license.Service, persistence *license.Persistence) { - if !service.HasFeature(license.FeatureAuditLogging) { - return - } - - h.auditOnce.Do(func() { - // Check if we already have a SQLiteLogger (avoid re-initialization) - if _, ok := audit.GetLogger().(*audit.SQLiteLogger); ok { - return - } - - // Use the directory of the license persistence as base? - // Or stick to the first tenant's dir? Or global? - // For now, let's use the directory where this license was found. - // Note: This relies on license.Persistence exposing methods or we assume logic. - // Since license.Persistence doesn't expose dir, we might need a workaround or pass dir. - // But in getTenantComponents we construct persistence from configDir. - // We'll trust audit.NewSQLiteLogger to handle it. - // Wait, we don't have configDir easily here unless we pass it. - // But we can assume audit should go to the same place as the license. - // Actually, let's just use the `configDir` passed to NewLicenseHandlers? - // No, we removed it. - // We'll use the directory from the persistence if possible, or just default. - // Let's assume passed persistence knows its path? No. - // We'll skip passing dir for now and rely on global settings or revisit. - // Wait, audit.NewSQLiteLogger NEEDS a DataDir. - // I'll grab it from the calling context in getTenantComponents? - // Refactoring: getTenantComponents calls getPersistenceForOrg which uses configPersistence.GetConfigDir(). - // I'll assume we can use that directory. - }) - - // Re-check lock outside Once to avoid blocking, but for simplicity: - // If Global logger is already set, we are good. - // NOTE: We are merely enabling it. -} - // Service returns the license service for use by other handlers. // NOTE: This now requires context to identify the tenant. // Handlers using this will need to be updated. @@ -299,9 +252,6 @@ func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.R Bool("lifetime", lic.IsLifetime()). Msg("Pulse Pro license activated") - // Initialize audit logger if the new license has audit_logging feature - h.initAuditLoggerIfLicensed(service, persistence) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ActivateLicenseResponse{ Success: true, diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 4f129443d..e4a53052c 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -224,6 +224,9 @@ var publicRouteAllowlist = []string{ "/api/security/quick-setup", "/api/login", "/api/oidc/login", + "/api/public/signup", + "/api/public/magic-link/request", + "/api/public/magic-link/verify", "/api/ai/oauth/callback", "/api/setup-script", "/api/system/verify-temperature-ssh", @@ -405,6 +408,8 @@ var allRouteAllowlist = []string{ "GET /api/audit", "GET /api/audit/", "GET /api/audit/{id}/verify", + "GET /api/audit/export", + "GET /api/audit/summary", "GET /api/admin/orgs/{id}/billing-state", "PUT /api/admin/orgs/{id}/billing-state", "POST /api/admin/orgs/{id}/suspend", diff --git a/internal/api/router_routes_org_license.go b/internal/api/router_routes_org_license.go index 39d8100d5..b50630c2c 100644 --- a/internal/api/router_routes_org_license.go +++ b/internal/api/router_routes_org_license.go @@ -13,9 +13,10 @@ import ( func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHandlers *RBACHandlers, auditHandlers *AuditHandlers) { conversionConfig := conversion.NewCollectionConfig() conversionHandlers := NewConversionHandlers( - conversion.NewRecorder(metering.NewWindowedAggregator()), + conversion.NewRecorder(metering.NewWindowedAggregator(), r.conversionStore), conversion.NewPipelineHealth(), conversionConfig, + r.conversionStore, ) // License routes (Pulse Pro) @@ -29,6 +30,7 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("GET /api/conversion/health", RequireAuth(r.config, conversionHandlers.HandleGetHealth)) r.mux.HandleFunc("GET /api/conversion/config", RequireAuth(r.config, conversionHandlers.HandleGetConfig)) r.mux.HandleFunc("PUT /api/conversion/config", RequireAuth(r.config, conversionHandlers.HandleUpdateConfig)) + r.mux.HandleFunc("GET /api/admin/conversion-funnel", RequireAdmin(r.config, conversionHandlers.HandleConversionFunnel)) // Organization routes (multi-tenant foundation) r.mux.HandleFunc("GET /api/orgs", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListOrgs))) @@ -48,6 +50,8 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("GET /api/audit", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers, license.FeatureAuditLogging, RequireScope(config.ScopeSettingsRead, auditHandlers.HandleListAuditEvents)))) r.mux.HandleFunc("GET /api/audit/", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers, license.FeatureAuditLogging, RequireScope(config.ScopeSettingsRead, auditHandlers.HandleListAuditEvents)))) r.mux.HandleFunc("GET /api/audit/{id}/verify", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers, license.FeatureAuditLogging, RequireScope(config.ScopeSettingsRead, auditHandlers.HandleVerifyAuditEvent)))) + r.mux.HandleFunc("GET /api/audit/export", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers, license.FeatureAuditLogging, RequireScope(config.ScopeSettingsRead, auditHandlers.HandleExportAuditEvents)))) + r.mux.HandleFunc("GET /api/audit/summary", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers, license.FeatureAuditLogging, RequireScope(config.ScopeSettingsRead, auditHandlers.HandleAuditSummary)))) // RBAC routes (Phase 2 - Enterprise feature) r.mux.HandleFunc("/api/admin/roles", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers, license.FeatureRBAC, rbacHandlers.HandleRoles))) diff --git a/pkg/audit/sqlite_factory.go b/pkg/audit/sqlite_factory.go new file mode 100644 index 000000000..213257ee5 --- /dev/null +++ b/pkg/audit/sqlite_factory.go @@ -0,0 +1,52 @@ +package audit + +import ( + "fmt" + "path/filepath" +) + +// SQLiteLoggerFactory creates SQLite-backed audit loggers for tenant databases. +// +// The TenantLoggerManager passes a dbPath like "/orgs//audit.db". +// NewSQLiteLogger expects a DataDir, and creates the database at: +// +// /audit/audit.db +// +// This factory bridges that mismatch by extracting the directory from dbPath. +type SQLiteLoggerFactory struct { + // CryptoMgr is used to enable HMAC signing (optional). + // If nil, signing is disabled and signatures will be empty. + CryptoMgr CryptoEncryptor + + // CryptoMgrForDataDir optionally provides a per-tenant crypto manager based on DataDir. + // If set, it takes precedence over CryptoMgr. + CryptoMgrForDataDir func(dataDir string) (CryptoEncryptor, error) + + // RetentionDays controls how long audit events are retained (0 uses SQLiteLogger defaults). + RetentionDays int +} + +func (f *SQLiteLoggerFactory) CreateLogger(dbPath string) (Logger, error) { + if filepath.Clean(dbPath) == "." || dbPath == "" { + return nil, fmt.Errorf("db path is required") + } + + dataDir := filepath.Dir(dbPath) + + cfg := SQLiteLoggerConfig{ + DataDir: dataDir, + RetentionDays: f.RetentionDays, + } + + if f.CryptoMgrForDataDir != nil { + cm, err := f.CryptoMgrForDataDir(dataDir) + if err != nil { + return nil, err + } + cfg.CryptoMgr = cm + } else { + cfg.CryptoMgr = f.CryptoMgr + } + + return NewSQLiteLogger(cfg) +} diff --git a/pkg/audit/sqlite_factory_test.go b/pkg/audit/sqlite_factory_test.go new file mode 100644 index 000000000..dc18cb8d0 --- /dev/null +++ b/pkg/audit/sqlite_factory_test.go @@ -0,0 +1,119 @@ +package audit + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestSQLiteLoggerFactory_CreateLogger_PersistsAndSigns(t *testing.T) { + baseDir := t.TempDir() + + factory := &SQLiteLoggerFactory{ + CryptoMgr: newMockCryptoManager(), + } + + dbPath := filepath.Join(baseDir, "orgs", "org-a", "audit.db") + logger, err := factory.CreateLogger(dbPath) + if err != nil { + t.Fatalf("CreateLogger failed: %v", err) + } + defer logger.Close() + + // Ensure the expected DB path exists (NewSQLiteLogger always creates /audit/audit.db). + expectedDB := filepath.Join(filepath.Dir(dbPath), "audit", "audit.db") + if _, err := os.Stat(expectedDB); err != nil { + t.Fatalf("expected audit db to exist at %q: %v", expectedDB, err) + } + + event := Event{ + ID: uuid.NewString(), + Timestamp: time.Now().UTC(), + EventType: "factory_test", + User: "tester", + IP: "127.0.0.1", + Path: "/api/test", + Success: true, + Details: "hello", + } + + if err := logger.Log(event); err != nil { + t.Fatalf("Log failed: %v", err) + } + + events, err := logger.Query(QueryFilter{ID: event.ID, Limit: 1}) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Signature == "" { + t.Fatalf("expected signature to be present (signing enabled)") + } + + verifier, ok := logger.(interface { + VerifySignature(Event) bool + }) + if !ok { + t.Fatalf("expected factory logger to support signature verification") + } + if !verifier.VerifySignature(events[0]) { + t.Fatalf("expected persisted event signature to verify") + } +} + +func TestSQLiteLoggerFactory_TenantIsolation(t *testing.T) { + baseDir := t.TempDir() + + factory := &SQLiteLoggerFactory{ + CryptoMgr: newMockCryptoManager(), + } + + mgr := NewTenantLoggerManager(baseDir, factory) + defer mgr.Close() + + // Write to org-a. + if err := mgr.Log("org-a", "login", "alice", "10.0.0.1", "/api/login", true, "ok"); err != nil { + t.Fatalf("org-a Log failed: %v", err) + } + + // Ensure org-b can't see org-a's events. + bEvents, err := mgr.Query("org-b", QueryFilter{Limit: 100}) + if err != nil { + t.Fatalf("org-b Query failed: %v", err) + } + if len(bEvents) != 0 { + t.Fatalf("expected org-b to have 0 events, got %d", len(bEvents)) + } + + // Write to org-b. + if err := mgr.Log("org-b", "login", "bob", "10.0.0.2", "/api/login", true, "ok"); err != nil { + t.Fatalf("org-b Log failed: %v", err) + } + + aEvents, err := mgr.Query("org-a", QueryFilter{Limit: 100}) + if err != nil { + t.Fatalf("org-a Query failed: %v", err) + } + if len(aEvents) != 1 { + t.Fatalf("expected org-a to have 1 event, got %d", len(aEvents)) + } + if aEvents[0].User != "alice" { + t.Fatalf("expected org-a user %q, got %q", "alice", aEvents[0].User) + } + + bEvents, err = mgr.Query("org-b", QueryFilter{Limit: 100}) + if err != nil { + t.Fatalf("org-b Query failed: %v", err) + } + if len(bEvents) != 1 { + t.Fatalf("expected org-b to have 1 event, got %d", len(bEvents)) + } + if bEvents[0].User != "bob" { + t.Fatalf("expected org-b user %q, got %q", "bob", bEvents[0].User) + } +} diff --git a/pkg/audit/tenant_logger.go b/pkg/audit/tenant_logger.go index 5405d5a93..876e16d38 100644 --- a/pkg/audit/tenant_logger.go +++ b/pkg/audit/tenant_logger.go @@ -3,12 +3,14 @@ package audit import ( "path/filepath" "sync" + "time" + "github.com/google/uuid" "github.com/rs/zerolog/log" ) // TenantLoggerManager manages per-tenant audit loggers. -// Each tenant gets their own isolated audit database at /audit.db +// Each tenant gets their own isolated audit database at /audit/audit.db type TenantLoggerManager struct { mu sync.RWMutex loggers map[string]Logger @@ -93,6 +95,8 @@ func (m *TenantLoggerManager) GetLogger(orgID string) Logger { func (m *TenantLoggerManager) Log(orgID, eventType, user, ip, path string, success bool, details string) error { logger := m.GetLogger(orgID) event := Event{ + ID: uuid.NewString(), + Timestamp: time.Now(), EventType: eventType, User: user, IP: ip, diff --git a/pkg/server/server.go b/pkg/server/server.go index 38eef21a2..09008a62a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -18,8 +18,10 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/api" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/crypto" "github.com/rcourtman/pulse-go-rewrite/internal/hosted" "github.com/rcourtman/pulse-go-rewrite/internal/license" + "github.com/rcourtman/pulse-go-rewrite/internal/license/conversion" "github.com/rcourtman/pulse-go-rewrite/internal/logging" _ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" @@ -91,12 +93,13 @@ func Run(ctx context.Context, version string) error { // Initialize license public key for Pro feature validation license.InitPublicKey() + // Multi-tenant persistence is the canonical way to resolve the base data directory. + // It uses cfg.DataPath, which already includes PULSE_DATA_DIR overrides. + mtPersistence := config.NewMultiTenantPersistence(cfg.DataPath) + baseDataDir := mtPersistence.BaseDataDir() + // Initialize RBAC manager for role-based access control - dataDir := os.Getenv("PULSE_DATA_DIR") - if dataDir == "" { - dataDir = "/etc/pulse" - } - rbacManager, err := auth.NewFileManager(dataDir) + rbacManager, err := auth.NewFileManager(baseDataDir) if err != nil { log.Warn().Err(err).Msg("Failed to initialize RBAC manager, role management will be unavailable") } else { @@ -107,14 +110,55 @@ func Run(ctx context.Context, version string) error { // Run multi-tenant data migration only when the feature is explicitly enabled. // This prevents any on-disk layout changes for default (single-tenant) users. if api.IsMultiTenantEnabled() { - if err := config.RunMigrationIfNeeded(dataDir); err != nil { + if err := config.RunMigrationIfNeeded(baseDataDir); err != nil { log.Error().Err(err).Msg("Multi-tenant data migration failed") // Continue anyway - migration failure shouldn't block startup } } + // Conversion telemetry must be durable and tenant-aware (P0-6). + conversionStore, err := conversion.NewConversionStore(filepath.Join(baseDataDir, "conversion.db")) + if err != nil { + return fmt.Errorf("failed to initialize conversion telemetry store: %w", err) + } + conversionStoreClosed := false + closeConversionStore := func() { + if conversionStoreClosed { + return + } + conversionStoreClosed = true + if err := conversionStore.Close(); err != nil { + log.Error().Err(err).Msg("Failed to close conversion store") + } + } + defer closeConversionStore() + + // Always capture audit events to SQLite (defense in depth). Read/export endpoints are license-gated. + // For the default org, TenantLoggerManager routes to the global logger, so initialize it as SQLite too. + var globalCrypto audit.CryptoEncryptor + if cm, err := crypto.NewCryptoManagerAt(baseDataDir); err != nil { + log.Warn().Err(err).Str("data_dir", baseDataDir).Msg("Failed to initialize crypto manager for audit signing; signatures will be disabled") + } else { + globalCrypto = cm + if sqliteLogger, err := audit.NewSQLiteLogger(audit.SQLiteLoggerConfig{ + DataDir: baseDataDir, + CryptoMgr: cm, + }); err != nil { + log.Warn().Err(err).Str("data_dir", baseDataDir).Msg("Failed to initialize global SQLite audit logger; falling back to console logger") + } else { + audit.SetLogger(sqliteLogger) + } + } + // Initialize tenant audit manager for per-tenant audit logging - tenantAuditManager := audit.NewTenantLoggerManager(dataDir, nil) + tenantAuditManager := audit.NewTenantLoggerManager(baseDataDir, &audit.SQLiteLoggerFactory{ + // Prefer per-tenant crypto managers so each org has its own .encryption.key. + CryptoMgrForDataDir: func(dataDir string) (audit.CryptoEncryptor, error) { + return crypto.NewCryptoManagerAt(dataDir) + }, + // Fallback for environments where per-tenant crypto initialization fails. + CryptoMgr: globalCrypto, + }) api.SetTenantAuditManager(tenantAuditManager) log.Info().Msg("Tenant audit manager initialized") @@ -155,7 +199,6 @@ func Run(ctx context.Context, version string) error { go wsHub.Run() // Initialize reloadable monitoring system - mtPersistence := config.NewMultiTenantPersistence(cfg.DataPath) reloadableMonitor, err := monitoring.NewReloadableMonitor(cfg, mtPersistence, wsHub) if err != nil { return fmt.Errorf("failed to initialize monitoring system: %w", err) @@ -256,7 +299,7 @@ func Run(ctx context.Context, version string) error { } return nil } - router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), reloadableMonitor.GetMultiTenantMonitor(), wsHub, reloadFunc, version) + router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), reloadableMonitor.GetMultiTenantMonitor(), wsHub, reloadFunc, version, conversionStore) // Inject resource store into monitor for WebSocket broadcasts router.SetMonitor(reloadableMonitor.GetMonitor()) @@ -410,6 +453,7 @@ shutdown: if err := audit.Close(); err != nil { log.Error().Err(err).Msg("Failed to close audit logger") } + closeConversionStore() log.Info().Msg("Server stopped") return nil