745cdd79cf
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1461 lines
49 KiB
Go
1461 lines
49 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"go.uber.org/zap"
|
|
|
|
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
)
|
|
|
|
type ComplianceService struct {
|
|
pool *pgxpool.Pool
|
|
audits *AuditService
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewComplianceService(pool *pgxpool.Pool, audits *AuditService, logger *zap.Logger) *ComplianceService {
|
|
return &ComplianceService{pool: pool, audits: audits, logger: logger}
|
|
}
|
|
|
|
type ComplianceDictionary struct {
|
|
ID int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
PlatformScope string `json:"platform_scope"`
|
|
ApplicablePlatforms []string `json:"applicable_platforms"`
|
|
DefaultLevel string `json:"default_level"`
|
|
Enabled bool `json:"enabled"`
|
|
Version int `json:"version"`
|
|
Description *string `json:"description,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
TermCount int `json:"term_count"`
|
|
}
|
|
|
|
type UpsertComplianceDictionaryRequest struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
PlatformScope string `json:"platform_scope"`
|
|
ApplicablePlatforms []string `json:"applicable_platforms"`
|
|
DefaultLevel string `json:"default_level"`
|
|
Enabled *bool `json:"enabled"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type ComplianceDictionaryTerm struct {
|
|
ID int64 `json:"id"`
|
|
DictionaryID int64 `json:"dictionary_id"`
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
LevelOverride *string `json:"level_override,omitempty"`
|
|
Hint *string `json:"hint,omitempty"`
|
|
ReferenceLaw *string `json:"reference_law,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type CreateComplianceDictionaryTermRequest struct {
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
LevelOverride *string `json:"level_override"`
|
|
Hint *string `json:"hint"`
|
|
ReferenceLaw *string `json:"reference_law"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type BatchImportComplianceTermsRequest struct {
|
|
Terms []CreateComplianceDictionaryTermRequest `json:"terms"`
|
|
}
|
|
|
|
type CompliancePolicy struct {
|
|
ID int64 `json:"id"`
|
|
MasterEnabled *bool `json:"master_enabled,omitempty"`
|
|
EnforcementMode string `json:"enforcement_mode"`
|
|
EnabledDictionaries []int64 `json:"enabled_dictionaries"`
|
|
LLMJudgeEnabled bool `json:"llm_judge_enabled"`
|
|
LLMCategories []string `json:"llm_categories"`
|
|
Revision int64 `json:"revision"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type UpdateCompliancePolicyRequest struct {
|
|
MasterEnabled *bool `json:"master_enabled"`
|
|
EnforcementMode string `json:"enforcement_mode"`
|
|
EnabledDictionaries []int64 `json:"enabled_dictionaries"`
|
|
LLMJudgeEnabled *bool `json:"llm_judge_enabled"`
|
|
LLMCategories []string `json:"llm_categories"`
|
|
}
|
|
|
|
type ComplianceManualReviewListRequest struct {
|
|
Status string
|
|
TenantID *int64
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type ComplianceManualReviewListResponse struct {
|
|
Items []sharedcompliance.ManualReview `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
|
|
type ComplianceRecordListRequest struct {
|
|
TenantID *int64
|
|
ArticleID *int64
|
|
Decision string
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type ComplianceRecordListResponse struct {
|
|
Items []sharedcompliance.CheckResult `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
|
|
type ComplianceStats struct {
|
|
CheckCount int64 `json:"check_count"`
|
|
BlockedCount int64 `json:"blocked_count"`
|
|
NeedsAckCount int64 `json:"needs_ack_count"`
|
|
PassedCount int64 `json:"passed_count"`
|
|
AckCount int64 `json:"ack_count"`
|
|
ManualPendingCount int64 `json:"manual_pending_count"`
|
|
ManualApprovedCount int64 `json:"manual_approved_count"`
|
|
ManualRejectedCount int64 `json:"manual_rejected_count"`
|
|
ByLevel map[string]int64 `json:"by_level"`
|
|
BySource map[string]int64 `json:"by_source"`
|
|
}
|
|
|
|
func (s *ComplianceService) ListDictionaries(ctx context.Context) ([]ComplianceDictionary, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
d.id,
|
|
d.code,
|
|
d.name,
|
|
d.platform_scope,
|
|
COALESCE(d.applicable_platforms, '{}'),
|
|
d.default_level,
|
|
d.enabled,
|
|
d.version,
|
|
d.description,
|
|
d.created_at,
|
|
d.updated_at,
|
|
COUNT(t.id)::int AS term_count
|
|
FROM ops.compliance_dictionaries d
|
|
LEFT JOIN ops.compliance_dictionary_terms t ON t.dictionary_id = d.id
|
|
GROUP BY d.id
|
|
ORDER BY d.id DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52001, "ops_compliance_dictionary_query_failed", "failed to list compliance dictionaries")
|
|
}
|
|
defer rows.Close()
|
|
out := make([]ComplianceDictionary, 0)
|
|
for rows.Next() {
|
|
item, scanErr := scanComplianceDictionary(rows)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(52001, "ops_compliance_dictionary_query_failed", "failed to scan compliance dictionary")
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ComplianceService) CreateDictionary(ctx context.Context, req UpsertComplianceDictionaryRequest) (*ComplianceDictionary, error) {
|
|
normalized, err := normalizeDictionaryInput(req, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
actor := ActorFromContext(ctx)
|
|
var actorID *int64
|
|
if actor != nil && actor.OperatorID > 0 {
|
|
actorID = &actor.OperatorID
|
|
}
|
|
var row ComplianceDictionary
|
|
var description pgtype.Text
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO ops.compliance_dictionaries (
|
|
code,
|
|
name,
|
|
platform_scope,
|
|
applicable_platforms,
|
|
default_level,
|
|
enabled,
|
|
description,
|
|
created_by,
|
|
updated_by
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
|
|
RETURNING id, code, name, platform_scope, COALESCE(applicable_platforms, '{}'), default_level, enabled, version, description, created_at, updated_at, 0
|
|
`, normalized.Code, normalized.Name, normalized.PlatformScope, normalized.ApplicablePlatforms, normalized.DefaultLevel, normalized.Enabled, normalized.Description, actorID).Scan(
|
|
&row.ID,
|
|
&row.Code,
|
|
&row.Name,
|
|
&row.PlatformScope,
|
|
&row.ApplicablePlatforms,
|
|
&row.DefaultLevel,
|
|
&row.Enabled,
|
|
&row.Version,
|
|
&description,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.TermCount,
|
|
)
|
|
if err != nil {
|
|
if isOpsUniqueViolation(err) {
|
|
return nil, response.ErrConflict(40971, "compliance_dictionary_code_exists", "dictionary code already exists")
|
|
}
|
|
return nil, response.ErrInternal(52002, "ops_compliance_dictionary_create_failed", "failed to create compliance dictionary")
|
|
}
|
|
if description.Valid {
|
|
row.Description = &description.String
|
|
}
|
|
s.audit(ctx, "compliance.dictionary.create", "compliance_dictionary", row.ID, map[string]any{"code": row.Code})
|
|
return &row, nil
|
|
}
|
|
|
|
func (s *ComplianceService) UpdateDictionary(ctx context.Context, id int64, req UpsertComplianceDictionaryRequest) (*ComplianceDictionary, error) {
|
|
normalized, err := normalizeDictionaryInput(req, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
actor := ActorFromContext(ctx)
|
|
var actorID *int64
|
|
if actor != nil && actor.OperatorID > 0 {
|
|
actorID = &actor.OperatorID
|
|
}
|
|
var row ComplianceDictionary
|
|
var description pgtype.Text
|
|
err = s.pool.QueryRow(ctx, `
|
|
UPDATE ops.compliance_dictionaries
|
|
SET name = $2,
|
|
platform_scope = $3,
|
|
applicable_platforms = $4,
|
|
default_level = $5,
|
|
enabled = $6,
|
|
description = $7,
|
|
updated_by = $8,
|
|
version = version + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, code, name, platform_scope, COALESCE(applicable_platforms, '{}'), default_level, enabled, version, description, created_at, updated_at,
|
|
(SELECT COUNT(*)::int FROM ops.compliance_dictionary_terms t WHERE t.dictionary_id = ops.compliance_dictionaries.id)
|
|
`, id, normalized.Name, normalized.PlatformScope, normalized.ApplicablePlatforms, normalized.DefaultLevel, normalized.Enabled, normalized.Description, actorID).Scan(
|
|
&row.ID,
|
|
&row.Code,
|
|
&row.Name,
|
|
&row.PlatformScope,
|
|
&row.ApplicablePlatforms,
|
|
&row.DefaultLevel,
|
|
&row.Enabled,
|
|
&row.Version,
|
|
&description,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.TermCount,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
return nil, response.ErrInternal(52003, "ops_compliance_dictionary_update_failed", "failed to update compliance dictionary")
|
|
}
|
|
if description.Valid {
|
|
row.Description = &description.String
|
|
}
|
|
s.audit(ctx, "compliance.dictionary.update", "compliance_dictionary", row.ID, map[string]any{"code": row.Code})
|
|
return &row, nil
|
|
}
|
|
|
|
func (s *ComplianceService) DeleteDictionary(ctx context.Context, id int64) error {
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM ops.compliance_dictionaries WHERE id = $1`, id)
|
|
if err != nil {
|
|
return response.ErrInternal(52004, "ops_compliance_dictionary_delete_failed", "failed to delete compliance dictionary")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
s.audit(ctx, "compliance.dictionary.delete", "compliance_dictionary", id, nil)
|
|
return nil
|
|
}
|
|
|
|
func (s *ComplianceService) ListTerms(ctx context.Context, dictionaryID int64) ([]ComplianceDictionaryTerm, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, dictionary_id, match_type, pattern, level_override, hint, reference_law, enabled, created_at, updated_at
|
|
FROM ops.compliance_dictionary_terms
|
|
WHERE dictionary_id = $1
|
|
ORDER BY id DESC
|
|
`, dictionaryID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52005, "ops_compliance_terms_query_failed", "failed to list compliance terms")
|
|
}
|
|
defer rows.Close()
|
|
out := make([]ComplianceDictionaryTerm, 0)
|
|
for rows.Next() {
|
|
item, scanErr := scanComplianceDictionaryTerm(rows)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(52005, "ops_compliance_terms_query_failed", "failed to scan compliance term")
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ComplianceService) CreateTerm(ctx context.Context, dictionaryID int64, req CreateComplianceDictionaryTermRequest) (*ComplianceDictionaryTerm, error) {
|
|
normalized, err := normalizeTermInput(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var item ComplianceDictionaryTerm
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO ops.compliance_dictionary_terms (
|
|
dictionary_id,
|
|
match_type,
|
|
pattern,
|
|
level_override,
|
|
hint,
|
|
reference_law,
|
|
enabled
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
RETURNING id, dictionary_id, match_type, pattern, level_override, hint, reference_law, enabled, created_at, updated_at
|
|
`, dictionaryID, normalized.MatchType, normalized.Pattern, normalized.LevelOverride, normalized.Hint, normalized.ReferenceLaw, normalized.Enabled).Scan(
|
|
&item.ID,
|
|
&item.DictionaryID,
|
|
&item.MatchType,
|
|
&item.Pattern,
|
|
new(pgtype.Text),
|
|
new(pgtype.Text),
|
|
new(pgtype.Text),
|
|
&item.Enabled,
|
|
&item.CreatedAt,
|
|
&item.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if isOpsForeignKeyViolation(err) {
|
|
return nil, response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
return nil, response.ErrInternal(52006, "ops_compliance_term_create_failed", "failed to create compliance term")
|
|
}
|
|
item, err = s.getTerm(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.bumpDictionaryVersion(ctx, dictionaryID)
|
|
s.audit(ctx, "compliance.term.create", "compliance_dictionary", dictionaryID, map[string]any{"term_id": item.ID})
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *ComplianceService) BatchImportTerms(ctx context.Context, dictionaryID int64, req BatchImportComplianceTermsRequest) ([]ComplianceDictionaryTerm, error) {
|
|
if len(req.Terms) == 0 {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "terms is required")
|
|
}
|
|
if len(req.Terms) > 1000 {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "terms cannot exceed 1000 rows")
|
|
}
|
|
out := make([]ComplianceDictionaryTerm, 0, len(req.Terms))
|
|
for _, item := range req.Terms {
|
|
created, err := s.CreateTerm(ctx, dictionaryID, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *created)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ComplianceService) DeleteTerm(ctx context.Context, dictionaryID, termID int64) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM ops.compliance_dictionary_terms
|
|
WHERE id = $1 AND dictionary_id = $2
|
|
`, termID, dictionaryID)
|
|
if err != nil {
|
|
return response.ErrInternal(52007, "ops_compliance_term_delete_failed", "failed to delete compliance term")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40472, "compliance_term_not_found", "compliance term not found")
|
|
}
|
|
_ = s.bumpDictionaryVersion(ctx, dictionaryID)
|
|
s.audit(ctx, "compliance.term.delete", "compliance_dictionary", dictionaryID, map[string]any{"term_id": termID})
|
|
return nil
|
|
}
|
|
|
|
func (s *ComplianceService) PublishDictionary(ctx context.Context, dictionaryID int64) (*ComplianceDictionary, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52008, "ops_compliance_dictionary_publish_failed", "failed to begin dictionary publish")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
dict, err := s.loadDictionaryForUpdate(ctx, tx, dictionaryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dict.Version++
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE ops.compliance_dictionaries
|
|
SET version = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, dictionaryID, dict.Version); err != nil {
|
|
return nil, response.ErrInternal(52008, "ops_compliance_dictionary_publish_failed", "failed to bump dictionary version")
|
|
}
|
|
if err := s.insertDictionaryVersionSnapshot(ctx, tx, dictionaryID, dict.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(52008, "ops_compliance_dictionary_publish_failed", "failed to commit dictionary publish")
|
|
}
|
|
s.audit(ctx, "compliance.dictionary.publish", "compliance_dictionary", dictionaryID, map[string]any{"version": dict.Version})
|
|
return s.GetDictionary(ctx, dictionaryID)
|
|
}
|
|
|
|
func (s *ComplianceService) RollbackDictionary(ctx context.Context, dictionaryID int64, version int) (*ComplianceDictionary, error) {
|
|
if version <= 0 {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "version must be positive")
|
|
}
|
|
var snapshot []byte
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT snapshot
|
|
FROM ops.compliance_dictionary_versions
|
|
WHERE dictionary_id = $1 AND version = $2
|
|
`, dictionaryID, version).Scan(&snapshot)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40473, "compliance_dictionary_version_not_found", "dictionary version not found")
|
|
}
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to load dictionary snapshot")
|
|
}
|
|
var parsed struct {
|
|
Name string `json:"name"`
|
|
PlatformScope string `json:"platform_scope"`
|
|
ApplicablePlatforms []string `json:"applicable_platforms"`
|
|
DefaultLevel string `json:"default_level"`
|
|
Terms []struct {
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
LevelOverride *string `json:"level_override"`
|
|
Hint *string `json:"hint"`
|
|
ReferenceLaw *string `json:"reference_law"`
|
|
Enabled bool `json:"enabled"`
|
|
} `json:"terms"`
|
|
}
|
|
_ = json.Unmarshal(snapshot, &parsed)
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to begin dictionary rollback")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE ops.compliance_dictionaries
|
|
SET name = COALESCE(NULLIF($2, ''), name),
|
|
platform_scope = COALESCE(NULLIF($3, ''), platform_scope),
|
|
applicable_platforms = $4,
|
|
default_level = COALESCE(NULLIF($5, ''), default_level),
|
|
version = version + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, dictionaryID, parsed.Name, parsed.PlatformScope, normalizedPlatformArray(parsed.ApplicablePlatforms, parsed.PlatformScope), parsed.DefaultLevel)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to rollback dictionary")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM ops.compliance_dictionary_terms WHERE dictionary_id = $1`, dictionaryID); err != nil {
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to clear dictionary terms")
|
|
}
|
|
for _, term := range parsed.Terms {
|
|
pattern := strings.TrimSpace(term.Pattern)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
matchType := normalizeTermMatchType(term.MatchType)
|
|
if matchType == "" {
|
|
matchType = "exact"
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO ops.compliance_dictionary_terms (
|
|
dictionary_id,
|
|
match_type,
|
|
pattern,
|
|
level_override,
|
|
hint,
|
|
reference_law,
|
|
enabled
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
`, dictionaryID, matchType, pattern, nullableStringPointer(term.LevelOverride), nullableStringPointer(term.Hint), nullableStringPointer(term.ReferenceLaw), term.Enabled); err != nil {
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to restore dictionary terms")
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(52009, "ops_compliance_dictionary_rollback_failed", "failed to commit dictionary rollback")
|
|
}
|
|
s.audit(ctx, "compliance.dictionary.rollback", "compliance_dictionary", dictionaryID, map[string]any{"version": version})
|
|
return s.GetDictionary(ctx, dictionaryID)
|
|
}
|
|
|
|
func (s *ComplianceService) GetDictionary(ctx context.Context, id int64) (*ComplianceDictionary, error) {
|
|
var item ComplianceDictionary
|
|
var description pgtype.Text
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
d.id,
|
|
d.code,
|
|
d.name,
|
|
d.platform_scope,
|
|
COALESCE(d.applicable_platforms, '{}'),
|
|
d.default_level,
|
|
d.enabled,
|
|
d.version,
|
|
d.description,
|
|
d.created_at,
|
|
d.updated_at,
|
|
(SELECT COUNT(*)::int FROM ops.compliance_dictionary_terms t WHERE t.dictionary_id = d.id)
|
|
FROM ops.compliance_dictionaries d
|
|
WHERE d.id = $1
|
|
`, id).Scan(
|
|
&item.ID,
|
|
&item.Code,
|
|
&item.Name,
|
|
&item.PlatformScope,
|
|
&item.ApplicablePlatforms,
|
|
&item.DefaultLevel,
|
|
&item.Enabled,
|
|
&item.Version,
|
|
&description,
|
|
&item.CreatedAt,
|
|
&item.UpdatedAt,
|
|
&item.TermCount,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
return nil, response.ErrInternal(52001, "ops_compliance_dictionary_query_failed", "failed to load compliance dictionary")
|
|
}
|
|
if description.Valid {
|
|
item.Description = &description.String
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *ComplianceService) GetGlobalPolicy(ctx context.Context) (*CompliancePolicy, error) {
|
|
policy, err := s.getPolicy(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func (s *ComplianceService) UpdateGlobalPolicy(ctx context.Context, req UpdateCompliancePolicyRequest) (*CompliancePolicy, error) {
|
|
policy, err := s.updatePolicy(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.audit(ctx, "compliance.policy.global.update", "compliance_policy", policy.ID, nil)
|
|
return policy, nil
|
|
}
|
|
|
|
func (s *ComplianceService) SetGlobalMasterSwitch(ctx context.Context, enabled bool) (*CompliancePolicy, error) {
|
|
req := UpdateCompliancePolicyRequest{MasterEnabled: &enabled}
|
|
policy, err := s.updatePolicy(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.audit(ctx, "compliance.policy.global.master_switch", "compliance_policy", policy.ID, map[string]any{"enabled": enabled})
|
|
return policy, nil
|
|
}
|
|
|
|
func (s *ComplianceService) ListManualReviews(ctx context.Context, req ComplianceManualReviewListRequest) (*ComplianceManualReviewListResponse, error) {
|
|
req.Page, req.PageSize = normalizePage(req.Page, req.PageSize)
|
|
where, args := manualReviewWhere(req.Status, req.TenantID)
|
|
var total int64
|
|
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM compliance_manual_reviews mr WHERE "+where, args...).Scan(&total); err != nil {
|
|
return nil, response.ErrInternal(52020, "ops_compliance_manual_reviews_query_failed", "failed to count manual reviews")
|
|
}
|
|
args = append(args, req.PageSize, (req.Page-1)*req.PageSize)
|
|
limitArg := len(args) - 1
|
|
offsetArg := len(args)
|
|
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
|
SELECT
|
|
mr.id,
|
|
mr.tenant_id,
|
|
mr.article_id,
|
|
mr.article_version_id,
|
|
mr.original_check_record_id,
|
|
mr.requested_by,
|
|
mr.requested_at,
|
|
mr.request_note,
|
|
mr.status,
|
|
mr.cancelled_by,
|
|
mr.cancelled_at,
|
|
mr.cancel_reason,
|
|
mr.reviewed_by,
|
|
mr.reviewed_at,
|
|
mr.decision_reason,
|
|
mr.original_violation_summary,
|
|
mr.original_target_platforms,
|
|
av.title,
|
|
u.name,
|
|
t.name
|
|
FROM compliance_manual_reviews mr
|
|
LEFT JOIN article_versions av ON av.id = mr.article_version_id
|
|
LEFT JOIN users u ON u.id = mr.requested_by
|
|
LEFT JOIN tenants t ON t.id = mr.tenant_id
|
|
WHERE %s
|
|
ORDER BY mr.requested_at DESC, mr.id DESC
|
|
LIMIT $%d OFFSET $%d
|
|
`, where, limitArg, offsetArg), args...)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52020, "ops_compliance_manual_reviews_query_failed", "failed to list manual reviews")
|
|
}
|
|
defer rows.Close()
|
|
items := make([]sharedcompliance.ManualReview, 0)
|
|
for rows.Next() {
|
|
item, scanErr := scanManualReviewForOps(rows, false)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(52020, "ops_compliance_manual_reviews_query_failed", "failed to scan manual review")
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return &ComplianceManualReviewListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, rows.Err()
|
|
}
|
|
|
|
func (s *ComplianceService) GetManualReview(ctx context.Context, id int64) (*sharedcompliance.ManualReview, error) {
|
|
review, err := s.loadManualReview(ctx, id, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return review, nil
|
|
}
|
|
|
|
func (s *ComplianceService) ApproveManualReview(ctx context.Context, id int64, reason string) (*sharedcompliance.ManualReview, error) {
|
|
return s.decideManualReview(ctx, id, "approved", reason)
|
|
}
|
|
|
|
func (s *ComplianceService) RejectManualReview(ctx context.Context, id int64, reason string) (*sharedcompliance.ManualReview, error) {
|
|
return s.decideManualReview(ctx, id, "rejected", reason)
|
|
}
|
|
|
|
func (s *ComplianceService) ListRecords(ctx context.Context, req ComplianceRecordListRequest) (*ComplianceRecordListResponse, error) {
|
|
req.Page, req.PageSize = normalizePage(req.Page, req.PageSize)
|
|
where, args := complianceRecordWhere(req)
|
|
var total int64
|
|
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM compliance_check_records WHERE "+where, args...).Scan(&total); err != nil {
|
|
return nil, response.ErrInternal(52030, "ops_compliance_records_query_failed", "failed to count compliance records")
|
|
}
|
|
args = append(args, req.PageSize, (req.Page-1)*req.PageSize)
|
|
limitArg := len(args) - 1
|
|
offsetArg := len(args)
|
|
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
|
SELECT
|
|
id,
|
|
tenant_id,
|
|
article_id,
|
|
article_version_id,
|
|
target_platforms,
|
|
content_hash,
|
|
policy_fingerprint,
|
|
enforcement_mode,
|
|
highest_level,
|
|
passed,
|
|
hit_count,
|
|
stored_hit_count,
|
|
truncated,
|
|
manual_review_status,
|
|
manual_review_id,
|
|
checked_at,
|
|
duration_ms
|
|
FROM compliance_check_records
|
|
WHERE %s
|
|
ORDER BY checked_at DESC, id DESC
|
|
LIMIT $%d OFFSET $%d
|
|
`, where, limitArg, offsetArg), args...)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52030, "ops_compliance_records_query_failed", "failed to list compliance records")
|
|
}
|
|
defer rows.Close()
|
|
items := make([]sharedcompliance.CheckResult, 0)
|
|
for rows.Next() {
|
|
record, scanErr := scanCheckRecordForOps(rows)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(52030, "ops_compliance_records_query_failed", "failed to scan compliance record")
|
|
}
|
|
violations, err := s.loadRecordViolations(ctx, record.ID, record.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, *checkResultFromOpsRecord(record, violations))
|
|
}
|
|
return &ComplianceRecordListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, rows.Err()
|
|
}
|
|
|
|
func (s *ComplianceService) Stats(ctx context.Context) (*ComplianceStats, error) {
|
|
stats := &ComplianceStats{
|
|
ByLevel: map[string]int64{},
|
|
BySource: map[string]int64{},
|
|
}
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
COUNT(*)::bigint,
|
|
COUNT(*) FILTER (WHERE enforcement_mode = 'mandatory' AND highest_level = 'block')::bigint,
|
|
COUNT(*) FILTER (WHERE hit_count > 0 AND NOT (enforcement_mode = 'mandatory' AND highest_level = 'block'))::bigint,
|
|
COUNT(*) FILTER (WHERE hit_count = 0)::bigint
|
|
FROM compliance_check_records
|
|
`).Scan(&stats.CheckCount, &stats.BlockedCount, &stats.NeedsAckCount, &stats.PassedCount); err != nil {
|
|
return nil, response.ErrInternal(52040, "ops_compliance_stats_query_failed", "failed to load compliance stats")
|
|
}
|
|
_ = s.pool.QueryRow(ctx, `SELECT COUNT(*)::bigint FROM compliance_ack_records`).Scan(&stats.AckCount)
|
|
_ = s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'pending')::bigint,
|
|
COUNT(*) FILTER (WHERE status = 'approved')::bigint,
|
|
COUNT(*) FILTER (WHERE status = 'rejected')::bigint
|
|
FROM compliance_manual_reviews
|
|
`).Scan(&stats.ManualPendingCount, &stats.ManualApprovedCount, &stats.ManualRejectedCount)
|
|
levelRows, err := s.pool.Query(ctx, `SELECT level, COUNT(*)::bigint FROM compliance_violations GROUP BY level`)
|
|
if err == nil {
|
|
defer levelRows.Close()
|
|
for levelRows.Next() {
|
|
var level string
|
|
var count int64
|
|
if scanErr := levelRows.Scan(&level, &count); scanErr == nil {
|
|
stats.ByLevel[level] = count
|
|
}
|
|
}
|
|
}
|
|
sourceRows, err := s.pool.Query(ctx, `SELECT source, COUNT(*)::bigint FROM compliance_violations GROUP BY source`)
|
|
if err == nil {
|
|
defer sourceRows.Close()
|
|
for sourceRows.Next() {
|
|
var source string
|
|
var count int64
|
|
if scanErr := sourceRows.Scan(&source, &count); scanErr == nil {
|
|
stats.BySource[source] = count
|
|
}
|
|
}
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *ComplianceService) decideManualReview(ctx context.Context, id int64, status, reason string) (*sharedcompliance.ManualReview, error) {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "reason is required")
|
|
}
|
|
if utf8.RuneCountInString(reason) > 1000 {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "reason must be 1000 characters or less")
|
|
}
|
|
actor := ActorFromContext(ctx)
|
|
if actor == nil || actor.OperatorID <= 0 {
|
|
return nil, response.ErrUnauthorized(40101, "missing_operator", "operator context is required")
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52021, "ops_compliance_manual_review_decision_failed", "failed to begin manual review decision")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
var reviewID int64
|
|
var articleID int64
|
|
var versionID int64
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE compliance_manual_reviews
|
|
SET status = $2,
|
|
reviewed_by = $3,
|
|
reviewed_at = NOW(),
|
|
decision_reason = $4
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING id, article_id, article_version_id
|
|
`, id, status, actor.OperatorID, reason).Scan(&reviewID, &articleID, &versionID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrConflict(41010, "compliance_manual_review_not_cancellable", "manual review is not pending")
|
|
}
|
|
return nil, response.ErrInternal(52021, "ops_compliance_manual_review_decision_failed", "failed to update manual review")
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE article_versions
|
|
SET manual_review_status = $1,
|
|
manual_review_id = $2,
|
|
manual_review_updated_at = NOW()
|
|
WHERE id = $3 AND article_id = $4
|
|
`, status, reviewID, versionID, articleID); err != nil {
|
|
return nil, response.ErrInternal(52021, "ops_compliance_manual_review_decision_failed", "failed to stamp article version")
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(52021, "ops_compliance_manual_review_decision_failed", "failed to commit manual review decision")
|
|
}
|
|
s.audit(ctx, "compliance.manual_review."+status, "compliance_manual_review", id, map[string]any{"reason": reason})
|
|
return s.loadManualReview(ctx, id, true)
|
|
}
|
|
|
|
func (s *ComplianceService) getPolicy(ctx context.Context) (*CompliancePolicy, error) {
|
|
var policy CompliancePolicy
|
|
if err := scanPolicy(s.pool.QueryRow(ctx, `
|
|
SELECT id, master_enabled, enforcement_mode, enabled_dictionaries, llm_judge_enabled, llm_categories, revision, updated_at
|
|
FROM ops.compliance_policies
|
|
WHERE id = 1
|
|
LIMIT 1
|
|
`), &policy); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40474, "compliance_policy_not_found", "compliance policy not found")
|
|
}
|
|
return nil, response.ErrInternal(52010, "ops_compliance_policy_query_failed", "failed to load compliance policy")
|
|
}
|
|
return &policy, nil
|
|
}
|
|
|
|
func (s *ComplianceService) updatePolicy(ctx context.Context, req UpdateCompliancePolicyRequest) (*CompliancePolicy, error) {
|
|
existing, _ := s.getPolicy(ctx)
|
|
mode := normalizeComplianceMode(req.EnforcementMode)
|
|
if mode == "" {
|
|
if existing != nil && existing.EnforcementMode != "" {
|
|
mode = existing.EnforcementMode
|
|
} else {
|
|
mode = "mandatory"
|
|
}
|
|
}
|
|
llmEnabled := false
|
|
if req.LLMJudgeEnabled != nil {
|
|
llmEnabled = *req.LLMJudgeEnabled
|
|
} else if existing != nil {
|
|
llmEnabled = existing.LLMJudgeEnabled
|
|
}
|
|
masterEnabled := req.MasterEnabled
|
|
if masterEnabled == nil {
|
|
if existing != nil {
|
|
masterEnabled = existing.MasterEnabled
|
|
}
|
|
if masterEnabled == nil {
|
|
defaultMaster := true
|
|
masterEnabled = &defaultMaster
|
|
}
|
|
}
|
|
enabledDictionaries := req.EnabledDictionaries
|
|
if enabledDictionaries == nil && existing != nil {
|
|
enabledDictionaries = existing.EnabledDictionaries
|
|
}
|
|
llmCategoriesValue := req.LLMCategories
|
|
if llmCategoriesValue == nil && existing != nil {
|
|
llmCategoriesValue = existing.LLMCategories
|
|
}
|
|
enabledDicts, _ := json.Marshal(enabledDictionaries)
|
|
llmCategories, _ := json.Marshal(llmCategoriesValue)
|
|
actor := ActorFromContext(ctx)
|
|
var actorID *int64
|
|
if actor != nil && actor.OperatorID > 0 {
|
|
actorID = &actor.OperatorID
|
|
}
|
|
var policy CompliancePolicy
|
|
if err := scanPolicy(s.pool.QueryRow(ctx, `
|
|
INSERT INTO ops.compliance_policies (
|
|
id, master_enabled, enforcement_mode, enabled_dictionaries, llm_judge_enabled, llm_categories, updated_by
|
|
)
|
|
VALUES (1, $1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (id)
|
|
DO UPDATE SET
|
|
master_enabled = EXCLUDED.master_enabled,
|
|
enforcement_mode = EXCLUDED.enforcement_mode,
|
|
enabled_dictionaries = EXCLUDED.enabled_dictionaries,
|
|
llm_judge_enabled = EXCLUDED.llm_judge_enabled,
|
|
llm_categories = EXCLUDED.llm_categories,
|
|
updated_by = EXCLUDED.updated_by,
|
|
revision = ops.compliance_policies.revision + 1,
|
|
updated_at = NOW()
|
|
RETURNING id, master_enabled, enforcement_mode, enabled_dictionaries, llm_judge_enabled, llm_categories, revision, updated_at
|
|
`, masterEnabled, mode, enabledDicts, llmEnabled, llmCategories, actorID), &policy); err != nil {
|
|
return nil, response.ErrInternal(52011, "ops_compliance_policy_update_failed", "failed to update compliance policy")
|
|
}
|
|
return &policy, nil
|
|
}
|
|
|
|
func (s *ComplianceService) loadManualReview(ctx context.Context, id int64, includePlaintext bool) (*sharedcompliance.ManualReview, error) {
|
|
query := `
|
|
SELECT
|
|
mr.id,
|
|
mr.tenant_id,
|
|
mr.article_id,
|
|
mr.article_version_id,
|
|
mr.original_check_record_id,
|
|
mr.requested_by,
|
|
mr.requested_at,
|
|
mr.request_note,
|
|
mr.status,
|
|
mr.cancelled_by,
|
|
mr.cancelled_at,
|
|
mr.cancel_reason,
|
|
mr.reviewed_by,
|
|
mr.reviewed_at,
|
|
mr.decision_reason,
|
|
mr.original_violation_summary,
|
|
mr.original_target_platforms,
|
|
av.title,
|
|
u.name,
|
|
t.name
|
|
`
|
|
if includePlaintext {
|
|
query += `, av.plaintext_snapshot`
|
|
}
|
|
query += `
|
|
FROM compliance_manual_reviews mr
|
|
LEFT JOIN article_versions av ON av.id = mr.article_version_id
|
|
LEFT JOIN users u ON u.id = mr.requested_by
|
|
LEFT JOIN tenants t ON t.id = mr.tenant_id
|
|
WHERE mr.id = $1
|
|
`
|
|
row := s.pool.QueryRow(ctx, query, id)
|
|
review, err := scanManualReviewForOps(row, includePlaintext)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40475, "compliance_manual_review_not_found", "manual review not found")
|
|
}
|
|
return nil, response.ErrInternal(52020, "ops_compliance_manual_reviews_query_failed", "failed to load manual review")
|
|
}
|
|
return &review, nil
|
|
}
|
|
|
|
func (s *ComplianceService) loadRecordViolations(ctx context.Context, recordID, tenantID int64) ([]sharedcompliance.Violation, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, record_id, platform_code, source, dictionary_code, dictionary_name, term_pattern, matched_text, start_offset, end_offset, dom_path, level, hint, reference_law
|
|
FROM compliance_violations
|
|
WHERE record_id = $1 AND tenant_id = $2
|
|
ORDER BY id
|
|
`, recordID, tenantID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(52031, "ops_compliance_violations_query_failed", "failed to load compliance violations")
|
|
}
|
|
defer rows.Close()
|
|
out := make([]sharedcompliance.Violation, 0)
|
|
for rows.Next() {
|
|
item, scanErr := scanViolationForOps(rows)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(52031, "ops_compliance_violations_query_failed", "failed to scan compliance violation")
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ComplianceService) bumpDictionaryVersion(ctx context.Context, dictionaryID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE ops.compliance_dictionaries
|
|
SET version = version + 1, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, dictionaryID)
|
|
return err
|
|
}
|
|
|
|
func (s *ComplianceService) loadDictionaryForUpdate(ctx context.Context, tx pgx.Tx, id int64) (*ComplianceDictionary, error) {
|
|
var item ComplianceDictionary
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT id, code, name, platform_scope, COALESCE(applicable_platforms, '{}'), default_level, enabled, version, description, created_at, updated_at, 0
|
|
FROM ops.compliance_dictionaries
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, id).Scan(&item.ID, &item.Code, &item.Name, &item.PlatformScope, &item.ApplicablePlatforms, &item.DefaultLevel, &item.Enabled, &item.Version, new(pgtype.Text), &item.CreatedAt, &item.UpdatedAt, &item.TermCount)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40471, "compliance_dictionary_not_found", "compliance dictionary not found")
|
|
}
|
|
return nil, response.ErrInternal(52001, "ops_compliance_dictionary_query_failed", "failed to load compliance dictionary")
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *ComplianceService) insertDictionaryVersionSnapshot(ctx context.Context, tx pgx.Tx, dictionaryID int64, version int) error {
|
|
var snapshot []byte
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT jsonb_build_object(
|
|
'code', d.code,
|
|
'name', d.name,
|
|
'platform_scope', d.platform_scope,
|
|
'applicable_platforms', COALESCE(to_jsonb(d.applicable_platforms), '[]'::jsonb),
|
|
'default_level', d.default_level,
|
|
'terms', COALESCE((
|
|
SELECT jsonb_agg(jsonb_build_object(
|
|
'id', t.id,
|
|
'match_type', t.match_type,
|
|
'pattern', t.pattern,
|
|
'level_override', t.level_override,
|
|
'hint', t.hint,
|
|
'reference_law', t.reference_law,
|
|
'enabled', t.enabled
|
|
) ORDER BY t.id)
|
|
FROM ops.compliance_dictionary_terms t
|
|
WHERE t.dictionary_id = d.id
|
|
), '[]'::jsonb)
|
|
)
|
|
FROM ops.compliance_dictionaries d
|
|
WHERE d.id = $1
|
|
`, dictionaryID).Scan(&snapshot)
|
|
if err != nil {
|
|
return response.ErrInternal(52008, "ops_compliance_dictionary_publish_failed", "failed to build dictionary snapshot")
|
|
}
|
|
actor := ActorFromContext(ctx)
|
|
var actorID *int64
|
|
if actor != nil && actor.OperatorID > 0 {
|
|
actorID = &actor.OperatorID
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO ops.compliance_dictionary_versions (dictionary_id, version, snapshot, published_by)
|
|
VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT (dictionary_id, version) DO NOTHING
|
|
`, dictionaryID, version, snapshot, actorID)
|
|
if err != nil {
|
|
return response.ErrInternal(52008, "ops_compliance_dictionary_publish_failed", "failed to save dictionary snapshot")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ComplianceService) getTerm(ctx context.Context, termID int64) (ComplianceDictionaryTerm, error) {
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT id, dictionary_id, match_type, pattern, level_override, hint, reference_law, enabled, created_at, updated_at
|
|
FROM ops.compliance_dictionary_terms
|
|
WHERE id = $1
|
|
`, termID)
|
|
item, err := scanComplianceDictionaryTerm(row)
|
|
if err != nil {
|
|
return item, response.ErrInternal(52005, "ops_compliance_terms_query_failed", "failed to load compliance term")
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *ComplianceService) audit(ctx context.Context, action, targetType string, targetID int64, metadata map[string]any) {
|
|
if s == nil || s.audits == nil {
|
|
return
|
|
}
|
|
actor := ActorFromContext(ctx)
|
|
if actor == nil {
|
|
return
|
|
}
|
|
if err := s.audits.Append(ctx, actor.audit(action, targetType, targetID, metadata)); err != nil && s.logger != nil {
|
|
s.logger.Warn("ops compliance audit failed", zap.String("action", action), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
type complianceDictionaryScanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanComplianceDictionary(scanner complianceDictionaryScanner) (ComplianceDictionary, error) {
|
|
var item ComplianceDictionary
|
|
var description pgtype.Text
|
|
if err := scanner.Scan(&item.ID, &item.Code, &item.Name, &item.PlatformScope, &item.ApplicablePlatforms, &item.DefaultLevel, &item.Enabled, &item.Version, &description, &item.CreatedAt, &item.UpdatedAt, &item.TermCount); err != nil {
|
|
return item, err
|
|
}
|
|
if description.Valid {
|
|
item.Description = &description.String
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func scanComplianceDictionaryTerm(scanner complianceDictionaryScanner) (ComplianceDictionaryTerm, error) {
|
|
var item ComplianceDictionaryTerm
|
|
var level pgtype.Text
|
|
var hint pgtype.Text
|
|
var referenceLaw pgtype.Text
|
|
if err := scanner.Scan(&item.ID, &item.DictionaryID, &item.MatchType, &item.Pattern, &level, &hint, &referenceLaw, &item.Enabled, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
|
return item, err
|
|
}
|
|
if level.Valid {
|
|
item.LevelOverride = &level.String
|
|
}
|
|
if hint.Valid {
|
|
item.Hint = &hint.String
|
|
}
|
|
if referenceLaw.Valid {
|
|
item.ReferenceLaw = &referenceLaw.String
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func scanPolicy(scanner complianceDictionaryScanner, policy *CompliancePolicy) error {
|
|
var master pgtype.Bool
|
|
var enabledRaw []byte
|
|
var llmCategoriesRaw []byte
|
|
if err := scanner.Scan(&policy.ID, &master, &policy.EnforcementMode, &enabledRaw, &policy.LLMJudgeEnabled, &llmCategoriesRaw, &policy.Revision, &policy.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
if master.Valid {
|
|
policy.MasterEnabled = &master.Bool
|
|
}
|
|
_ = json.Unmarshal(enabledRaw, &policy.EnabledDictionaries)
|
|
_ = json.Unmarshal(llmCategoriesRaw, &policy.LLMCategories)
|
|
if policy.EnabledDictionaries == nil {
|
|
policy.EnabledDictionaries = []int64{}
|
|
}
|
|
if policy.LLMCategories == nil {
|
|
policy.LLMCategories = []string{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanManualReviewForOps(scanner complianceDictionaryScanner, includePlaintext bool) (sharedcompliance.ManualReview, error) {
|
|
var item sharedcompliance.ManualReview
|
|
var checkID pgtype.Int8
|
|
var requestNote pgtype.Text
|
|
var cancelledBy pgtype.Int8
|
|
var cancelledAt pgtype.Timestamptz
|
|
var cancelReason pgtype.Text
|
|
var reviewedBy pgtype.Int8
|
|
var reviewedAt pgtype.Timestamptz
|
|
var decisionReason pgtype.Text
|
|
var summary []byte
|
|
var platforms []string
|
|
var title pgtype.Text
|
|
var applicant pgtype.Text
|
|
var tenant pgtype.Text
|
|
dest := []any{
|
|
&item.ID, &item.TenantID, &item.ArticleID, &item.ArticleVersionID, &checkID, &item.RequestedBy, &item.RequestedAt, &requestNote, &item.Status, &cancelledBy, &cancelledAt, &cancelReason, &reviewedBy, &reviewedAt, &decisionReason, &summary, &platforms, &title, &applicant, &tenant,
|
|
}
|
|
var plaintext pgtype.Text
|
|
if includePlaintext {
|
|
dest = append(dest, &plaintext)
|
|
}
|
|
if err := scanner.Scan(dest...); err != nil {
|
|
return item, err
|
|
}
|
|
if checkID.Valid {
|
|
item.CheckRecordID = &checkID.Int64
|
|
}
|
|
if requestNote.Valid {
|
|
item.RequestNote = &requestNote.String
|
|
}
|
|
if cancelledBy.Valid {
|
|
item.CancelledBy = &cancelledBy.Int64
|
|
}
|
|
if cancelledAt.Valid {
|
|
item.CancelledAt = &cancelledAt.Time
|
|
}
|
|
if cancelReason.Valid {
|
|
item.CancelReason = &cancelReason.String
|
|
}
|
|
if reviewedBy.Valid {
|
|
item.ReviewedBy = &reviewedBy.Int64
|
|
}
|
|
if reviewedAt.Valid {
|
|
item.ReviewedAt = &reviewedAt.Time
|
|
}
|
|
if decisionReason.Valid {
|
|
item.DecisionReason = &decisionReason.String
|
|
}
|
|
_ = json.Unmarshal(summary, &item.ViolationSummary)
|
|
item.OriginalPlatforms = platforms
|
|
if title.Valid {
|
|
item.ArticleTitle = &title.String
|
|
}
|
|
if applicant.Valid {
|
|
item.ApplicantName = &applicant.String
|
|
}
|
|
if tenant.Valid {
|
|
item.TenantName = &tenant.String
|
|
}
|
|
if includePlaintext && plaintext.Valid {
|
|
item.ArticlePlaintext = &plaintext.String
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
type opsCheckRecordRow struct {
|
|
ID int64
|
|
TenantID int64
|
|
ArticleID int64
|
|
ArticleVersionID int64
|
|
TargetPlatforms []string
|
|
ContentHash string
|
|
PolicyFingerprint string
|
|
EnforcementMode string
|
|
HighestLevel *string
|
|
Passed bool
|
|
HitCount int
|
|
StoredHitCount int
|
|
Truncated bool
|
|
ManualReviewStatus sharedcompliance.ManualReviewStatus
|
|
ManualReviewID *int64
|
|
CheckedAt time.Time
|
|
DurationMS int
|
|
}
|
|
|
|
func scanCheckRecordForOps(scanner complianceDictionaryScanner) (opsCheckRecordRow, error) {
|
|
var record opsCheckRecordRow
|
|
var articleID pgtype.Int8
|
|
var versionID pgtype.Int8
|
|
var highest pgtype.Text
|
|
var manualReviewID pgtype.Int8
|
|
var manualStatus string
|
|
if err := scanner.Scan(&record.ID, &record.TenantID, &articleID, &versionID, &record.TargetPlatforms, &record.ContentHash, &record.PolicyFingerprint, &record.EnforcementMode, &highest, &record.Passed, &record.HitCount, &record.StoredHitCount, &record.Truncated, &manualStatus, &manualReviewID, &record.CheckedAt, &record.DurationMS); err != nil {
|
|
return record, err
|
|
}
|
|
if articleID.Valid {
|
|
record.ArticleID = articleID.Int64
|
|
}
|
|
if versionID.Valid {
|
|
record.ArticleVersionID = versionID.Int64
|
|
}
|
|
if highest.Valid {
|
|
record.HighestLevel = &highest.String
|
|
}
|
|
if manualReviewID.Valid {
|
|
record.ManualReviewID = &manualReviewID.Int64
|
|
}
|
|
record.ManualReviewStatus = sharedcompliance.ManualReviewStatus(manualStatus)
|
|
return record, nil
|
|
}
|
|
|
|
func scanViolationForOps(scanner complianceDictionaryScanner) (sharedcompliance.Violation, error) {
|
|
var item sharedcompliance.Violation
|
|
var platform pgtype.Text
|
|
var dictCode pgtype.Text
|
|
var dictName pgtype.Text
|
|
var termPattern pgtype.Text
|
|
var domPath pgtype.Text
|
|
var hint pgtype.Text
|
|
var ref pgtype.Text
|
|
if err := scanner.Scan(&item.ID, &item.RecordID, &platform, &item.Source, &dictCode, &dictName, &termPattern, &item.MatchedText, &item.StartOffset, &item.EndOffset, &domPath, &item.Level, &hint, &ref); err != nil {
|
|
return item, err
|
|
}
|
|
if platform.Valid {
|
|
item.PlatformCode = &platform.String
|
|
}
|
|
if dictCode.Valid {
|
|
item.DictionaryCode = &dictCode.String
|
|
}
|
|
if dictName.Valid {
|
|
item.DictionaryName = &dictName.String
|
|
}
|
|
if termPattern.Valid {
|
|
item.TermPattern = &termPattern.String
|
|
}
|
|
if domPath.Valid {
|
|
item.DomPath = &domPath.String
|
|
}
|
|
if hint.Valid {
|
|
item.Hint = &hint.String
|
|
}
|
|
if ref.Valid {
|
|
item.ReferenceLaw = &ref.String
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func checkResultFromOpsRecord(record opsCheckRecordRow, violations []sharedcompliance.Violation) *sharedcompliance.CheckResult {
|
|
decision := sharedcompliance.GateDecisionPass
|
|
if record.HitCount > 0 {
|
|
decision = sharedcompliance.GateDecisionNeedsAck
|
|
if record.EnforcementMode == "mandatory" && record.HighestLevel != nil && *record.HighestLevel == "block" {
|
|
decision = sharedcompliance.GateDecisionBlock
|
|
}
|
|
}
|
|
return &sharedcompliance.CheckResult{
|
|
RecordID: record.ID,
|
|
Decision: decision,
|
|
Passed: record.Passed,
|
|
HighestLevel: record.HighestLevel,
|
|
Violations: violations,
|
|
HitCount: record.HitCount,
|
|
StoredHitCount: record.StoredHitCount,
|
|
Truncated: record.Truncated,
|
|
EnforcementMode: record.EnforcementMode,
|
|
ContentHash: record.ContentHash,
|
|
PolicyFingerprint: record.PolicyFingerprint,
|
|
TargetPlatforms: record.TargetPlatforms,
|
|
ManualReviewStatus: record.ManualReviewStatus,
|
|
ManualReviewID: record.ManualReviewID,
|
|
CheckedAt: record.CheckedAt,
|
|
DurationMS: record.DurationMS,
|
|
}
|
|
}
|
|
|
|
func normalizeDictionaryInput(req UpsertComplianceDictionaryRequest, updating bool) (UpsertComplianceDictionaryRequest, error) {
|
|
req.Code = strings.TrimSpace(req.Code)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.PlatformScope = strings.ToLower(strings.TrimSpace(req.PlatformScope))
|
|
req.DefaultLevel = normalizeComplianceLevel(req.DefaultLevel)
|
|
if !updating && req.Code == "" {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "code is required")
|
|
}
|
|
if req.Name == "" {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
if req.PlatformScope == "" {
|
|
req.PlatformScope = "all"
|
|
}
|
|
if req.PlatformScope != "all" && req.PlatformScope != "specific" {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "platform_scope must be all or specific")
|
|
}
|
|
req.ApplicablePlatforms = normalizedPlatformArray(req.ApplicablePlatforms, req.PlatformScope)
|
|
if req.PlatformScope == "specific" && len(req.ApplicablePlatforms) == 0 {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "applicable_platforms is required for specific scope")
|
|
}
|
|
if req.DefaultLevel == "" {
|
|
req.DefaultLevel = "block"
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
req.Enabled = &enabled
|
|
if req.Description != nil {
|
|
value := strings.TrimSpace(*req.Description)
|
|
req.Description = &value
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func normalizeTermInput(req CreateComplianceDictionaryTermRequest) (CreateComplianceDictionaryTermRequest, error) {
|
|
req.MatchType = normalizeTermMatchType(req.MatchType)
|
|
if req.MatchType == "" {
|
|
req.MatchType = "exact"
|
|
}
|
|
req.Pattern = strings.TrimSpace(req.Pattern)
|
|
if req.Pattern == "" {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "pattern is required")
|
|
}
|
|
if req.LevelOverride != nil {
|
|
level := normalizeComplianceLevel(*req.LevelOverride)
|
|
if level == "" {
|
|
return req, response.ErrBadRequest(40001, "invalid_params", "level_override is invalid")
|
|
}
|
|
req.LevelOverride = &level
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
req.Enabled = &enabled
|
|
return req, nil
|
|
}
|
|
|
|
func normalizeTermMatchType(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
switch value {
|
|
case "", "exact":
|
|
return "exact"
|
|
case "regex":
|
|
return value
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeComplianceLevel(level string) string {
|
|
level = strings.ToLower(strings.TrimSpace(level))
|
|
switch level {
|
|
case "block", "high", "medium", "info":
|
|
return level
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeComplianceMode(mode string) string {
|
|
mode = strings.ToLower(strings.TrimSpace(mode))
|
|
switch mode {
|
|
case "mandatory", "advisory", "disabled":
|
|
return mode
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizedPlatformArray(platforms []string, scope string) []string {
|
|
if strings.EqualFold(strings.TrimSpace(scope), "all") {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(platforms))
|
|
for _, platform := range platforms {
|
|
platform = strings.TrimSpace(platform)
|
|
if platform == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[platform]; ok {
|
|
continue
|
|
}
|
|
seen[platform] = struct{}{}
|
|
out = append(out, platform)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nullableStringPointer(value *string) *string {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
trimmed := strings.TrimSpace(*value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return &trimmed
|
|
}
|
|
|
|
func manualReviewWhere(status string, tenantID *int64) (string, []any) {
|
|
where := "1=1"
|
|
args := []any{}
|
|
status = strings.ToLower(strings.TrimSpace(status))
|
|
if status != "" {
|
|
args = append(args, status)
|
|
where += fmt.Sprintf(" AND mr.status = $%d", len(args))
|
|
}
|
|
if tenantID != nil && *tenantID > 0 {
|
|
args = append(args, *tenantID)
|
|
where += fmt.Sprintf(" AND mr.tenant_id = $%d", len(args))
|
|
}
|
|
return where, args
|
|
}
|
|
|
|
func complianceRecordWhere(req ComplianceRecordListRequest) (string, []any) {
|
|
where := "1=1"
|
|
args := []any{}
|
|
if req.TenantID != nil && *req.TenantID > 0 {
|
|
args = append(args, *req.TenantID)
|
|
where += fmt.Sprintf(" AND tenant_id = $%d", len(args))
|
|
}
|
|
if req.ArticleID != nil && *req.ArticleID > 0 {
|
|
args = append(args, *req.ArticleID)
|
|
where += fmt.Sprintf(" AND article_id = $%d", len(args))
|
|
}
|
|
switch strings.TrimSpace(req.Decision) {
|
|
case "pass":
|
|
where += " AND hit_count = 0"
|
|
case "block":
|
|
where += " AND enforcement_mode = 'mandatory' AND highest_level = 'block'"
|
|
case "needs_ack":
|
|
where += " AND hit_count > 0 AND NOT (enforcement_mode = 'mandatory' AND highest_level = 'block')"
|
|
}
|
|
return where, args
|
|
}
|
|
|
|
func normalizePage(page, size int) (int, int) {
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if size <= 0 || size > 200 {
|
|
size = 50
|
|
}
|
|
return page, size
|
|
}
|
|
|
|
func isOpsUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|
|
|
|
func isOpsForeignKeyViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
|
}
|