139 lines
5.5 KiB
Go
139 lines
5.5 KiB
Go
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
|
|
}
|