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>
This commit is contained in:
77
ai-compliance-sdk/internal/multitenant/models.go
Normal file
77
ai-compliance-sdk/internal/multitenant/models.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package multitenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TenantOverview provides a consolidated view of a tenant's compliance status
|
||||
// including scores, module highlights, and namespace information.
|
||||
type TenantOverview struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
LLMQuotaMonthly int `json:"llm_quota_monthly"`
|
||||
ComplianceScore int `json:"compliance_score"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
NamespaceCount int `json:"namespace_count"`
|
||||
|
||||
// Module highlights
|
||||
OpenIncidents int `json:"open_incidents"`
|
||||
OpenReports int `json:"open_reports"` // whistleblower
|
||||
PendingDSRs int `json:"pending_dsrs"`
|
||||
TrainingRate float64 `json:"training_completion_rate"`
|
||||
VendorRiskHigh int `json:"vendor_risk_high"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MultiTenantOverviewResponse wraps the list of tenant overviews with aggregate metrics.
|
||||
type MultiTenantOverviewResponse struct {
|
||||
Tenants []TenantOverview `json:"tenants"`
|
||||
Total int `json:"total"`
|
||||
AverageScore int `json:"average_score"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// CreateTenantRequest represents a request to create a new tenant.
|
||||
type CreateTenantRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
LLMQuotaMonthly int `json:"llm_quota_monthly"`
|
||||
}
|
||||
|
||||
// UpdateTenantRequest represents a partial update to an existing tenant.
|
||||
// Pointer fields allow distinguishing between "not provided" and "zero value".
|
||||
type UpdateTenantRequest struct {
|
||||
Name *string `json:"name"`
|
||||
MaxUsers *int `json:"max_users"`
|
||||
LLMQuotaMonthly *int `json:"llm_quota_monthly"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
// CreateNamespaceRequest represents a request to create a new namespace within a tenant.
|
||||
type CreateNamespaceRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
IsolationLevel string `json:"isolation_level"`
|
||||
DataClassification string `json:"data_classification"`
|
||||
}
|
||||
|
||||
// SwitchTenantRequest represents a request to switch the active tenant context.
|
||||
type SwitchTenantRequest struct {
|
||||
TenantID string `json:"tenant_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SwitchTenantResponse contains the tenant info needed for the frontend to switch context.
|
||||
type SwitchTenantResponse struct {
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
148
ai-compliance-sdk/internal/multitenant/store.go
Normal file
148
ai-compliance-sdk/internal/multitenant/store.go
Normal file
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user