Files
breakpilot-compliance/ai-compliance-sdk/internal/multitenant/store.go
Benjamin Boenisch 504dd3591b feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends
Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:11:27 +01:00

149 lines
5.1 KiB
Go

package multitenant
import (
"context"
"fmt"
"log"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides aggregated multi-tenant views by combining data from the
// existing RBAC store, reporting store, and direct SQL queries for module highlights.
type Store struct {
pool *pgxpool.Pool
rbacStore *rbac.Store
reportingStore *reporting.Store
}
// NewStore creates a new multi-tenant store.
func NewStore(pool *pgxpool.Pool, rbacStore *rbac.Store, reportingStore *reporting.Store) *Store {
return &Store{
pool: pool,
rbacStore: rbacStore,
reportingStore: reportingStore,
}
}
// GetOverview retrieves all tenants with their compliance scores and module highlights.
// It aggregates data from the RBAC tenant list, the reporting compliance score,
// and direct SQL counts for namespaces, incidents, reports, DSRs, training, and vendors.
// Individual query failures are tolerated and result in zero-value defaults.
func (s *Store) GetOverview(ctx context.Context) (*MultiTenantOverviewResponse, error) {
tenants, err := s.rbacStore.ListTenants(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list tenants: %w", err)
}
overviews := make([]TenantOverview, 0, len(tenants))
totalScore := 0
for _, tenant := range tenants {
overview := s.buildTenantOverview(ctx, tenant)
totalScore += overview.ComplianceScore
overviews = append(overviews, overview)
}
averageScore := 0
if len(overviews) > 0 {
averageScore = totalScore / len(overviews)
}
return &MultiTenantOverviewResponse{
Tenants: overviews,
Total: len(overviews),
AverageScore: averageScore,
GeneratedAt: time.Now().UTC(),
}, nil
}
// GetTenantDetail returns detailed compliance info for a specific tenant.
func (s *Store) GetTenantDetail(ctx context.Context, tenantID uuid.UUID) (*TenantOverview, error) {
tenant, err := s.rbacStore.GetTenant(ctx, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get tenant: %w", err)
}
overview := s.buildTenantOverview(ctx, tenant)
return &overview, nil
}
// buildTenantOverview constructs a TenantOverview by fetching compliance scores
// and module highlights for a single tenant. Errors are logged but do not
// propagate -- missing data defaults to zero values.
func (s *Store) buildTenantOverview(ctx context.Context, tenant *rbac.Tenant) TenantOverview {
overview := TenantOverview{
ID: tenant.ID,
Name: tenant.Name,
Slug: tenant.Slug,
Status: string(tenant.Status),
MaxUsers: tenant.MaxUsers,
LLMQuotaMonthly: tenant.LLMQuotaMonthly,
CreatedAt: tenant.CreatedAt,
UpdatedAt: tenant.UpdatedAt,
}
// Compliance score and risk level derived from an executive report.
// GenerateReport computes the compliance score and risk overview internally.
report, err := s.reportingStore.GenerateReport(ctx, tenant.ID)
if err != nil {
log.Printf("multitenant: failed to generate report for tenant %s: %v", tenant.ID, err)
} else {
overview.ComplianceScore = report.ComplianceScore
overview.RiskLevel = report.RiskOverview.OverallLevel
}
// Namespace count
overview.NamespaceCount = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM compliance_namespaces WHERE tenant_id = $1")
// Open incidents
overview.OpenIncidents = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM incidents WHERE tenant_id = $1 AND status IN ('new', 'investigating', 'containment')")
// Open whistleblower reports
overview.OpenReports = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 AND status IN ('new', 'acknowledged', 'investigating')")
// Pending DSR requests
overview.PendingDSRs = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM dsr_requests WHERE tenant_id = $1 AND status IN ('new', 'in_progress')")
// Training completion rate (average progress, 0-100)
overview.TrainingRate = s.avgSafe(ctx, tenant.ID,
"SELECT COALESCE(AVG(CASE WHEN status = 'completed' THEN 100.0 ELSE progress END), 0) FROM academy_enrollments WHERE tenant_id = $1")
// High-risk vendors
overview.VendorRiskHigh = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM vendors WHERE tenant_id = $1 AND risk_level = 'high'")
return overview
}
// countSafe executes a COUNT(*) query that takes a single tenant_id parameter.
// If the query fails for any reason (e.g. table does not exist), it returns 0.
func (s *Store) countSafe(ctx context.Context, tenantID uuid.UUID, query string) int {
var count int
err := s.pool.QueryRow(ctx, query, tenantID).Scan(&count)
if err != nil {
// Tolerate errors -- table may not exist or query may fail
return 0
}
return count
}
// avgSafe executes an AVG query that takes a single tenant_id parameter.
// If the query fails for any reason, it returns 0.
func (s *Store) avgSafe(ctx context.Context, tenantID uuid.UUID, query string) float64 {
var avg float64
err := s.pool.QueryRow(ctx, query, tenantID).Scan(&avg)
if err != nil {
return 0
}
return avg
}