All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 44s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s
Drafting Engine: 7-module pipeline with narrative tags, allowed facts governance, PII sanitizer, prose validator with repair loop, hash-based cache, and terminology guide. v1 fallback via ?v=1 query param. IACE: Initial AI-Act Conformity Engine with risk classifier, completeness checker, hazard library, and PostgreSQL store for AI system assessments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1778 lines
51 KiB
Go
1778 lines
51 KiB
Go
package iace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Store handles IACE data persistence using PostgreSQL
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore creates a new IACE store
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Project CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateProject creates a new IACE project
|
|
func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req CreateProjectRequest) (*Project, error) {
|
|
project := &Project{
|
|
ID: uuid.New(),
|
|
TenantID: tenantID,
|
|
MachineName: req.MachineName,
|
|
MachineType: req.MachineType,
|
|
Manufacturer: req.Manufacturer,
|
|
Description: req.Description,
|
|
NarrativeText: req.NarrativeText,
|
|
Status: ProjectStatusDraft,
|
|
CEMarkingTarget: req.CEMarkingTarget,
|
|
Metadata: req.Metadata,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_projects (
|
|
id, tenant_id, machine_name, machine_type, manufacturer,
|
|
description, narrative_text, status, ce_marking_target,
|
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
|
created_at, updated_at, archived_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9,
|
|
$10, $11, $12, $13,
|
|
$14, $15, $16
|
|
)
|
|
`,
|
|
project.ID, project.TenantID, project.MachineName, project.MachineType, project.Manufacturer,
|
|
project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget,
|
|
project.CompletenessScore, nil, project.TriggeredRegulations, project.Metadata,
|
|
project.CreatedAt, project.UpdatedAt, project.ArchivedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create project: %w", err)
|
|
}
|
|
|
|
return project, nil
|
|
}
|
|
|
|
// GetProject retrieves a project by ID
|
|
func (s *Store) GetProject(ctx context.Context, id uuid.UUID) (*Project, error) {
|
|
var p Project
|
|
var status string
|
|
var riskSummary, triggeredRegulations, metadata []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, tenant_id, machine_name, machine_type, manufacturer,
|
|
description, narrative_text, status, ce_marking_target,
|
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
|
created_at, updated_at, archived_at
|
|
FROM iace_projects WHERE id = $1
|
|
`, id).Scan(
|
|
&p.ID, &p.TenantID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
|
&p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
|
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
|
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get project: %w", err)
|
|
}
|
|
|
|
p.Status = ProjectStatus(status)
|
|
json.Unmarshal(riskSummary, &p.RiskSummary)
|
|
json.Unmarshal(triggeredRegulations, &p.TriggeredRegulations)
|
|
json.Unmarshal(metadata, &p.Metadata)
|
|
|
|
return &p, nil
|
|
}
|
|
|
|
// ListProjects lists all projects for a tenant
|
|
func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, tenant_id, machine_name, machine_type, manufacturer,
|
|
description, narrative_text, status, ce_marking_target,
|
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
|
created_at, updated_at, archived_at
|
|
FROM iace_projects WHERE tenant_id = $1
|
|
ORDER BY created_at DESC
|
|
`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list projects: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var projects []Project
|
|
for rows.Next() {
|
|
var p Project
|
|
var status string
|
|
var riskSummary, triggeredRegulations, metadata []byte
|
|
|
|
err := rows.Scan(
|
|
&p.ID, &p.TenantID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
|
&p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
|
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
|
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list projects scan: %w", err)
|
|
}
|
|
|
|
p.Status = ProjectStatus(status)
|
|
json.Unmarshal(riskSummary, &p.RiskSummary)
|
|
json.Unmarshal(triggeredRegulations, &p.TriggeredRegulations)
|
|
json.Unmarshal(metadata, &p.Metadata)
|
|
|
|
projects = append(projects, p)
|
|
}
|
|
|
|
return projects, nil
|
|
}
|
|
|
|
// UpdateProject updates an existing project's mutable fields
|
|
func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProjectRequest) (*Project, error) {
|
|
// Fetch current project first
|
|
project, err := s.GetProject(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if project == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Apply partial updates
|
|
if req.MachineName != nil {
|
|
project.MachineName = *req.MachineName
|
|
}
|
|
if req.MachineType != nil {
|
|
project.MachineType = *req.MachineType
|
|
}
|
|
if req.Manufacturer != nil {
|
|
project.Manufacturer = *req.Manufacturer
|
|
}
|
|
if req.Description != nil {
|
|
project.Description = *req.Description
|
|
}
|
|
if req.NarrativeText != nil {
|
|
project.NarrativeText = *req.NarrativeText
|
|
}
|
|
if req.CEMarkingTarget != nil {
|
|
project.CEMarkingTarget = *req.CEMarkingTarget
|
|
}
|
|
if req.Metadata != nil {
|
|
project.Metadata = *req.Metadata
|
|
}
|
|
|
|
project.UpdatedAt = time.Now().UTC()
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE iace_projects SET
|
|
machine_name = $2, machine_type = $3, manufacturer = $4,
|
|
description = $5, narrative_text = $6, ce_marking_target = $7,
|
|
metadata = $8, updated_at = $9
|
|
WHERE id = $1
|
|
`,
|
|
id, project.MachineName, project.MachineType, project.Manufacturer,
|
|
project.Description, project.NarrativeText, project.CEMarkingTarget,
|
|
project.Metadata, project.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update project: %w", err)
|
|
}
|
|
|
|
return project, nil
|
|
}
|
|
|
|
// ArchiveProject sets the archived_at timestamp and status for a project
|
|
func (s *Store) ArchiveProject(ctx context.Context, id uuid.UUID) error {
|
|
now := time.Now().UTC()
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE iace_projects SET
|
|
status = $2, archived_at = $3, updated_at = $3
|
|
WHERE id = $1
|
|
`, id, string(ProjectStatusArchived), now)
|
|
if err != nil {
|
|
return fmt.Errorf("archive project: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateProjectStatus updates the lifecycle status of a project
|
|
func (s *Store) UpdateProjectStatus(ctx context.Context, id uuid.UUID, status ProjectStatus) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE iace_projects SET status = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, id, string(status))
|
|
if err != nil {
|
|
return fmt.Errorf("update project status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateProjectCompleteness updates the completeness score and risk summary
|
|
func (s *Store) UpdateProjectCompleteness(ctx context.Context, id uuid.UUID, score float64, riskSummary map[string]int) error {
|
|
riskSummaryJSON, err := json.Marshal(riskSummary)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal risk summary: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE iace_projects SET
|
|
completeness_score = $2, risk_summary = $3, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, id, score, riskSummaryJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("update project completeness: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Component CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateComponent creates a new component within a project
|
|
func (s *Store) CreateComponent(ctx context.Context, req CreateComponentRequest) (*Component, error) {
|
|
comp := &Component{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
ParentID: req.ParentID,
|
|
Name: req.Name,
|
|
ComponentType: req.ComponentType,
|
|
Version: req.Version,
|
|
Description: req.Description,
|
|
IsSafetyRelevant: req.IsSafetyRelevant,
|
|
IsNetworked: req.IsNetworked,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_components (
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
metadata, sort_order, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9,
|
|
$10, $11, $12, $13
|
|
)
|
|
`,
|
|
comp.ID, comp.ProjectID, comp.ParentID, comp.Name, string(comp.ComponentType),
|
|
comp.Version, comp.Description, comp.IsSafetyRelevant, comp.IsNetworked,
|
|
comp.Metadata, comp.SortOrder, comp.CreatedAt, comp.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create component: %w", err)
|
|
}
|
|
|
|
return comp, nil
|
|
}
|
|
|
|
// GetComponent retrieves a component by ID
|
|
func (s *Store) GetComponent(ctx context.Context, id uuid.UUID) (*Component, error) {
|
|
var c Component
|
|
var compType string
|
|
var metadata []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
metadata, sort_order, created_at, updated_at
|
|
FROM iace_components WHERE id = $1
|
|
`, id).Scan(
|
|
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
|
|
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
|
|
&metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get component: %w", err)
|
|
}
|
|
|
|
c.ComponentType = ComponentType(compType)
|
|
json.Unmarshal(metadata, &c.Metadata)
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
// ListComponents lists all components for a project
|
|
func (s *Store) ListComponents(ctx context.Context, projectID uuid.UUID) ([]Component, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
metadata, sort_order, created_at, updated_at
|
|
FROM iace_components WHERE project_id = $1
|
|
ORDER BY sort_order ASC, created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list components: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var components []Component
|
|
for rows.Next() {
|
|
var c Component
|
|
var compType string
|
|
var metadata []byte
|
|
|
|
err := rows.Scan(
|
|
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
|
|
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
|
|
&metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list components scan: %w", err)
|
|
}
|
|
|
|
c.ComponentType = ComponentType(compType)
|
|
json.Unmarshal(metadata, &c.Metadata)
|
|
|
|
components = append(components, c)
|
|
}
|
|
|
|
return components, nil
|
|
}
|
|
|
|
// UpdateComponent updates a component with a dynamic set of fields
|
|
func (s *Store) UpdateComponent(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Component, error) {
|
|
if len(updates) == 0 {
|
|
return s.GetComponent(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_components SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "name", "version", "description":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "component_type":
|
|
query += fmt.Sprintf(", component_type = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "is_safety_relevant":
|
|
query += fmt.Sprintf(", is_safety_relevant = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "is_networked":
|
|
query += fmt.Sprintf(", is_networked = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "sort_order":
|
|
query += fmt.Sprintf(", sort_order = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "metadata":
|
|
metaJSON, _ := json.Marshal(val)
|
|
query += fmt.Sprintf(", metadata = $%d", argIdx)
|
|
args = append(args, metaJSON)
|
|
argIdx++
|
|
case "parent_id":
|
|
query += fmt.Sprintf(", parent_id = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update component: %w", err)
|
|
}
|
|
|
|
return s.GetComponent(ctx, id)
|
|
}
|
|
|
|
// DeleteComponent deletes a component by ID
|
|
func (s *Store) DeleteComponent(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.pool.Exec(ctx, "DELETE FROM iace_components WHERE id = $1", id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete component: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Classification Operations
|
|
// ============================================================================
|
|
|
|
// UpsertClassification inserts or updates a regulatory classification for a project
|
|
func (s *Store) UpsertClassification(ctx context.Context, projectID uuid.UUID, regulation RegulationType, result string, riskLevel string, confidence float64, reasoning string, ragSources, requirements json.RawMessage) (*RegulatoryClassification, error) {
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_classifications (
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8, $9,
|
|
$10, $11
|
|
)
|
|
ON CONFLICT (project_id, regulation)
|
|
DO UPDATE SET
|
|
classification_result = EXCLUDED.classification_result,
|
|
risk_level = EXCLUDED.risk_level,
|
|
confidence = EXCLUDED.confidence,
|
|
reasoning = EXCLUDED.reasoning,
|
|
rag_sources = EXCLUDED.rag_sources,
|
|
requirements = EXCLUDED.requirements,
|
|
updated_at = EXCLUDED.updated_at
|
|
`,
|
|
id, projectID, string(regulation), result,
|
|
riskLevel, confidence, reasoning,
|
|
ragSources, requirements,
|
|
now, now,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert classification: %w", err)
|
|
}
|
|
|
|
// Retrieve the upserted row (may have kept the original ID on conflict)
|
|
return s.getClassificationByProjectAndRegulation(ctx, projectID, regulation)
|
|
}
|
|
|
|
// getClassificationByProjectAndRegulation is a helper to fetch a single classification
|
|
func (s *Store) getClassificationByProjectAndRegulation(ctx context.Context, projectID uuid.UUID, regulation RegulationType) (*RegulatoryClassification, error) {
|
|
var c RegulatoryClassification
|
|
var reg, rl string
|
|
var ragSources, requirements []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
FROM iace_classifications
|
|
WHERE project_id = $1 AND regulation = $2
|
|
`, projectID, string(regulation)).Scan(
|
|
&c.ID, &c.ProjectID, ®, &c.ClassificationResult,
|
|
&rl, &c.Confidence, &c.Reasoning,
|
|
&ragSources, &requirements,
|
|
&c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classification: %w", err)
|
|
}
|
|
|
|
c.Regulation = RegulationType(reg)
|
|
c.RiskLevel = RiskLevel(rl)
|
|
json.Unmarshal(ragSources, &c.RAGSources)
|
|
json.Unmarshal(requirements, &c.Requirements)
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
// GetClassifications retrieves all classifications for a project
|
|
func (s *Store) GetClassifications(ctx context.Context, projectID uuid.UUID) ([]RegulatoryClassification, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
FROM iace_classifications
|
|
WHERE project_id = $1
|
|
ORDER BY regulation ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classifications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var classifications []RegulatoryClassification
|
|
for rows.Next() {
|
|
var c RegulatoryClassification
|
|
var reg, rl string
|
|
var ragSources, requirements []byte
|
|
|
|
err := rows.Scan(
|
|
&c.ID, &c.ProjectID, ®, &c.ClassificationResult,
|
|
&rl, &c.Confidence, &c.Reasoning,
|
|
&ragSources, &requirements,
|
|
&c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classifications scan: %w", err)
|
|
}
|
|
|
|
c.Regulation = RegulationType(reg)
|
|
c.RiskLevel = RiskLevel(rl)
|
|
json.Unmarshal(ragSources, &c.RAGSources)
|
|
json.Unmarshal(requirements, &c.Requirements)
|
|
|
|
classifications = append(classifications, c)
|
|
}
|
|
|
|
return classifications, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hazard CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateHazard creates a new hazard within a project
|
|
func (s *Store) CreateHazard(ctx context.Context, req CreateHazardRequest) (*Hazard, error) {
|
|
h := &Hazard{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
ComponentID: req.ComponentID,
|
|
LibraryHazardID: req.LibraryHazardID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Scenario: req.Scenario,
|
|
Category: req.Category,
|
|
Status: HazardStatusIdentified,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_hazards (
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, status,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7, $8, $9,
|
|
$10, $11
|
|
)
|
|
`,
|
|
h.ID, h.ProjectID, h.ComponentID, h.LibraryHazardID,
|
|
h.Name, h.Description, h.Scenario, h.Category, string(h.Status),
|
|
h.CreatedAt, h.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create hazard: %w", err)
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// GetHazard retrieves a hazard by ID
|
|
func (s *Store) GetHazard(ctx context.Context, id uuid.UUID) (*Hazard, error) {
|
|
var h Hazard
|
|
var status string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, status,
|
|
created_at, updated_at
|
|
FROM iace_hazards WHERE id = $1
|
|
`, id).Scan(
|
|
&h.ID, &h.ProjectID, &h.ComponentID, &h.LibraryHazardID,
|
|
&h.Name, &h.Description, &h.Scenario, &h.Category, &status,
|
|
&h.CreatedAt, &h.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get hazard: %w", err)
|
|
}
|
|
|
|
h.Status = HazardStatus(status)
|
|
return &h, nil
|
|
}
|
|
|
|
// ListHazards lists all hazards for a project
|
|
func (s *Store) ListHazards(ctx context.Context, projectID uuid.UUID) ([]Hazard, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, status,
|
|
created_at, updated_at
|
|
FROM iace_hazards WHERE project_id = $1
|
|
ORDER BY created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazards: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var hazards []Hazard
|
|
for rows.Next() {
|
|
var h Hazard
|
|
var status string
|
|
|
|
err := rows.Scan(
|
|
&h.ID, &h.ProjectID, &h.ComponentID, &h.LibraryHazardID,
|
|
&h.Name, &h.Description, &h.Scenario, &h.Category, &status,
|
|
&h.CreatedAt, &h.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazards scan: %w", err)
|
|
}
|
|
|
|
h.Status = HazardStatus(status)
|
|
hazards = append(hazards, h)
|
|
}
|
|
|
|
return hazards, nil
|
|
}
|
|
|
|
// UpdateHazard updates a hazard with a dynamic set of fields
|
|
func (s *Store) UpdateHazard(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Hazard, error) {
|
|
if len(updates) == 0 {
|
|
return s.GetHazard(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_hazards SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "name", "description", "scenario", "category":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "status":
|
|
query += fmt.Sprintf(", status = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "component_id":
|
|
query += fmt.Sprintf(", component_id = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update hazard: %w", err)
|
|
}
|
|
|
|
return s.GetHazard(ctx, id)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Risk Assessment Operations
|
|
// ============================================================================
|
|
|
|
// CreateRiskAssessment creates a new risk assessment for a hazard
|
|
func (s *Store) CreateRiskAssessment(ctx context.Context, assessment *RiskAssessment) error {
|
|
if assessment.ID == uuid.Nil {
|
|
assessment.ID = uuid.New()
|
|
}
|
|
if assessment.CreatedAt.IsZero() {
|
|
assessment.CreatedAt = time.Now().UTC()
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_risk_assessments (
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15, $16,
|
|
$17, $18
|
|
)
|
|
`,
|
|
assessment.ID, assessment.HazardID, assessment.Version, string(assessment.AssessmentType),
|
|
assessment.Severity, assessment.Exposure, assessment.Probability,
|
|
assessment.InherentRisk, assessment.ControlMaturity, assessment.ControlCoverage,
|
|
assessment.TestEvidenceStrength, assessment.CEff, assessment.ResidualRisk,
|
|
string(assessment.RiskLevel), assessment.IsAcceptable, assessment.AcceptanceJustification,
|
|
assessment.AssessedBy, assessment.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create risk assessment: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLatestAssessment retrieves the most recent risk assessment for a hazard
|
|
func (s *Store) GetLatestAssessment(ctx context.Context, hazardID uuid.UUID) (*RiskAssessment, error) {
|
|
var a RiskAssessment
|
|
var assessmentType, riskLevel string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
FROM iace_risk_assessments
|
|
WHERE hazard_id = $1
|
|
ORDER BY version DESC, created_at DESC
|
|
LIMIT 1
|
|
`, hazardID).Scan(
|
|
&a.ID, &a.HazardID, &a.Version, &assessmentType,
|
|
&a.Severity, &a.Exposure, &a.Probability,
|
|
&a.InherentRisk, &a.ControlMaturity, &a.ControlCoverage,
|
|
&a.TestEvidenceStrength, &a.CEff, &a.ResidualRisk,
|
|
&riskLevel, &a.IsAcceptable, &a.AcceptanceJustification,
|
|
&a.AssessedBy, &a.CreatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get latest assessment: %w", err)
|
|
}
|
|
|
|
a.AssessmentType = AssessmentType(assessmentType)
|
|
a.RiskLevel = RiskLevel(riskLevel)
|
|
|
|
return &a, nil
|
|
}
|
|
|
|
// ListAssessments lists all risk assessments for a hazard, newest first
|
|
func (s *Store) ListAssessments(ctx context.Context, hazardID uuid.UUID) ([]RiskAssessment, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
FROM iace_risk_assessments
|
|
WHERE hazard_id = $1
|
|
ORDER BY version DESC, created_at DESC
|
|
`, hazardID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list assessments: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var assessments []RiskAssessment
|
|
for rows.Next() {
|
|
var a RiskAssessment
|
|
var assessmentType, riskLevel string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.HazardID, &a.Version, &assessmentType,
|
|
&a.Severity, &a.Exposure, &a.Probability,
|
|
&a.InherentRisk, &a.ControlMaturity, &a.ControlCoverage,
|
|
&a.TestEvidenceStrength, &a.CEff, &a.ResidualRisk,
|
|
&riskLevel, &a.IsAcceptable, &a.AcceptanceJustification,
|
|
&a.AssessedBy, &a.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list assessments scan: %w", err)
|
|
}
|
|
|
|
a.AssessmentType = AssessmentType(assessmentType)
|
|
a.RiskLevel = RiskLevel(riskLevel)
|
|
|
|
assessments = append(assessments, a)
|
|
}
|
|
|
|
return assessments, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mitigation CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateMitigation creates a new mitigation measure for a hazard
|
|
func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationRequest) (*Mitigation, error) {
|
|
m := &Mitigation{
|
|
ID: uuid.New(),
|
|
HazardID: req.HazardID,
|
|
ReductionType: req.ReductionType,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Status: MitigationStatusPlanned,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_mitigations (
|
|
id, hazard_id, reduction_type, name, description,
|
|
status, verification_method, verification_result,
|
|
verified_at, verified_by,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8,
|
|
$9, $10,
|
|
$11, $12
|
|
)
|
|
`,
|
|
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
|
|
string(m.Status), "", "",
|
|
nil, uuid.Nil,
|
|
m.CreatedAt, m.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create mitigation: %w", err)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// UpdateMitigation updates a mitigation with a dynamic set of fields
|
|
func (s *Store) UpdateMitigation(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Mitigation, error) {
|
|
if len(updates) == 0 {
|
|
return s.getMitigation(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_mitigations SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "name", "description", "verification_result":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "status":
|
|
query += fmt.Sprintf(", status = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "reduction_type":
|
|
query += fmt.Sprintf(", reduction_type = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "verification_method":
|
|
query += fmt.Sprintf(", verification_method = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update mitigation: %w", err)
|
|
}
|
|
|
|
return s.getMitigation(ctx, id)
|
|
}
|
|
|
|
// VerifyMitigation marks a mitigation as verified
|
|
func (s *Store) VerifyMitigation(ctx context.Context, id uuid.UUID, verificationResult string, verifiedBy string) error {
|
|
now := time.Now().UTC()
|
|
verifiedByUUID, err := uuid.Parse(verifiedBy)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid verified_by UUID: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE iace_mitigations SET
|
|
status = $2,
|
|
verification_result = $3,
|
|
verified_at = $4,
|
|
verified_by = $5,
|
|
updated_at = $4
|
|
WHERE id = $1
|
|
`, id, string(MitigationStatusVerified), verificationResult, now, verifiedByUUID)
|
|
if err != nil {
|
|
return fmt.Errorf("verify mitigation: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListMitigations lists all mitigations for a hazard
|
|
func (s *Store) ListMitigations(ctx context.Context, hazardID uuid.UUID) ([]Mitigation, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, hazard_id, reduction_type, name, description,
|
|
status, verification_method, verification_result,
|
|
verified_at, verified_by,
|
|
created_at, updated_at
|
|
FROM iace_mitigations WHERE hazard_id = $1
|
|
ORDER BY created_at ASC
|
|
`, hazardID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list mitigations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var mitigations []Mitigation
|
|
for rows.Next() {
|
|
var m Mitigation
|
|
var reductionType, status, verificationMethod string
|
|
|
|
err := rows.Scan(
|
|
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
|
&status, &verificationMethod, &m.VerificationResult,
|
|
&m.VerifiedAt, &m.VerifiedBy,
|
|
&m.CreatedAt, &m.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list mitigations scan: %w", err)
|
|
}
|
|
|
|
m.ReductionType = ReductionType(reductionType)
|
|
m.Status = MitigationStatus(status)
|
|
m.VerificationMethod = VerificationMethod(verificationMethod)
|
|
|
|
mitigations = append(mitigations, m)
|
|
}
|
|
|
|
return mitigations, nil
|
|
}
|
|
|
|
// getMitigation is a helper to fetch a single mitigation by ID
|
|
func (s *Store) getMitigation(ctx context.Context, id uuid.UUID) (*Mitigation, error) {
|
|
var m Mitigation
|
|
var reductionType, status, verificationMethod string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, hazard_id, reduction_type, name, description,
|
|
status, verification_method, verification_result,
|
|
verified_at, verified_by,
|
|
created_at, updated_at
|
|
FROM iace_mitigations WHERE id = $1
|
|
`, id).Scan(
|
|
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
|
&status, &verificationMethod, &m.VerificationResult,
|
|
&m.VerifiedAt, &m.VerifiedBy,
|
|
&m.CreatedAt, &m.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get mitigation: %w", err)
|
|
}
|
|
|
|
m.ReductionType = ReductionType(reductionType)
|
|
m.Status = MitigationStatus(status)
|
|
m.VerificationMethod = VerificationMethod(verificationMethod)
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Evidence Operations
|
|
// ============================================================================
|
|
|
|
// CreateEvidence creates a new evidence record
|
|
func (s *Store) CreateEvidence(ctx context.Context, evidence *Evidence) error {
|
|
if evidence.ID == uuid.Nil {
|
|
evidence.ID = uuid.New()
|
|
}
|
|
if evidence.CreatedAt.IsZero() {
|
|
evidence.CreatedAt = time.Now().UTC()
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_evidence (
|
|
id, project_id, mitigation_id, verification_plan_id,
|
|
file_name, file_path, file_hash, file_size, mime_type,
|
|
description, uploaded_by, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7, $8, $9,
|
|
$10, $11, $12
|
|
)
|
|
`,
|
|
evidence.ID, evidence.ProjectID, evidence.MitigationID, evidence.VerificationPlanID,
|
|
evidence.FileName, evidence.FilePath, evidence.FileHash, evidence.FileSize, evidence.MimeType,
|
|
evidence.Description, evidence.UploadedBy, evidence.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create evidence: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListEvidence lists all evidence for a project
|
|
func (s *Store) ListEvidence(ctx context.Context, projectID uuid.UUID) ([]Evidence, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, mitigation_id, verification_plan_id,
|
|
file_name, file_path, file_hash, file_size, mime_type,
|
|
description, uploaded_by, created_at
|
|
FROM iace_evidence WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list evidence: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var evidence []Evidence
|
|
for rows.Next() {
|
|
var e Evidence
|
|
|
|
err := rows.Scan(
|
|
&e.ID, &e.ProjectID, &e.MitigationID, &e.VerificationPlanID,
|
|
&e.FileName, &e.FilePath, &e.FileHash, &e.FileSize, &e.MimeType,
|
|
&e.Description, &e.UploadedBy, &e.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list evidence scan: %w", err)
|
|
}
|
|
|
|
evidence = append(evidence, e)
|
|
}
|
|
|
|
return evidence, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Verification Plan Operations
|
|
// ============================================================================
|
|
|
|
// CreateVerificationPlan creates a new verification plan
|
|
func (s *Store) CreateVerificationPlan(ctx context.Context, req CreateVerificationPlanRequest) (*VerificationPlan, error) {
|
|
vp := &VerificationPlan{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
HazardID: req.HazardID,
|
|
MitigationID: req.MitigationID,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
AcceptanceCriteria: req.AcceptanceCriteria,
|
|
Method: req.Method,
|
|
Status: "planned",
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_verification_plans (
|
|
id, project_id, hazard_id, mitigation_id,
|
|
title, description, acceptance_criteria, method,
|
|
status, result, completed_at, completed_by,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7, $8,
|
|
$9, $10, $11, $12,
|
|
$13, $14
|
|
)
|
|
`,
|
|
vp.ID, vp.ProjectID, vp.HazardID, vp.MitigationID,
|
|
vp.Title, vp.Description, vp.AcceptanceCriteria, string(vp.Method),
|
|
vp.Status, "", nil, uuid.Nil,
|
|
vp.CreatedAt, vp.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create verification plan: %w", err)
|
|
}
|
|
|
|
return vp, nil
|
|
}
|
|
|
|
// UpdateVerificationPlan updates a verification plan with a dynamic set of fields
|
|
func (s *Store) UpdateVerificationPlan(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*VerificationPlan, error) {
|
|
if len(updates) == 0 {
|
|
return s.getVerificationPlan(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_verification_plans SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "title", "description", "acceptance_criteria", "result", "status":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "method":
|
|
query += fmt.Sprintf(", method = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update verification plan: %w", err)
|
|
}
|
|
|
|
return s.getVerificationPlan(ctx, id)
|
|
}
|
|
|
|
// CompleteVerification marks a verification plan as completed
|
|
func (s *Store) CompleteVerification(ctx context.Context, id uuid.UUID, result string, completedBy string) error {
|
|
now := time.Now().UTC()
|
|
completedByUUID, err := uuid.Parse(completedBy)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid completed_by UUID: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE iace_verification_plans SET
|
|
status = 'completed',
|
|
result = $2,
|
|
completed_at = $3,
|
|
completed_by = $4,
|
|
updated_at = $3
|
|
WHERE id = $1
|
|
`, id, result, now, completedByUUID)
|
|
if err != nil {
|
|
return fmt.Errorf("complete verification: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListVerificationPlans lists all verification plans for a project
|
|
func (s *Store) ListVerificationPlans(ctx context.Context, projectID uuid.UUID) ([]VerificationPlan, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, hazard_id, mitigation_id,
|
|
title, description, acceptance_criteria, method,
|
|
status, result, completed_at, completed_by,
|
|
created_at, updated_at
|
|
FROM iace_verification_plans WHERE project_id = $1
|
|
ORDER BY created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list verification plans: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var plans []VerificationPlan
|
|
for rows.Next() {
|
|
var vp VerificationPlan
|
|
var method string
|
|
|
|
err := rows.Scan(
|
|
&vp.ID, &vp.ProjectID, &vp.HazardID, &vp.MitigationID,
|
|
&vp.Title, &vp.Description, &vp.AcceptanceCriteria, &method,
|
|
&vp.Status, &vp.Result, &vp.CompletedAt, &vp.CompletedBy,
|
|
&vp.CreatedAt, &vp.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list verification plans scan: %w", err)
|
|
}
|
|
|
|
vp.Method = VerificationMethod(method)
|
|
plans = append(plans, vp)
|
|
}
|
|
|
|
return plans, nil
|
|
}
|
|
|
|
// getVerificationPlan is a helper to fetch a single verification plan by ID
|
|
func (s *Store) getVerificationPlan(ctx context.Context, id uuid.UUID) (*VerificationPlan, error) {
|
|
var vp VerificationPlan
|
|
var method string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, hazard_id, mitigation_id,
|
|
title, description, acceptance_criteria, method,
|
|
status, result, completed_at, completed_by,
|
|
created_at, updated_at
|
|
FROM iace_verification_plans WHERE id = $1
|
|
`, id).Scan(
|
|
&vp.ID, &vp.ProjectID, &vp.HazardID, &vp.MitigationID,
|
|
&vp.Title, &vp.Description, &vp.AcceptanceCriteria, &method,
|
|
&vp.Status, &vp.Result, &vp.CompletedAt, &vp.CompletedBy,
|
|
&vp.CreatedAt, &vp.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get verification plan: %w", err)
|
|
}
|
|
|
|
vp.Method = VerificationMethod(method)
|
|
return &vp, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tech File Section Operations
|
|
// ============================================================================
|
|
|
|
// CreateTechFileSection creates a new section in the technical file
|
|
func (s *Store) CreateTechFileSection(ctx context.Context, projectID uuid.UUID, sectionType, title, content string) (*TechFileSection, error) {
|
|
tf := &TechFileSection{
|
|
ID: uuid.New(),
|
|
ProjectID: projectID,
|
|
SectionType: sectionType,
|
|
Title: title,
|
|
Content: content,
|
|
Version: 1,
|
|
Status: TechFileSectionStatusDraft,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_tech_file_sections (
|
|
id, project_id, section_type, title, content,
|
|
version, status, approved_by, approved_at, metadata,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9, $10,
|
|
$11, $12
|
|
)
|
|
`,
|
|
tf.ID, tf.ProjectID, tf.SectionType, tf.Title, tf.Content,
|
|
tf.Version, string(tf.Status), uuid.Nil, nil, nil,
|
|
tf.CreatedAt, tf.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create tech file section: %w", err)
|
|
}
|
|
|
|
return tf, nil
|
|
}
|
|
|
|
// UpdateTechFileSection updates the content of a tech file section and bumps version
|
|
func (s *Store) UpdateTechFileSection(ctx context.Context, id uuid.UUID, content string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE iace_tech_file_sections SET
|
|
content = $2,
|
|
version = version + 1,
|
|
status = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, id, content, string(TechFileSectionStatusDraft))
|
|
if err != nil {
|
|
return fmt.Errorf("update tech file section: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApproveTechFileSection marks a tech file section as approved
|
|
func (s *Store) ApproveTechFileSection(ctx context.Context, id uuid.UUID, approvedBy string) error {
|
|
now := time.Now().UTC()
|
|
approvedByUUID, err := uuid.Parse(approvedBy)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid approved_by UUID: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE iace_tech_file_sections SET
|
|
status = $2,
|
|
approved_by = $3,
|
|
approved_at = $4,
|
|
updated_at = $4
|
|
WHERE id = $1
|
|
`, id, string(TechFileSectionStatusApproved), approvedByUUID, now)
|
|
if err != nil {
|
|
return fmt.Errorf("approve tech file section: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListTechFileSections lists all tech file sections for a project
|
|
func (s *Store) ListTechFileSections(ctx context.Context, projectID uuid.UUID) ([]TechFileSection, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, section_type, title, content,
|
|
version, status, approved_by, approved_at, metadata,
|
|
created_at, updated_at
|
|
FROM iace_tech_file_sections WHERE project_id = $1
|
|
ORDER BY section_type ASC, created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tech file sections: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sections []TechFileSection
|
|
for rows.Next() {
|
|
var tf TechFileSection
|
|
var status string
|
|
var metadata []byte
|
|
|
|
err := rows.Scan(
|
|
&tf.ID, &tf.ProjectID, &tf.SectionType, &tf.Title, &tf.Content,
|
|
&tf.Version, &status, &tf.ApprovedBy, &tf.ApprovedAt, &metadata,
|
|
&tf.CreatedAt, &tf.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tech file sections scan: %w", err)
|
|
}
|
|
|
|
tf.Status = TechFileSectionStatus(status)
|
|
json.Unmarshal(metadata, &tf.Metadata)
|
|
|
|
sections = append(sections, tf)
|
|
}
|
|
|
|
return sections, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Monitoring Event Operations
|
|
// ============================================================================
|
|
|
|
// CreateMonitoringEvent creates a new post-market monitoring event
|
|
func (s *Store) CreateMonitoringEvent(ctx context.Context, req CreateMonitoringEventRequest) (*MonitoringEvent, error) {
|
|
me := &MonitoringEvent{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
EventType: req.EventType,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
Severity: req.Severity,
|
|
Status: "open",
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_monitoring_events (
|
|
id, project_id, event_type, title, description,
|
|
severity, impact_assessment, status,
|
|
resolved_at, resolved_by, metadata,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8,
|
|
$9, $10, $11,
|
|
$12, $13
|
|
)
|
|
`,
|
|
me.ID, me.ProjectID, string(me.EventType), me.Title, me.Description,
|
|
me.Severity, "", me.Status,
|
|
nil, uuid.Nil, nil,
|
|
me.CreatedAt, me.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create monitoring event: %w", err)
|
|
}
|
|
|
|
return me, nil
|
|
}
|
|
|
|
// ListMonitoringEvents lists all monitoring events for a project
|
|
func (s *Store) ListMonitoringEvents(ctx context.Context, projectID uuid.UUID) ([]MonitoringEvent, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, event_type, title, description,
|
|
severity, impact_assessment, status,
|
|
resolved_at, resolved_by, metadata,
|
|
created_at, updated_at
|
|
FROM iace_monitoring_events WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list monitoring events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []MonitoringEvent
|
|
for rows.Next() {
|
|
var me MonitoringEvent
|
|
var eventType string
|
|
var metadata []byte
|
|
|
|
err := rows.Scan(
|
|
&me.ID, &me.ProjectID, &eventType, &me.Title, &me.Description,
|
|
&me.Severity, &me.ImpactAssessment, &me.Status,
|
|
&me.ResolvedAt, &me.ResolvedBy, &metadata,
|
|
&me.CreatedAt, &me.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list monitoring events scan: %w", err)
|
|
}
|
|
|
|
me.EventType = MonitoringEventType(eventType)
|
|
json.Unmarshal(metadata, &me.Metadata)
|
|
|
|
events = append(events, me)
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
// UpdateMonitoringEvent updates a monitoring event with a dynamic set of fields
|
|
func (s *Store) UpdateMonitoringEvent(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*MonitoringEvent, error) {
|
|
if len(updates) == 0 {
|
|
return s.getMonitoringEvent(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_monitoring_events SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "title", "description", "severity", "impact_assessment", "status":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "event_type":
|
|
query += fmt.Sprintf(", event_type = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "resolved_at":
|
|
query += fmt.Sprintf(", resolved_at = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "resolved_by":
|
|
query += fmt.Sprintf(", resolved_by = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "metadata":
|
|
metaJSON, _ := json.Marshal(val)
|
|
query += fmt.Sprintf(", metadata = $%d", argIdx)
|
|
args = append(args, metaJSON)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update monitoring event: %w", err)
|
|
}
|
|
|
|
return s.getMonitoringEvent(ctx, id)
|
|
}
|
|
|
|
// getMonitoringEvent is a helper to fetch a single monitoring event by ID
|
|
func (s *Store) getMonitoringEvent(ctx context.Context, id uuid.UUID) (*MonitoringEvent, error) {
|
|
var me MonitoringEvent
|
|
var eventType string
|
|
var metadata []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, event_type, title, description,
|
|
severity, impact_assessment, status,
|
|
resolved_at, resolved_by, metadata,
|
|
created_at, updated_at
|
|
FROM iace_monitoring_events WHERE id = $1
|
|
`, id).Scan(
|
|
&me.ID, &me.ProjectID, &eventType, &me.Title, &me.Description,
|
|
&me.Severity, &me.ImpactAssessment, &me.Status,
|
|
&me.ResolvedAt, &me.ResolvedBy, &metadata,
|
|
&me.CreatedAt, &me.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get monitoring event: %w", err)
|
|
}
|
|
|
|
me.EventType = MonitoringEventType(eventType)
|
|
json.Unmarshal(metadata, &me.Metadata)
|
|
|
|
return &me, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Audit Trail Operations
|
|
// ============================================================================
|
|
|
|
// AddAuditEntry adds an immutable audit trail entry
|
|
func (s *Store) AddAuditEntry(ctx context.Context, projectID uuid.UUID, entityType string, entityID uuid.UUID, action AuditAction, userID string, oldValues, newValues json.RawMessage) error {
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid user_id UUID: %w", err)
|
|
}
|
|
|
|
// Compute a simple hash for integrity: sha256(entityType + entityID + action + timestamp)
|
|
hashInput := fmt.Sprintf("%s:%s:%s:%s:%s", projectID, entityType, entityID, string(action), now.Format(time.RFC3339Nano))
|
|
// Use a simple deterministic hash representation
|
|
hash := fmt.Sprintf("%x", hashInput)
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO iace_audit_trail (
|
|
id, project_id, entity_type, entity_id,
|
|
action, user_id, old_values, new_values,
|
|
hash, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7, $8,
|
|
$9, $10
|
|
)
|
|
`,
|
|
id, projectID, entityType, entityID,
|
|
string(action), userUUID, oldValues, newValues,
|
|
hash, now,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("add audit entry: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListAuditTrail lists all audit trail entries for a project, newest first
|
|
func (s *Store) ListAuditTrail(ctx context.Context, projectID uuid.UUID) ([]AuditTrailEntry, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, entity_type, entity_id,
|
|
action, user_id, old_values, new_values,
|
|
hash, created_at
|
|
FROM iace_audit_trail WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list audit trail: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []AuditTrailEntry
|
|
for rows.Next() {
|
|
var e AuditTrailEntry
|
|
var action string
|
|
|
|
err := rows.Scan(
|
|
&e.ID, &e.ProjectID, &e.EntityType, &e.EntityID,
|
|
&action, &e.UserID, &e.OldValues, &e.NewValues,
|
|
&e.Hash, &e.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list audit trail scan: %w", err)
|
|
}
|
|
|
|
e.Action = AuditAction(action)
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hazard Library Operations
|
|
// ============================================================================
|
|
|
|
// ListHazardLibrary lists hazard library entries, optionally filtered by category and component type
|
|
func (s *Store) ListHazardLibrary(ctx context.Context, category string, componentType string) ([]HazardLibraryEntry, error) {
|
|
query := `
|
|
SELECT
|
|
id, category, name, description,
|
|
default_severity, default_probability,
|
|
applicable_component_types, regulation_references,
|
|
suggested_mitigations, is_builtin, tenant_id,
|
|
created_at
|
|
FROM iace_hazard_library WHERE 1=1`
|
|
|
|
args := []interface{}{}
|
|
argIdx := 1
|
|
|
|
if category != "" {
|
|
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
|
args = append(args, category)
|
|
argIdx++
|
|
}
|
|
if componentType != "" {
|
|
query += fmt.Sprintf(" AND applicable_component_types @> $%d::jsonb", argIdx)
|
|
componentTypeJSON, _ := json.Marshal([]string{componentType})
|
|
args = append(args, string(componentTypeJSON))
|
|
argIdx++
|
|
}
|
|
|
|
query += " ORDER BY category ASC, name ASC"
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazard library: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []HazardLibraryEntry
|
|
for rows.Next() {
|
|
var e HazardLibraryEntry
|
|
var applicableComponentTypes, regulationReferences, suggestedMitigations []byte
|
|
|
|
err := rows.Scan(
|
|
&e.ID, &e.Category, &e.Name, &e.Description,
|
|
&e.DefaultSeverity, &e.DefaultProbability,
|
|
&applicableComponentTypes, ®ulationReferences,
|
|
&suggestedMitigations, &e.IsBuiltin, &e.TenantID,
|
|
&e.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazard library scan: %w", err)
|
|
}
|
|
|
|
json.Unmarshal(applicableComponentTypes, &e.ApplicableComponentTypes)
|
|
json.Unmarshal(regulationReferences, &e.RegulationReferences)
|
|
json.Unmarshal(suggestedMitigations, &e.SuggestedMitigations)
|
|
|
|
if e.ApplicableComponentTypes == nil {
|
|
e.ApplicableComponentTypes = []string{}
|
|
}
|
|
if e.RegulationReferences == nil {
|
|
e.RegulationReferences = []string{}
|
|
}
|
|
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// GetHazardLibraryEntry retrieves a single hazard library entry by ID
|
|
func (s *Store) GetHazardLibraryEntry(ctx context.Context, id uuid.UUID) (*HazardLibraryEntry, error) {
|
|
var e HazardLibraryEntry
|
|
var applicableComponentTypes, regulationReferences, suggestedMitigations []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, category, name, description,
|
|
default_severity, default_probability,
|
|
applicable_component_types, regulation_references,
|
|
suggested_mitigations, is_builtin, tenant_id,
|
|
created_at
|
|
FROM iace_hazard_library WHERE id = $1
|
|
`, id).Scan(
|
|
&e.ID, &e.Category, &e.Name, &e.Description,
|
|
&e.DefaultSeverity, &e.DefaultProbability,
|
|
&applicableComponentTypes, ®ulationReferences,
|
|
&suggestedMitigations, &e.IsBuiltin, &e.TenantID,
|
|
&e.CreatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get hazard library entry: %w", err)
|
|
}
|
|
|
|
json.Unmarshal(applicableComponentTypes, &e.ApplicableComponentTypes)
|
|
json.Unmarshal(regulationReferences, &e.RegulationReferences)
|
|
json.Unmarshal(suggestedMitigations, &e.SuggestedMitigations)
|
|
|
|
if e.ApplicableComponentTypes == nil {
|
|
e.ApplicableComponentTypes = []string{}
|
|
}
|
|
if e.RegulationReferences == nil {
|
|
e.RegulationReferences = []string{}
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Risk Summary (Aggregated View)
|
|
// ============================================================================
|
|
|
|
// GetRiskSummary computes an aggregated risk overview for a project
|
|
func (s *Store) GetRiskSummary(ctx context.Context, projectID uuid.UUID) (*RiskSummaryResponse, error) {
|
|
// Get all hazards for the project
|
|
hazards, err := s.ListHazards(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get risk summary - list hazards: %w", err)
|
|
}
|
|
|
|
summary := &RiskSummaryResponse{
|
|
TotalHazards: len(hazards),
|
|
AllAcceptable: true,
|
|
}
|
|
|
|
if len(hazards) == 0 {
|
|
summary.OverallRiskLevel = RiskLevelNegligible
|
|
return summary, nil
|
|
}
|
|
|
|
highestRisk := RiskLevelNegligible
|
|
|
|
for _, h := range hazards {
|
|
latest, err := s.GetLatestAssessment(ctx, h.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get risk summary - get assessment for hazard %s: %w", h.ID, err)
|
|
}
|
|
if latest == nil {
|
|
// Hazard without assessment counts as unassessed; consider it not acceptable
|
|
summary.AllAcceptable = false
|
|
continue
|
|
}
|
|
|
|
switch latest.RiskLevel {
|
|
case RiskLevelCritical:
|
|
summary.Critical++
|
|
case RiskLevelHigh:
|
|
summary.High++
|
|
case RiskLevelMedium:
|
|
summary.Medium++
|
|
case RiskLevelLow:
|
|
summary.Low++
|
|
case RiskLevelNegligible:
|
|
summary.Negligible++
|
|
}
|
|
|
|
if !latest.IsAcceptable {
|
|
summary.AllAcceptable = false
|
|
}
|
|
|
|
// Track highest risk level
|
|
if riskLevelSeverity(latest.RiskLevel) > riskLevelSeverity(highestRisk) {
|
|
highestRisk = latest.RiskLevel
|
|
}
|
|
}
|
|
|
|
summary.OverallRiskLevel = highestRisk
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// riskLevelSeverity returns a numeric severity for risk level comparison
|
|
func riskLevelSeverity(rl RiskLevel) int {
|
|
switch rl {
|
|
case RiskLevelCritical:
|
|
return 5
|
|
case RiskLevelHigh:
|
|
return 4
|
|
case RiskLevelMedium:
|
|
return 3
|
|
case RiskLevelLow:
|
|
return 2
|
|
case RiskLevelNegligible:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|