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>
149 lines
5.1 KiB
Go
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
|
|
}
|