Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,65 @@
-- +goose Up
-- SENTINEL SOC — PostgreSQL Schema
-- Tables: soc_events, soc_incidents, soc_sensors
CREATE TABLE soc_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
sensor_id TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0,
description TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
decision_hash TEXT NOT NULL DEFAULT '',
verdict TEXT NOT NULL DEFAULT 'REVIEW',
timestamp TIMESTAMPTZ NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE soc_incidents (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'OPEN',
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
event_ids JSONB NOT NULL DEFAULT '[]',
event_count INTEGER NOT NULL DEFAULT 0,
decision_chain_anchor TEXT NOT NULL DEFAULT '',
chain_length INTEGER NOT NULL DEFAULT 0,
correlation_rule TEXT NOT NULL DEFAULT '',
kill_chain_phase TEXT NOT NULL DEFAULT '',
mitre_mapping JSONB NOT NULL DEFAULT '[]',
playbook_applied TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ
);
CREATE TABLE soc_sensors (
sensor_id TEXT PRIMARY KEY,
sensor_type TEXT NOT NULL,
status TEXT DEFAULT 'UNKNOWN',
first_seen TIMESTAMPTZ NOT NULL,
last_seen TIMESTAMPTZ NOT NULL,
event_count INTEGER DEFAULT 0,
missed_heartbeats INTEGER DEFAULT 0,
hostname TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT ''
);
-- Indexes
CREATE INDEX idx_soc_events_timestamp ON soc_events(timestamp);
CREATE INDEX idx_soc_events_severity ON soc_events(severity);
CREATE INDEX idx_soc_events_category ON soc_events(category);
CREATE INDEX idx_soc_events_sensor ON soc_events(sensor_id);
CREATE INDEX idx_soc_events_content_hash ON soc_events(content_hash);
CREATE INDEX idx_soc_incidents_status ON soc_incidents(status);
CREATE INDEX idx_soc_sensors_status ON soc_sensors(status);
-- +goose Down
DROP TABLE IF EXISTS soc_sensors;
DROP TABLE IF EXISTS soc_incidents;
DROP TABLE IF EXISTS soc_events;

View file

@ -0,0 +1,57 @@
-- +goose Up
-- SENTINEL SOC — Auth & Multi-Tenancy (PostgreSQL)
-- Tables: users, api_keys, tenants
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
tenant_id TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan_id TEXT NOT NULL DEFAULT 'free',
stripe_customer_id TEXT NOT NULL DEFAULT '',
stripe_sub_id TEXT NOT NULL DEFAULT '',
owner_user_id TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
events_this_month INTEGER NOT NULL DEFAULT 0,
month_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add assigned_to column to incidents (was missing in 001)
ALTER TABLE soc_incidents ADD COLUMN IF NOT EXISTS assigned_to TEXT NOT NULL DEFAULT '';
-- Indexes
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_user_id);
-- +goose Down
DROP TABLE IF EXISTS tenants;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS users;
ALTER TABLE soc_incidents DROP COLUMN IF EXISTS assigned_to;

View file

