06bfbd1dca
Build + Deploy / build-admin-compliance (push) Successful in 2m46s
Build + Deploy / build-backend-compliance (push) Successful in 26s
Build + Deploy / build-ai-sdk (push) Successful in 52s
Build + Deploy / build-developer-portal (push) Successful in 22s
Build + Deploy / build-tts (push) Successful in 16s
Build + Deploy / build-document-crawler (push) Successful in 12s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
Build + Deploy / build-dsms-node (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m16s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Successful in 29s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 16s
Build + Deploy / trigger-orca (push) Successful in 2m36s
Implements the Use-Case Compiler that turns Master Controls into interactive compliance audits. 5 templates (Vendor Check, SAST/DAST, DSGVO, NIS2, CRA), deterministic + LLM question generation, scoring engine with regulation/severity breakdown, and gap detection. - Backend: 9 API endpoints, 22 unit tests (all pass) - Frontend: Template selector, questionnaire, result dashboard - Migration 027: usecase_audits + usecase_answers tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
348 lines
9.7 KiB
Go
348 lines
9.7 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Store handles database operations for use-case audits.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore creates a new Store.
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// ── Audit CRUD ─────────────────────────────────────────────────────
|
|
|
|
// CreateAudit inserts a new audit.
|
|
func (s *Store) CreateAudit(a *Audit) error {
|
|
ctx := context.Background()
|
|
a.ID = uuid.New()
|
|
a.CreatedAt = time.Now()
|
|
a.UpdatedAt = time.Now()
|
|
a.Status = StatusDraft
|
|
|
|
questionsJSON, err := json.Marshal(a.Questions)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal questions: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO compliance.usecase_audits
|
|
(id, tenant_id, template_id, name, target_name, status,
|
|
total_questions, answered_questions, compliance_score,
|
|
questions, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
|
a.ID, a.TenantID, a.TemplateID, a.Name, a.TargetName,
|
|
a.Status, a.TotalQuestions, a.AnsweredQuestions, a.ComplianceScore,
|
|
questionsJSON, a.CreatedAt, a.UpdatedAt,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetAudit loads an audit by ID.
|
|
func (s *Store) GetAudit(id uuid.UUID) (*Audit, error) {
|
|
ctx := context.Background()
|
|
a := &Audit{}
|
|
var questionsJSON []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, tenant_id, template_id, name, target_name, status,
|
|
total_questions, answered_questions, compliance_score,
|
|
questions, created_at, updated_at, completed_at
|
|
FROM compliance.usecase_audits WHERE id = $1`, id,
|
|
).Scan(
|
|
&a.ID, &a.TenantID, &a.TemplateID, &a.Name, &a.TargetName,
|
|
&a.Status, &a.TotalQuestions, &a.AnsweredQuestions,
|
|
&a.ComplianceScore, &questionsJSON,
|
|
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(questionsJSON) > 0 {
|
|
json.Unmarshal(questionsJSON, &a.Questions)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// ListAudits returns all audits for a tenant.
|
|
func (s *Store) ListAudits(tenantID uuid.UUID) ([]Audit, error) {
|
|
ctx := context.Background()
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, template_id, name, target_name, status,
|
|
total_questions, answered_questions, compliance_score,
|
|
created_at, updated_at, completed_at
|
|
FROM compliance.usecase_audits
|
|
WHERE tenant_id = $1
|
|
ORDER BY created_at DESC`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var audits []Audit
|
|
for rows.Next() {
|
|
var a Audit
|
|
a.TenantID = tenantID
|
|
if err := rows.Scan(
|
|
&a.ID, &a.TemplateID, &a.Name, &a.TargetName, &a.Status,
|
|
&a.TotalQuestions, &a.AnsweredQuestions, &a.ComplianceScore,
|
|
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
audits = append(audits, a)
|
|
}
|
|
return audits, nil
|
|
}
|
|
|
|
// UpdateAuditScore updates the score and status of an audit.
|
|
func (s *Store) UpdateAuditScore(id uuid.UUID, answered int, score float64, status AuditStatus) error {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
query := `
|
|
UPDATE compliance.usecase_audits
|
|
SET answered_questions = $2, compliance_score = $3,
|
|
status = $4, updated_at = $5`
|
|
|
|
args := []interface{}{id, answered, score, status, now}
|
|
if status == StatusCompleted {
|
|
query += `, completed_at = $6 WHERE id = $1`
|
|
args = append(args, now)
|
|
} else {
|
|
query += ` WHERE id = $1`
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// ── Answer CRUD ────────────────────────────────────────────────────
|
|
|
|
// SaveAnswer upserts an answer (INSERT ... ON CONFLICT UPDATE).
|
|
func (s *Store) SaveAnswer(a *Answer) error {
|
|
ctx := context.Background()
|
|
a.ID = uuid.New()
|
|
a.AnsweredAt = time.Now()
|
|
|
|
answerJSON, err := json.Marshal(map[string]interface{}{
|
|
"value": a.Value,
|
|
"comment": a.Comment,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal answer: %w", err)
|
|
}
|
|
|
|
evidenceJSON, _ := json.Marshal(a.EvidenceIDs)
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO compliance.usecase_answers
|
|
(id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
ON CONFLICT (audit_id, question_id)
|
|
DO UPDATE SET answer = $5, evidence_ids = $6, status = $7, answered_at = $8`,
|
|
a.ID, a.AuditID, a.QuestionID, a.MCID,
|
|
answerJSON, evidenceJSON, a.Status, a.AnsweredAt,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ListAnswers returns all answers for an audit.
|
|
func (s *Store) ListAnswers(auditID uuid.UUID) ([]Answer, error) {
|
|
ctx := context.Background()
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at
|
|
FROM compliance.usecase_answers
|
|
WHERE audit_id = $1
|
|
ORDER BY answered_at`, auditID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var answers []Answer
|
|
for rows.Next() {
|
|
var a Answer
|
|
var answerJSON, evidenceJSON []byte
|
|
|
|
if err := rows.Scan(
|
|
&a.ID, &a.AuditID, &a.QuestionID, &a.MCID,
|
|
&answerJSON, &evidenceJSON, &a.Status, &a.AnsweredAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if json.Unmarshal(answerJSON, &payload) == nil {
|
|
a.Value = payload["value"]
|
|
if c, ok := payload["comment"].(string); ok {
|
|
a.Comment = c
|
|
}
|
|
}
|
|
json.Unmarshal(evidenceJSON, &a.EvidenceIDs)
|
|
answers = append(answers, a)
|
|
}
|
|
return answers, nil
|
|
}
|
|
|
|
// ── MC Queries ─────────────────────────────────────────────────────
|
|
|
|
// MCInfo holds minimal data about a Master Control for compilation.
|
|
type MCInfo struct {
|
|
MasterControlID string `json:"master_control_id"`
|
|
CanonicalName string `json:"canonical_name"`
|
|
TotalControls int `json:"total_controls"`
|
|
RegSource string `json:"regulation_source"`
|
|
}
|
|
|
|
// FetchMCsByFilters returns MCs whose canonical_name matches any filter pattern.
|
|
func (s *Store) FetchMCsByFilters(filters []string) ([]MCInfo, error) {
|
|
if len(filters) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build LIKE conditions from filter patterns (support trailing *)
|
|
conditions := make([]string, len(filters))
|
|
args := make([]interface{}, len(filters))
|
|
for i, f := range filters {
|
|
// Convert "third_party_management_*" → "third_party_management_%"
|
|
pattern := f
|
|
if len(pattern) > 0 && pattern[len(pattern)-1] == '*' {
|
|
pattern = pattern[:len(pattern)-1] + "%"
|
|
}
|
|
conditions[i] = fmt.Sprintf("mc.canonical_name LIKE $%d", i+1)
|
|
args[i] = pattern
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT mc.master_control_id, mc.canonical_name, mc.total_controls,
|
|
COALESCE(
|
|
(SELECT pc.source_citation::jsonb->>'source'
|
|
FROM compliance.master_control_members mcm2
|
|
JOIN compliance.canonical_controls cc2 ON cc2.id = mcm2.control_uuid
|
|
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc2.parent_control_uuid
|
|
WHERE mcm2.master_control_uuid = mc.id
|
|
AND pc.source_citation IS NOT NULL
|
|
LIMIT 1), ''
|
|
) as regulation_source
|
|
FROM compliance.master_controls mc
|
|
WHERE %s
|
|
ORDER BY mc.total_controls DESC
|
|
LIMIT 200`,
|
|
joinOr(conditions))
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch MCs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var mcs []MCInfo
|
|
for rows.Next() {
|
|
var m MCInfo
|
|
if err := rows.Scan(&m.MasterControlID, &m.CanonicalName,
|
|
&m.TotalControls, &m.RegSource); err != nil {
|
|
return nil, err
|
|
}
|
|
mcs = append(mcs, m)
|
|
}
|
|
return mcs, nil
|
|
}
|
|
|
|
// FetchCheckQuestions loads existing doc_check_controls for MCs.
|
|
func (s *Store) FetchCheckQuestions(mcIDs []string) (map[string][]CheckQuestion, error) {
|
|
if len(mcIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT control_id, check_question, pass_criteria, fail_criteria, severity
|
|
FROM compliance.doc_check_controls
|
|
WHERE control_id = ANY($1)`, mcIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string][]CheckQuestion)
|
|
for rows.Next() {
|
|
var cq CheckQuestion
|
|
if err := rows.Scan(&cq.ControlID, &cq.Question,
|
|
&cq.PassCriteria, &cq.FailCriteria, &cq.Severity); err != nil {
|
|
return nil, err
|
|
}
|
|
result[cq.ControlID] = append(result[cq.ControlID], cq)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CheckQuestion holds an existing doc_check_control question.
|
|
type CheckQuestion struct {
|
|
ControlID string `json:"control_id"`
|
|
Question string `json:"check_question"`
|
|
PassCriteria string `json:"pass_criteria"`
|
|
FailCriteria string `json:"fail_criteria"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
// CountMCSourceCitations counts controls with source_citation per MC.
|
|
func (s *Store) CountMCSourceCitations(mcIDs []string) (map[string]int, error) {
|
|
if len(mcIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT mc.master_control_id,
|
|
COUNT(CASE WHEN cc.source_citation IS NOT NULL
|
|
AND cc.source_citation != '' THEN 1 END)
|
|
FROM compliance.master_controls mc
|
|
JOIN compliance.master_control_members mcm ON mcm.master_control_uuid = mc.id
|
|
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
|
WHERE mc.master_control_id = ANY($1)
|
|
GROUP BY mc.master_control_id`, mcIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string]int)
|
|
for rows.Next() {
|
|
var id string
|
|
var count int
|
|
if err := rows.Scan(&id, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
result[id] = count
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func joinOr(conditions []string) string {
|
|
if len(conditions) == 1 {
|
|
return conditions[0]
|
|
}
|
|
result := "("
|
|
for i, c := range conditions {
|
|
if i > 0 {
|
|
result += " OR "
|
|
}
|
|
result += c
|
|
}
|
|
return result + ")"
|
|
}
|