Files
yovision/Bell/internal/store/audit_postgres.go
T

139 lines
5.5 KiB
Go
Raw Normal View History

2026-08-11 00:24:32 +08:00
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/audit"
)
func (p *Postgres) AuditRelayReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 4 {
return errors.New("postgres Bell schema migration v4 is required for audit relay")
}
var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool
var receiptUse bool
if err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
has_table_privilege(current_user, 'bell.audit_events', 'INSERT'),
has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'),
has_table_privilege(current_user, 'bell.audit_events', 'DELETE'),
has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan(
&auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse,
); err != nil {
return errors.New("verify Bell audit relay privileges")
}
if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse {
return errors.New("Bell audit relay privileges violate append-only boundary")
}
return nil
}
func (p *Postgres) ProcessAuditBatch(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
candidates []audit.Candidate,
) ([]audit.Result, error) {
if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize {
return nil, errors.New("invalid audit candidate batch")
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, errors.New("begin Bell audit batch")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil {
return nil, errors.New("lock Bell audit receipt")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil {
return nil, errors.New("expire Bell audit receipts")
}
var existingHash, existingBody []byte
err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text
FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody)
if err == nil {
if !bytes.Equal(existingHash, requestHash[:]) {
return nil, audit.ErrReplayConflict
}
var response audit.BatchResponse
if err := json.Unmarshal(existingBody, &response); err != nil {
return nil, errors.New("decode stored Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit replay")
}
return response.Results, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("read Bell audit receipt")
}
results := make([]audit.Result, 0, len(candidates))
for _, candidate := range candidates {
if candidate.ErrorCode != "" {
code := candidate.ErrorCode
results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code})
continue
}
event := candidate.Envelope.Event
payload, err := json.Marshal(event)
if err != nil {
return nil, errors.New("encode Bell audit fact")
}
result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events(
source_system, event_id, schema_version, event_type, tenant_id, site_id,
device_id, actor_type, actor_id, reason, trace_id, aggregate_generation,
quota_source_version, area_policy_source_version, payload, occurred_at, record_hash
) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
ON CONFLICT (source_system,event_id) DO NOTHING`,
event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID,
event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason,
event.TraceID, event.AggregateGeneration,
event.ProjectionVersions.QuotaSourceVersion,
event.ProjectionVersions.AreaPolicySourceVersion,
payload, event.OccurredAt, candidate.RecordHash[:])
if err != nil {
return nil, fmt.Errorf("insert Bell audit fact: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return nil, errors.New("read Bell audit insert result")
}
if affected == 1 {
results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"})
continue
}
var storedHash []byte
if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events
WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil {
return nil, errors.New("read existing Bell audit fact")
}
if bytes.Equal(storedHash, candidate.RecordHash[:]) {
results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"})
} else {
code := "id_conflict"
results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code})
}
}
encoded, err := json.Marshal(audit.BatchResponse{Results: results})
if err != nil {
return nil, errors.New("encode Bell audit response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil {
return nil, errors.New("insert Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit batch")
}
return results, nil
}