@ -0,0 +1,91 @@
// Package postgres provides PostgreSQL persistence for the SENTINEL SOC.
//
// Uses pgx/v5 driver (pure Go, no CGO) with connection pooling.
// Migrations managed by goose.
package postgres
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // pgx driver registered as "pgx"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrations embed.FS
// DB wraps a PostgreSQL connection pool.
type DB struct {
pool *sql.DB
logger *slog.Logger
}
// Open connects to PostgreSQL and runs any pending goose migrations.
//
// dsn example: "postgres://sentinel:pass@localhost:5432/sentinel_soc?sslmode=disable"
func Open(dsn string, logger *slog.Logger) (*DB, error) {
pool, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("postgres: open: %w", err)
}
// Connection pool tuning for SOC workload.
pool.SetMaxOpenConns(25)
pool.SetMaxIdleConns(10)
pool.SetConnMaxLifetime(5 * time.Minute)
pool.SetConnMaxIdleTime(1 * time.Minute)
// Verify connectivity.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := pool.PingContext(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("postgres: ping: %w", err)
}
db := &DB{pool: pool, logger: logger}
// Run pending goose migrations.
if err := db.migrate(); err != nil {
pool.Close()
return nil, fmt.Errorf("postgres: migrate: %w", err)
}
logger.Info("PostgreSQL connected", "dsn_host", redactDSN(dsn))
return db, nil
}
// Close releases the connection pool.
func (db *DB) Close() error {
return db.pool.Close()
}
// Pool returns the underlying *sql.DB for direct queries.
func (db *DB) Pool() *sql.DB {
return db.pool
}
func (db *DB) migrate() error {
goose.SetBaseFS(migrations)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose dialect: %w", err)
}
if err := goose.Up(db.pool, "migrations"); err != nil {
return fmt.Errorf("goose up: %w", err)
}
db.logger.Info("goose migrations applied")
return nil
}
// redactDSN extracts host:port for logging without exposing credentials.
func redactDSN(dsn string) string {
if len(dsn) > 60 {
return dsn[:20] + "…" + dsn[len(dsn)-15:]
}
return "***"
}

View file

@ -0,0 +1,427 @@
package postgres
import (
"database/sql"
"fmt"
"time"
"github.com/syntrex/gomcp/internal/domain/soc"
)
// SOCRepo provides PostgreSQL persistence for SOC events, incidents, and sensors.
// Implements domain/soc.SOCRepository.
type SOCRepo struct {
db *DB
}
// NewSOCRepo creates a PostgreSQL-backed SOC repository.
// Unlike SQLite, tables are created via goose migrations (not inline DDL).
func NewSOCRepo(db *DB) *SOCRepo {
return &SOCRepo{db: db}
}
// === Events ===
// InsertEvent persists a SOC event.
func (r *SOCRepo) InsertEvent(e soc.SOCEvent) error {
_, err := r.db.Pool().Exec(
`INSERT INTO soc_events (id, tenant_id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, content_hash, decision_hash, verdict, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
e.ID, e.TenantID, e.Source, e.SensorID, e.Severity, e.Category, e.Subcategory,
e.Confidence, e.Description, e.SessionID, e.ContentHash, e.DecisionHash, e.Verdict,
e.Timestamp,
)
return err
}
// EventExistsByHash checks if an event with the given content hash already exists (§5.2 dedup).
func (r *SOCRepo) EventExistsByHash(contentHash string) (bool, error) {
if contentHash == "" {
return false, nil
}
var count int
err := r.db.Pool().QueryRow(
"SELECT COUNT(*) FROM soc_events WHERE content_hash = $1", contentHash,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// ListEvents returns events ordered by timestamp (newest first), with limit.
func (r *SOCRepo) ListEvents(tenantID string, limit int) ([]soc.SOCEvent, error) {
if limit <= 0 {
limit = 50
}
var rows *sql.Rows
var err error
if tenantID != "" {
rows, err = r.db.Pool().Query(
`SELECT id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, decision_hash, verdict, timestamp
FROM soc_events WHERE tenant_id = $1 ORDER BY timestamp DESC LIMIT $2`, tenantID, limit)
} else {
rows, err = r.db.Pool().Query(
`SELECT id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, decision_hash, verdict, timestamp
FROM soc_events ORDER BY timestamp DESC LIMIT $1`, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanEvents(rows)
}
// ListEventsByCategory returns events filtered by category.
func (r *SOCRepo) ListEventsByCategory(tenantID string, category string, limit int) ([]soc.SOCEvent, error) {
if limit <= 0 {
limit = 50
}
var rows *sql.Rows
var err error
if tenantID != "" {
rows, err = r.db.Pool().Query(
`SELECT id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, decision_hash, verdict, timestamp
FROM soc_events WHERE tenant_id = $1 AND category = $2 ORDER BY timestamp DESC LIMIT $3`,
tenantID, category, limit)
} else {
rows, err = r.db.Pool().Query(
`SELECT id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, decision_hash, verdict, timestamp
FROM soc_events WHERE category = $1 ORDER BY timestamp DESC LIMIT $2`,
category, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanEvents(rows)
}
// CountEvents returns total event count.
func (r *SOCRepo) CountEvents(tenantID string) (int, error) {
var count int
var err error
if tenantID != "" {
err = r.db.Pool().QueryRow("SELECT COUNT(*) FROM soc_events WHERE tenant_id = $1", tenantID).Scan(&count)
} else {
err = r.db.Pool().QueryRow("SELECT COUNT(*) FROM soc_events").Scan(&count)
}
return count, err
}
// GetEvent retrieves a single event by ID.
func (r *SOCRepo) GetEvent(id string) (*soc.SOCEvent, error) {
var e soc.SOCEvent
err := r.db.Pool().QueryRow(
`SELECT id, source, sensor_id, severity, category, subcategory,
confidence, description, session_id, decision_hash, verdict, timestamp
FROM soc_events WHERE id = $1`, id,
).Scan(&e.ID, &e.Source, &e.SensorID, &e.Severity,
&e.Category, &e.Subcategory, &e.Confidence, &e.Description,
&e.SessionID, &e.DecisionHash, &e.Verdict, &e.Timestamp)
if err != nil {
return nil, err
}
return &e, nil
}
// CountEventsSince returns events in the given time window.
func (r *SOCRepo) CountEventsSince(tenantID string, since time.Time) (int, error) {
var count int
var err error
if tenantID != "" {
err = r.db.Pool().QueryRow(
"SELECT COUNT(*) FROM soc_events WHERE tenant_id = $1 AND timestamp >= $2", tenantID, since,
).Scan(&count)
} else {
err = r.db.Pool().QueryRow(
"SELECT COUNT(*) FROM soc_events WHERE timestamp >= $1", since,
).Scan(&count)
}
return count, err
}
func scanEvents(rows *sql.Rows) ([]soc.SOCEvent, error) {
var events []soc.SOCEvent
for rows.Next() {
var e soc.SOCEvent
err := rows.Scan(&e.ID, &e.Source, &e.SensorID, &e.Severity,
&e.Category, &e.Subcategory, &e.Confidence, &e.Description,
&e.SessionID, &e.DecisionHash, &e.Verdict, &e.Timestamp)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// === Incidents ===
// InsertIncident persists a new incident.
func (r *SOCRepo) InsertIncident(inc soc.Incident) error {
_, err := r.db.Pool().Exec(
`INSERT INTO soc_incidents (id, tenant_id, status, severity, title, description,
event_count, decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
inc.ID, inc.TenantID, inc.Status, inc.Severity, inc.Title, inc.Description,
inc.EventCount, inc.DecisionChainAnchor, inc.ChainLength,
inc.CorrelationRule, inc.KillChainPhase,
inc.CreatedAt, inc.UpdatedAt,
)
return err
}
// GetIncident retrieves an incident by ID.
func (r *SOCRepo) GetIncident(id string) (*soc.Incident, error) {
var inc soc.Incident
var resolvedAt sql.NullTime
err := r.db.Pool().QueryRow(
`SELECT id, status, severity, title, description, event_count,
decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, playbook_applied, created_at, updated_at, resolved_at
FROM soc_incidents WHERE id = $1`, id,
).Scan(&inc.ID, &inc.Status, &inc.Severity, &inc.Title, &inc.Description,
&inc.EventCount, &inc.DecisionChainAnchor, &inc.ChainLength,
&inc.CorrelationRule, &inc.KillChainPhase, &inc.PlaybookApplied,
&inc.CreatedAt, &inc.UpdatedAt, &resolvedAt)
if err != nil {
return nil, err
}
if resolvedAt.Valid {
inc.ResolvedAt = &resolvedAt.Time
}
return &inc, nil
}
// ListIncidents returns incidents, optionally filtered by status.
func (r *SOCRepo) ListIncidents(tenantID string, status string, limit int) ([]soc.Incident, error) {
if limit <= 0 {
limit = 50
}
var rows *sql.Rows
var err error
switch {
case tenantID != "" && status != "":
rows, err = r.db.Pool().Query(
`SELECT id, status, severity, title, description, event_count,
decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, playbook_applied, created_at, updated_at
FROM soc_incidents WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3`,
tenantID, status, limit)
case tenantID != "":
rows, err = r.db.Pool().Query(
`SELECT id, status, severity, title, description, event_count,
decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, playbook_applied, created_at, updated_at
FROM soc_incidents WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2`,
tenantID, limit)
case status != "":
rows, err = r.db.Pool().Query(
`SELECT id, status, severity, title, description, event_count,
decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, playbook_applied, created_at, updated_at
FROM soc_incidents WHERE status = $1 ORDER BY created_at DESC LIMIT $2`,
status, limit)
default:
rows, err = r.db.Pool().Query(
`SELECT id, status, severity, title, description, event_count,
decision_chain_anchor, chain_length, correlation_rule,
kill_chain_phase, playbook_applied, created_at, updated_at
FROM soc_incidents ORDER BY created_at DESC LIMIT $1`, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var incidents []soc.Incident
for rows.Next() {
var inc soc.Incident
err := rows.Scan(&inc.ID, &inc.Status, &inc.Severity, &inc.Title,
&inc.Description, &inc.EventCount, &inc.DecisionChainAnchor,
&inc.ChainLength, &inc.CorrelationRule, &inc.KillChainPhase,
&inc.PlaybookApplied, &inc.CreatedAt, &inc.UpdatedAt)
if err != nil {
return nil, err
}
incidents = append(incidents, inc)
}
return incidents, rows.Err()
}
// UpdateIncidentStatus updates status (and optionally resolved_at).
func (r *SOCRepo) UpdateIncidentStatus(id string, status soc.IncidentStatus) error {
now := time.Now()
if status == soc.StatusResolved || status == soc.StatusFalsePositive {
_, err := r.db.Pool().Exec(
`UPDATE soc_incidents SET status = $1, updated_at = $2, resolved_at = $3 WHERE id = $4`,
status, now, now, id)
return err
}
_, err := r.db.Pool().Exec(
`UPDATE soc_incidents SET status = $1, updated_at = $2 WHERE id = $3`,
status, now, id)
return err
}
// CountOpenIncidents returns count of non-resolved incidents.
func (r *SOCRepo) CountOpenIncidents(tenantID string) (int, error) {
var count int
var err error
if tenantID != "" {
err = r.db.Pool().QueryRow(
"SELECT COUNT(*) FROM soc_incidents WHERE tenant_id = $1 AND status IN ('OPEN', 'INVESTIGATING')",
tenantID,
).Scan(&count)
} else {
err = r.db.Pool().QueryRow(
"SELECT COUNT(*) FROM soc_incidents WHERE status IN ('OPEN', 'INVESTIGATING')",
).Scan(&count)
}
return count, err
}
// UpdateIncident persists full incident state (case management).
func (r *SOCRepo) UpdateIncident(inc *soc.Incident) error {
_, err := r.db.Pool().Exec(
`UPDATE soc_incidents SET
status = $1, severity = $2, description = $3,
event_count = $4, assigned_to = COALESCE($5, ''),
playbook_applied = $6, kill_chain_phase = $7,
updated_at = $8, resolved_at = $9
WHERE id = $10`,
inc.Status, inc.Severity, inc.Description,
inc.EventCount, inc.AssignedTo,
inc.PlaybookApplied, inc.KillChainPhase,
inc.UpdatedAt, inc.ResolvedAt,
inc.ID,
)
return err
}
// === Sensors ===
// UpsertSensor creates or updates a sensor entry.
func (r *SOCRepo) UpsertSensor(s soc.Sensor) error {
_, err := r.db.Pool().Exec(
`INSERT INTO soc_sensors (sensor_id, tenant_id, sensor_type, status, first_seen, last_seen,
event_count, missed_heartbeats, hostname, version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT(sensor_id) DO UPDATE SET
status = EXCLUDED.status,
last_seen = EXCLUDED.last_seen,
event_count = EXCLUDED.event_count,
missed_heartbeats = EXCLUDED.missed_heartbeats`,
s.SensorID, s.TenantID, s.SensorType, s.Status,
s.FirstSeen, s.LastSeen,
s.EventCount, s.MissedHeartbeats, s.Hostname, s.Version,
)
return err
}
// GetSensor retrieves a sensor by ID.
func (r *SOCRepo) GetSensor(id string) (*soc.Sensor, error) {
var s soc.Sensor
err := r.db.Pool().QueryRow(
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
event_count, missed_heartbeats, hostname, version
FROM soc_sensors WHERE sensor_id = $1`, id,
).Scan(&s.SensorID, &s.SensorType, &s.Status, &s.FirstSeen, &s.LastSeen,
&s.EventCount, &s.MissedHeartbeats, &s.Hostname, &s.Version)
if err != nil {
return nil, err
}
return &s, nil
}
// ListSensors returns all registered sensors.
func (r *SOCRepo) ListSensors(tenantID string) ([]soc.Sensor, error) {
var rows *sql.Rows
var err error
if tenantID != "" {
rows, err = r.db.Pool().Query(
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
event_count, missed_heartbeats, hostname, version
FROM soc_sensors WHERE tenant_id = $1 ORDER BY last_seen DESC`, tenantID)
} else {
rows, err = r.db.Pool().Query(
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
event_count, missed_heartbeats, hostname, version
FROM soc_sensors ORDER BY last_seen DESC`)
}
if err != nil {
return nil, err
}
defer rows.Close()
var sensors []soc.Sensor
for rows.Next() {
var s soc.Sensor
err := rows.Scan(&s.SensorID, &s.SensorType, &s.Status,
&s.FirstSeen, &s.LastSeen, &s.EventCount, &s.MissedHeartbeats,
&s.Hostname, &s.Version)
if err != nil {
return nil, err
}
sensors = append(sensors, s)
}
return sensors, rows.Err()
}
// CountSensorsByStatus returns sensor count grouped by status.
func (r *SOCRepo) CountSensorsByStatus(tenantID string) (map[soc.SensorStatus]int, error) {
var rows *sql.Rows
var err error
if tenantID != "" {
rows, err = r.db.Pool().Query("SELECT status, COUNT(*) FROM soc_sensors WHERE tenant_id = $1 GROUP BY status", tenantID)
} else {
rows, err = r.db.Pool().Query("SELECT status, COUNT(*) FROM soc_sensors GROUP BY status")
}
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[soc.SensorStatus]int)
for rows.Next() {
var status soc.SensorStatus
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, err
}
result[status] = count
}
return result, rows.Err()
}
// PurgeExpiredEvents deletes events older than the retention period.
func (r *SOCRepo) PurgeExpiredEvents(retentionDays int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -retentionDays)
result, err := r.db.Pool().Exec("DELETE FROM soc_events WHERE timestamp < $1", cutoff)
if err != nil {
return 0, fmt.Errorf("purge events: %w", err)
}
return result.RowsAffected()
}
// PurgeExpiredIncidents deletes resolved incidents older than the retention period.
func (r *SOCRepo) PurgeExpiredIncidents(retentionDays int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -retentionDays)
result, err := r.db.Pool().Exec(
"DELETE FROM soc_incidents WHERE status = $1 AND created_at < $2",
soc.StatusResolved, cutoff)
if err != nil {
return 0, fmt.Errorf("purge incidents: %w", err)
}
return result.RowsAffected()
}
// Compile-time interface compliance check.
var _ soc.SOCRepository = (*SOCRepo)(nil)