Documentation
¶
Overview ¶
Package audit — hash chain external publisher.
Periodically publishes the audit log's chain-tip (latest entry_hash + entry count) to external object storage (Cloudflare R2 / S3-compatible). The publication is signed with HMAC-SHA256 so an attacker who gains write access to the audit database cannot silently rewrite history — the external record of the chain tip acts as an independent anchor that any verifier can check against the local DB via VerifyChain().
SEBI Cybersecurity & Cyber Resilience Framework (CSCRF) requires tamper- evident audit logs. A hash-chain alone is necessary but not sufficient: if the attacker rewrites every entry_hash consistently, the local chain still verifies. Publishing the tip externally closes that gap.
The feature is OPT-IN. If the required env vars are not set, the publisher logs "disabled (no storage configured)" once at startup and does nothing. This keeps local dev and unconfigured deployments working while making production opt-in a simple env-var change.
Storage client: we avoid pulling in aws-sdk-go (large transitive tree) and instead speak raw S3 REST via net/http + AWS SigV4. Cloudflare R2 accepts SigV4 signed PUT requests on its S3-compatible endpoint. Only PUT /object is implemented — no list/get/delete needed for this feature.
Package audit provides SQLite-backed persistence for MCP tool call audit records.
Index ¶
- Constants
- Variables
- func ClearPluginEventTypes()
- func ClientIPFromCtx(ctx context.Context) string
- func ClientUAFromCtx(ctx context.Context) string
- func ComputeProofHash(noticeBytes, actionBytes string) string
- func HashEmail(email string) string
- func ListEventTypes() map[string]EventTypeSchema
- func Middleware(store *Store) server.ToolHandlerMiddleware
- func ParseRetentionDays(raw string, defaultDays int) int
- func PluginEventTypeCount() int
- func RegisterEventType(name string, schema EventTypeSchema) error
- func SanitizeParams(params map[string]any) map[string]any
- func StartHashPublisher(ctx context.Context, store *Store, cfg HashPublishConfig, logger *slog.Logger)
- func SummarizeInput(toolName string, args map[string]any) string
- func SummarizeOutput(toolName string, result *gomcp.CallToolResult) string
- func ToolCategory(toolName string) string
- func ValidateS3Endpoint(raw string) error
- func WithClientIP(ctx context.Context, ip string) context.Context
- func WithClientUA(ctx context.Context, ua string) context.Context
- type ChainVerification
- type ConsentAction
- type ConsentLogEntry
- type ConsentMethod
- type ConsentStore
- func (c *ConsentStore) HasActiveGrant(emailHash string) (bool, error)
- func (c *ConsentStore) InitTable() error
- func (c *ConsentStore) Insert(entry *ConsentLogEntry) error
- func (c *ConsentStore) ListByEmailHash(emailHash string, limit int) ([]*ConsentLogEntry, error)
- func (c *ConsentStore) MarkWithdrawnByEmailHash(emailHash string, withdrawnAt time.Time, ...) (int64, error)
- type EventTypeSchema
- type HashPublishConfig
- type HashTipPublication
- type ListOptions
- type Stats
- type Store
- func (s *Store) AddActivityListener(id string) chan *ToolCall
- func (s *Store) ChainTip() (string, int64, error)
- func (s *Store) CleanupOldRecords(days int) (int64, error)
- func (s *Store) DeleteOlderThan(before time.Time) (int64, error)
- func (s *Store) DroppedCount() int64
- func (s *Store) EnqueueCtx(ctx context.Context, entry *ToolCall)
- func (s *Store) GetGlobalStats(since time.Time) (*Stats, error)
- func (s *Store) GetOrderAttribution(email, orderID string) ([]*ToolCall, error)
- func (s *Store) GetStats(email string, since time.Time, category string, errorsOnly bool) (*Stats, error)
- func (s *Store) GetToolCounts(email string, since time.Time, category string, errorsOnly bool) (map[string]int, error)
- func (s *Store) GetToolHistograms(since time.Time) ([]ToolHistogram, error)
- func (s *Store) GetToolMetrics(since time.Time) ([]ToolMetric, error)
- func (s *Store) GetTopErrorUsers(since time.Time, limit int) ([]UserErrorCount, error)
- func (s *Store) InitTable() error
- func (s *Store) InvalidateStatsCache(email string)
- func (s *Store) List(email string, opts ListOptions) ([]*ToolCall, int, error)
- func (s *Store) ListOrders(email string, since time.Time) ([]*ToolCall, error)
- func (s *Store) Record(entry *ToolCall) error
- func (s *Store) RemoveActivityListener(id string)
- func (s *Store) SeedChain()
- func (s *Store) SetAnomalyCacheEventDispatcher(d *domain.EventDispatcher)
- func (s *Store) SetEncryptionKey(key []byte)
- func (s *Store) SetLoggerPort(l logport.Logger)
- func (s *Store) StartRetentionWorkerCtx(ctx context.Context, days int)
- func (s *Store) StartWorkerCtx(ctx context.Context)
- func (s *Store) StatsCacheHitRate() float64
- func (s *Store) Stop()
- func (s *Store) StopRetentionWorker()
- func (s *Store) UserOrderStats(email string, days int) (mean, stdev, count float64)
- func (s *Store) VerifyChain() (*ChainVerification, error)
- type ToolCall
- type ToolHistogram
- type ToolHistogramBucket
- type ToolMetric
- type UserErrorCount
Constants ¶
const DefaultMaxStatsCacheEntries = 10_000
DefaultMaxStatsCacheEntries is the default size cap for statsCache.
Under normal operation the key space is bounded by (active users) * (distinct days windows). Active users today are in the hundreds and only `days=30` is queried, so we expect real-world len() to sit well below this. The cap is defence in depth: a brute-force login loop or a bug in email parsing could otherwise grow the cache without bound and OOM the process. 10_000 entries is cheap (~1 MB of map+entries on a 64-bit runtime) and several orders of magnitude above the expected steady state.
const DefaultRetentionDays = 90
DefaultRetentionDays is the in-package default used by StartRetentionWorker and retentionDaysFromEnv when AUDIT_RETENTION_DAYS is unset or invalid.
NOTE: This is a DPDP-compliance-minimum default. Deployments with stricter regulatory requirements (e.g. SEBI algo trading audit trail — 5 years) wire a larger retention window via AUDIT_RETENTION_DAYS or invoke DeleteOlderThan directly from their own scheduler (see app/wire.go). The in-package worker is the backstop, not the only line of defence.
Variables ¶
var MetricsBuckets = []int64{10, 50, 100, 500, 1000, 5000}
MetricsBuckets are the Prometheus `le` boundaries for tool latency histograms (in milliseconds). Bucket choice tuned for the typical MCP tool latency profile observed in audit data:
- 10ms covers cached / no-IO tools (server_version, get_quotes against in-memory instruments)
- 50ms covers most read tools (broker GET round-trip)
- 100ms covers most write tools (broker POST + audit + persist)
- 500ms is the riskguard sub-millisecond p99 target's slack
- 1000ms / 5000ms catch the long tail (paginated history, slow broker)
- +Inf covers everything (timeout middleware caps at 30s)
Bucket count is 6 — small enough to fit comfortably in Prometheus cardinality budgets even at 80+ tools (480 series) and large enough to reconstruct percentiles via histogram_quantile.
Functions ¶
func ClearPluginEventTypes ¶
func ClearPluginEventTypes()
ClearPluginEventTypes removes every plugin-registered event type. Test-only.
func ClientIPFromCtx ¶
ClientIPFromCtx extracts the IP set by WithClientIP. Empty string when not set.
func ClientUAFromCtx ¶
ClientUAFromCtx extracts the UA set by WithClientUA. Empty string when not set.
func ComputeProofHash ¶
ComputeProofHash returns the hex-encoded SHA-256 digest of the concatenation of noticeBytes || 0x00 || actionBytes. The separator byte prevents boundary-ambiguity attacks where moving bytes from one field to the other would otherwise produce the same hash.
Callers: the OAuth callback should pass the canonical displayed-notice string (e.g. privacy policy text at the version shown) and a string describing the user action (e.g. "grant scopes=trading,analytics via oauth_callback").
func HashEmail ¶
HashEmail returns the hex-encoded SHA-256 digest of the lowercased email. This is the canonical function callers should use to produce user_email_hash values for Insert and ListByEmailHash. Returns "" for empty input so callers never accidentally store the hash of the empty string.
Design: SHA-256 (not HMAC) is deliberate. The consent log must be verifiable by an auditor without our secret key — they should be able to hash the email from their records and match it against our log. HMAC would prevent that.
func ListEventTypes ¶
func ListEventTypes() map[string]EventTypeSchema
ListEventTypes returns a snapshot of all plugin-registered event types keyed by name. Safe for concurrent use; the returned map is a fresh allocation.
func Middleware ¶
func Middleware(store *Store) server.ToolHandlerMiddleware
Middleware returns mcp-go ToolHandlerMiddleware that logs every tool call.
func ParseRetentionDays ¶
ParseRetentionDays parses the effective retention window in days from a raw string value (typically AUDIT_RETENTION_DAYS from the environment).
Resolution order:
- raw if a non-empty parseable integer (including 0 or negative — which disable retention).
- Otherwise the provided defaultDays.
Unparseable values (non-numeric garbage) fall back to the default so a typo'd env var does not silently disable audit retention.
Pure function — no env read. Callers pass os.Getenv("AUDIT_RETENTION_DAYS") explicitly so tests can exercise the parser without t.Setenv.
func PluginEventTypeCount ¶
func PluginEventTypeCount() int
PluginEventTypeCount returns the number of registered plugin event types. Used by the admin plugins-list surface.
func RegisterEventType ¶
func RegisterEventType(name string, schema EventTypeSchema) error
RegisterEventType installs a plugin-contributed audit event type. Returns an error when:
- name is empty (the admin surface keys on name);
- schema.Description is empty;
- schema.Category is empty;
- schema.Category collides with a reserved built-in category.
Duplicate names replace the prior schema (last-wins; matches RegisterWidget/RegisterMiddleware conventions).
func SanitizeParams ¶
SanitizeParams returns a shallow copy of params with sensitive keys redacted to "<redacted>". Key matching is case-insensitive. Returns nil for nil input.
func StartHashPublisher ¶
func StartHashPublisher(ctx context.Context, store *Store, cfg HashPublishConfig, logger *slog.Logger)
StartHashPublisher spins up the hash-chain publisher as a background goroutine. Returns immediately. The goroutine exits cleanly when ctx is cancelled.
If cfg is not Enabled(), logs a single informational line and returns without starting any goroutine. This is the intended behaviour for unconfigured deployments.
If the configured S3 endpoint points at an internal / private / loopback address (SSRF risk — e.g., an operator who mistakenly puts AUDIT_HASH_PUBLISH_S3_ENDPOINT=http://169.254.169.254/ would leak SigV4 creds + audit tip hashes to the cloud metadata service), the publisher refuses to start and logs an error. This is a startup-time check — fail fast beats fail silently.
func SummarizeInput ¶
SummarizeInput returns a human-readable one-line summary of tool input parameters. It has specific formatters for known tools and a generic fallback for unknown ones.
func SummarizeOutput ¶
func SummarizeOutput(toolName string, result *gomcp.CallToolResult) string
SummarizeOutput returns a human-readable summary of tool output. It has per-tool formatters that produce concise summaries instead of raw JSON.
func ToolCategory ¶
ToolCategory returns the category for a given tool name. Unknown tools return "other".
func ValidateS3Endpoint ¶
ValidateS3Endpoint rejects misconfigured AUDIT_HASH_PUBLISH_S3_ENDPOINT values that would cause SSRF: internal/link-local, RFC 1918 private, loopback, and multicast addresses. Also rejects malformed URLs and non-http(s) schemes.
Name resolution: if the host is a DNS name, all resolved IPs are checked (not just the first) — belt-and-suspenders against DNS rebinding style tricks at config time. If resolution fails entirely, the URL is rejected: an unresolvable endpoint is useless anyway and allowing it could let a later DNS flip resolve to an internal IP.
Called at startup from StartHashPublisher. NOT called at runtime — doing so on every publish would be redundant (endpoint is fixed) and would couple availability to DNS uptime.
func WithClientIP ¶
WithClientIP returns ctx augmented with the resolved client IP. Empty values are stored as-is — the worker writes empty columns when the upstream didn't propagate an IP (stdio MCP, dev mode, etc.).
Types ¶
type ChainVerification ¶
type ChainVerification struct {
Valid bool `json:"valid"`
BrokenAt int64 `json:"broken_at_id,omitempty"` // ID of the first entry with a mismatched hash
Total int `json:"total_entries"`
Verified int `json:"verified_entries"`
Message string `json:"message"`
}
ChainVerification holds the result of VerifyChain.
type ConsentAction ¶
type ConsentAction string
ConsentAction enumerates the allowed values for the consent_action column. Matches the CHECK constraint in the consent_log DDL.
const ( // ConsentActionGrant records that the user affirmatively gave consent // (e.g. completed the OAuth authorization flow, flipped a dashboard toggle on). ConsentActionGrant ConsentAction = "grant" // ConsentActionWithdraw records that the user rescinded previously granted // consent (e.g. account deletion, toggled a scope off). ConsentActionWithdraw ConsentAction = "withdraw" )
type ConsentLogEntry ¶
type ConsentLogEntry struct {
ID int64 `json:"id"`
UserEmailHash string `json:"user_email_hash"`
TimestampUTC time.Time `json:"timestamp_utc"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
NoticeVersion string `json:"notice_version"`
ConsentAction ConsentAction `json:"consent_action"`
Scope string `json:"scope"` // JSON-encoded — opaque to the store
Method ConsentMethod `json:"method"`
ProofHash string `json:"proof_hash"`
WithdrawnAt time.Time `json:"withdrawn_at,omitempty"`
}
ConsentLogEntry represents a single row in the consent_log table.
The table is append-only: a grant and a subsequent withdraw both appear as rows, so the full consent history is reconstructable. DPB (Data Protection Board of India) may request this log during a DPDP Act 2023 audit.
PII minimization: user_email_hash is SHA-256(lowercased email) — the raw email never enters this table. Callers should log the email in the main audit trail (kc/audit.Store) if they need it, not here.
WithdrawnAt is non-zero on a "grant" row that has been subsequently rescinded (DPDP §6(4)). The withdraw event itself is also a separate "withdraw" row — WithdrawnAt is a fast-path column so processors can filter active consents in a single index scan instead of correlating grant/withdraw row pairs. NULL/zero means "still active" (or "this row IS the withdraw event").
type ConsentMethod ¶
type ConsentMethod string
ConsentMethod enumerates the allowed values for the method column. Matches the CHECK constraint in the consent_log DDL.
const ( // ConsentMethodOAuthCallback indicates consent captured during the Kite // OAuth callback — the user authorised Kite + implicitly accepted the // privacy notice surfaced on our login page / terms. ConsentMethodOAuthCallback ConsentMethod = "oauth_callback" // ConsentMethodDashboardToggle indicates consent captured via an explicit // control on the user dashboard (e.g. "enable Telegram", "withdraw // analytics consent"). ConsentMethodDashboardToggle ConsentMethod = "dashboard_toggle" )
type ConsentStore ¶
type ConsentStore struct {
// contains filtered or unexported fields
}
ConsentStore persists consent-grant and consent-withdraw events for DPDP Act 2023 compliance. It shares the SQLite connection pool with the main audit Store — a dedicated *sql.DB would violate the single-writer-pool invariant that SQLite WAL relies on.
func NewConsentStore ¶
func NewConsentStore(db *alerts.DB) *ConsentStore
NewConsentStore returns a new ConsentStore backed by the shared DB handle. Callers must invoke InitTable once at startup before Insert/List.
func (*ConsentStore) HasActiveGrant ¶
func (c *ConsentStore) HasActiveGrant(emailHash string) (bool, error)
HasActiveGrant reports whether the email-hash has at least one grant row that has not been subsequently withdrawn. Returns (true, nil) when an active consent exists; (false, nil) when none does (either never granted, or every grant has been withdrawn).
Used by tooling that must verify consent before performing user- affecting work — e.g. scheduled briefings, marketing dispatch.
func (*ConsentStore) InitTable ¶
func (c *ConsentStore) InitTable() error
InitTable creates the consent_log table and its supporting indexes if they do not already exist. Idempotent — safe to call at every startup.
The CHECK constraints on consent_action and method mirror the ConsentAction and ConsentMethod constants. Extending those constants requires an ALTER-TABLE-recreate migration (SQLite can't drop CHECK constraints).
Migration sequencing matters: SQLite ADD COLUMN must complete before any partial-index references the column on an existing table. Earlier versions (pre-PR-D) of this DDL ran the partial index `idx_consent_active` (which references `withdrawn_at IS NULL`) BEFORE the ALTER TABLE migration, so on pre-PR-D databases the index creation failed before the column could be added — taking down the whole startup. We now split into three phases:
- CREATE TABLE — idempotent, no-op on existing tables.
- ALTER TABLE ADD COLUMN — idempotent via duplicate-column tolerance.
- CREATE INDEX — now safe; the column is guaranteed to exist.
func (*ConsentStore) Insert ¶
func (c *ConsentStore) Insert(entry *ConsentLogEntry) error
Insert persists a consent event. On success, entry.ID is populated with the auto-increment row ID.
Timestamp handling: we store the caller-supplied TimestampUTC explicitly rather than relying on CURRENT_TIMESTAMP, so that replayed events (e.g. a background worker draining a queue after restart) carry their original timestamp. Caller should always pass time.Now().UTC().
func (*ConsentStore) ListByEmailHash ¶
func (c *ConsentStore) ListByEmailHash(emailHash string, limit int) ([]*ConsentLogEntry, error)
ListByEmailHash returns all consent events for the given user_email_hash ordered by timestamp_utc ASC (oldest first, so the consent history reads chronologically). Limit caps the result set; 0 or negative means "no cap".
func (*ConsentStore) MarkWithdrawnByEmailHash ¶
func (c *ConsentStore) MarkWithdrawnByEmailHash( emailHash string, withdrawnAt time.Time, noticeVersion, reason, ipAddress, userAgent string, ) (int64, error)
MarkWithdrawnByEmailHash stamps every active grant row for the given user_email_hash with withdrawnAt and inserts a "withdraw" action row describing the withdrawal. Both writes happen in sequence (SQLite single-writer pool serialises them); a partial failure leaves the log internally consistent because grant rows are always findable via consent_action='grant' AND withdrawn_at IS NULL.
noticeVersion identifies which privacy notice the user accepted at the time their grant was captured — copied onto the withdraw row so the audit trail records "user withdrew consent to <version> at <timestamp>". Caller passes the active notice version; ListByEmail Hash can recover the original grant's notice from its own row.
reason is plain text recorded as the withdraw row's scope field (DPDP §6(4) doesn't require a reason; we capture it for operations).
Returns the number of grant rows that were marked withdrawn. Zero is a normal outcome when the user had nothing to withdraw — the caller can decide whether to surface that as an error.
type EventTypeSchema ¶
type EventTypeSchema struct {
// Description is a one-line human-readable summary. Required.
Description string
// Category groups related event types. Required. Plugins must
// use their own namespace (e.g., "plugin", "integration",
// "compliance") — the built-in categories (order, alert, session,
// admin, billing) are reserved.
Category string
// Fields is the list of expected columns/keys on each event
// of this type. Informational only — the registry doesn't
// enforce shape on the audit rows.
Fields []string
}
EventTypeSchema describes a plugin-registered audit event category. Plugins that emit audit events beyond the built-in ToolCall pipeline (e.g., webhook deliveries, external API calls, compliance snapshots) register a schema so the admin plugins-list surface can show what categories of audit data the server is producing.
This is an inventory/documentation surface — it does NOT validate the payload. Plugins remain responsible for populating their audit rows through whatever storage path they own. The registry exists so auditors can enumerate "what audit event types does this deployment produce?" at a glance, and the admin endpoint can report plugin- contributed categories alongside built-ins.
type HashPublishConfig ¶
type HashPublishConfig struct {
// Interval between tip publishes. Defaults to 1 hour.
Interval time.Duration
// S3Endpoint is the S3-compatible URL (e.g. Cloudflare R2:
// https://<account-id>.r2.cloudflarestorage.com). Empty disables publishing.
S3Endpoint string
// Bucket is the bucket name. Empty disables publishing.
Bucket string
// Region is the AWS region. For R2 this is always "auto".
Region string
// AccessKey / SecretKey are the S3-compatible credentials.
AccessKey string
SecretKey string
// SigningKey is the HMAC-SHA256 key used to sign the published blob.
// If empty, falls back to OAUTH_JWT_SECRET. Callers should pass the
// JWT secret here since it's already derived and domain-separated.
SigningKey []byte
// SchemaVersion identifies the publication format for future evolution.
SchemaVersion int
}
HashPublishConfig holds configuration for the hash-chain external publisher. All fields are populated from environment variables in LoadHashPublishConfig.
func LoadHashPublishConfig ¶
func LoadHashPublishConfig(signingKey []byte) HashPublishConfig
LoadHashPublishConfig reads the AUDIT_HASH_PUBLISH_* env vars from the process environment. The signingKey fallback argument should typically be the OAUTH_JWT_SECRET bytes so the HMAC uses an already-strong secret.
Production wiring path. Tests should prefer LoadHashPublishConfigFromGetenv with a literal map-driven getenv callback so they can drop t.Setenv and run with t.Parallel — same pattern as app/envcheck.go's envCheckWithGetenv.
func LoadHashPublishConfigFromGetenv ¶
func LoadHashPublishConfigFromGetenv(signingKey []byte, getenv func(string) string) HashPublishConfig
LoadHashPublishConfigFromGetenv is the pure-parser variant. Caller injects the env-lookup function so tests can drive every branch with literal maps — no t.Setenv, parallel-safe. Production calls with os.Getenv via the LoadHashPublishConfig shim.
func (HashPublishConfig) Enabled ¶
func (c HashPublishConfig) Enabled() bool
Enabled reports whether the publisher has enough configuration to run. A publisher with Enabled()==false logs a single "disabled" line at startup and becomes a no-op. This keeps local dev and unconfigured deployments working without surprise failures.
type HashTipPublication ¶
type HashTipPublication struct {
Timestamp string `json:"timestamp"` // RFC3339 UTC
TipHash string `json:"tip_hash"` // latest entry_hash from tool_calls
EntryCount int64 `json:"entry_count"` // MAX(id) — monotonic, matches chain length
SchemaVersion int `json:"schema_version"` // 1
Signature string `json:"signature"` // HMAC-SHA256(SigningKey, unsignedJSON)
}
HashTipPublication is the JSON payload uploaded to external storage. It's intentionally minimal: only the tip hash, count, time, and schema — enough to detect tampering, not so much that we leak audit content.
type ListOptions ¶
type ListOptions struct {
Limit int
Offset int
Category string
ToolName string // exact-match filter on tool_name column; empty means no filter
OnlyErrors bool
Since time.Time
Until time.Time
}
ListOptions controls filtering and pagination for List queries.
type Stats ¶
type Stats struct {
TotalCalls int `json:"total_calls"`
ErrorCount int `json:"error_count"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
TopTool string `json:"top_tool"`
TopToolCount int `json:"top_tool_count"`
}
Stats holds aggregate metrics for a set of audit trail entries.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store provides audit trail persistence backed by SQLite via alerts.DB.
`logger` is typed as the kc/logger.Logger port (aliased here as logport.Logger). Background workers (StartWorkerCtx, StartRetentionWorkerCtx) take a context.Context parameter that drives both shutdown AND log correlation — the goroutine stores the parent ctx on serviceCtx and passes it to every Logger call. This preserves trace correlation from app-startup through to async drain operations, which raw slog (no ctx in Info/Warn) could not.
SOLID 99→100 cleanup: the legacy *slog.Logger and non-ctx variants (SetLogger, StartWorker, Enqueue) were retired once all callers migrated to the canonical port-typed + ctx-aware API. The remaining surface is SetLoggerPort + StartWorkerCtx + EnqueueCtx + StartRetentionWorkerCtx.
func New ¶
func New(db *alerts.DB) *Store
New creates a new audit Store using the given database handle. The in-memory UserOrderStats cache is initialised here with the default 15-minute TTL.
func (*Store) AddActivityListener ¶
AddActivityListener registers a buffered channel that receives new ToolCall entries as they are recorded.
func (*Store) ChainTip ¶
ChainTip returns the latest entry_hash and the total count of audit entries. Used by the hash publisher. (Chain-break markers are included in the count because they are legitimate chain links.)
Returns ("", 0, nil) on an empty chain — callers should treat this as "nothing to publish yet", not an error.
func (*Store) CleanupOldRecords ¶
CleanupOldRecords deletes tool_call rows older than `days` days from now. Returns the number of rows deleted.
When days <= 0 retention is treated as DISABLED and the call is a no-op returning (0, nil). Operators disable the in-package cleanup worker by setting AUDIT_RETENTION_DAYS=0 (useful when an external scheduler already owns retention, e.g. app/wire.go).
Internally delegates to DeleteOlderThan so the hash-chain break-marker behaviour applies: chain integrity is preserved across retention deletions via a recorded __chain_break entry. See VerifyChain for details.
func (*Store) DeleteOlderThan ¶
DeleteOlderThan removes tool_calls older than the given time. If hash chaining is active, a chain-break marker is inserted before the deletion to preserve chain continuity for verification. Returns the number of rows deleted (excluding the marker).
func (*Store) DroppedCount ¶
DroppedCount returns the number of audit entries that have been dropped since process start. Used by ops endpoints and alerting.
func (*Store) EnqueueCtx ¶
EnqueueCtx adds a tool call to the write buffer with a request context for log correlation. Non-blocking; logs and counts any dropped entries via DroppedCount so operators can detect compliance gaps.
H3 fix (phase 2i): previously the worker-not-started fallback swallowed Record errors with `_ = s.Record(entry)` and the buffer-full path logged at Warn level with no counter. Both paths now go through incDropped so monitoring can alert on non-zero DroppedCount.
Sync-fallback path logs Error on every drop (rare — worker normally running). Buffer-full path logs a Warning every 100 drops with the cumulative count so a noisy backlog doesn't spam error logs but operators still see the trend.
ctx flows from the audit middleware (request-scoped) so trace IDs thread through to the persistence-failure log. nil ctx falls back to context.Background — preserves existing test-fixture callers.
func (*Store) GetGlobalStats ¶
GetGlobalStats returns aggregate stats across all users since the given time.
func (*Store) GetOrderAttribution ¶
GetOrderAttribution returns the tool call that placed the given order and all other tool calls from the same session in the 60 seconds before the order, providing a decision trace that shows how the AI arrived at the trade.
func (*Store) GetStats ¶
func (s *Store) GetStats(email string, since time.Time, category string, errorsOnly bool) (*Stats, error)
GetStats returns aggregate stats for a given email since the given time. If since is zero, all records are included. Optional category and errorsOnly filters scope the stats to match the user's active filters.
func (*Store) GetToolCounts ¶
func (s *Store) GetToolCounts(email string, since time.Time, category string, errorsOnly bool) (map[string]int, error)
GetToolCounts returns tool_name -> count for the given email since the given time. Results are ordered by count descending, limited to the top 20 tools. Optional category and errorsOnly filters scope results to match the user's active filters.
func (*Store) GetToolHistograms ¶
func (s *Store) GetToolHistograms(since time.Time) ([]ToolHistogram, error)
GetToolHistograms returns per-tool latency histograms for tool calls since the given time. Histograms are bucketed by MetricsBuckets; results exclude the synthetic __chain_break row (matches GetToolMetrics convention).
Implementation note: the SQL builds one CASE-binning column per bucket boundary. This is straightforward at 6 buckets; if the boundary list grows past ~15, the query should be split into per-bucket UNION ALL or moved to in-memory histograms.
func (*Store) GetToolMetrics ¶
func (s *Store) GetToolMetrics(since time.Time) ([]ToolMetric, error)
GetToolMetrics returns per-tool aggregate metrics (call count, avg/max latency, error count) for all tool calls since the given time. Results are ordered by call count descending, limited to the top 50 tools.
func (*Store) GetTopErrorUsers ¶
GetTopErrorUsers returns the top N users with the most errors since the given time. Email values are decrypted if an encryption key is configured.
func (*Store) InvalidateStatsCache ¶
InvalidateStatsCache drops cached UserOrderStats entries for the given email. Called automatically on Record() for order-writing tools, but exposed publicly for tests and callers that bypass Record().
func (*Store) List ¶
List retrieves tool call records for a given email, filtered and paginated according to opts. It returns the matching entries, the total count of matching records (ignoring limit/offset), and any error.
func (*Store) ListOrders ¶
ListOrders returns tool calls with order IDs for the given email.
func (*Store) Record ¶
Record inserts a tool call entry into the audit log. Duplicate call_ids are silently ignored (ON CONFLICT (call_id) DO NOTHING). When an encryption key is set, the email column stores the HMAC hash (for queryable lookups) and email_encrypted stores the AES-GCM ciphertext (for display/export). Legacy rows without encryption store plaintext.
SQL portability: ON CONFLICT (call_id) DO NOTHING is the dialect-portable idempotent-insert form (SQLite ≥3.24, Postgres ≥9.5) per kite-mcp-server Phase 2.1 audit. Single SQL string for both dialects, no runtime branching.
func (*Store) RemoveActivityListener ¶
RemoveActivityListener unregisters a listener by id and closes its channel.
func (*Store) SeedChain ¶
func (s *Store) SeedChain()
SeedChain reads the last entry_hash from the database to resume the hash chain after a restart. Must be called after InitTable and SetEncryptionKey.
func (*Store) SetAnomalyCacheEventDispatcher ¶
func (s *Store) SetAnomalyCacheEventDispatcher(d *domain.EventDispatcher)
SetAnomalyCacheEventDispatcher wires the domain event dispatcher into the in-memory UserOrderStats cache so every baseline snapshot, user-scoped invalidation, and per-entry eviction is published as a typed domain.AnomalyCache*Event. Pure plumbing — the Store does not itself subscribe to these events. app/wire.go calls this once at startup; passing nil restores the legacy no-dispatch behaviour.
func (*Store) SetEncryptionKey ¶
SetEncryptionKey configures the key used for HMAC email hashing, AES-GCM email encryption, and HMAC hash chaining. Call this before StartWorker.
func (*Store) SetLoggerPort ¶
SetLoggerPort assigns a structured logger via the kc/logger.Logger port for background-worker diagnostics.
Nil is permitted: every internal logger call site is nil-checked (matches pre-migration semantics where unset s.logger was a no-op).
func (*Store) StartRetentionWorkerCtx ¶
StartRetentionWorker launches a background goroutine that runs CleanupOldRecords(days) once every retentionTickInterval (default 24h).
When days <= 0 the worker is not started (retention disabled); callers can still invoke CleanupOldRecords directly if they want a one-shot cleanup.
This is intended as an in-package backstop that operators can enable when no external scheduler owns audit retention. The existing app/wire.go scheduler already runs DeleteOlderThan daily at 03:00 IST for production deployments; leaving the in-package worker inactive avoids double-deletion. Callers that want to drive retention entirely from the audit package (e.g. tests, alternative wiring, tooling) should call this method explicitly and pair it with StopRetentionWorker on shutdown.
Safe to call multiple times: a second call is a no-op while a worker is already running.
ctx is the parent service context — captured by the goroutine for log correlation (the goroutine's cleanup-success / cleanup-failure log entries flow through it). Shutdown is still driven by the stop channel (close via StopRetentionWorker); ctx is currently for logging only, but capturing it now positions us to honour ctx.Done() without a public-API change.
func (*Store) StartWorkerCtx ¶
StartWorkerCtx starts a background goroutine that drains the write channel. The ctx is the parent service context — typically the App startup ctx — and is captured on the Store as serviceCtx so the goroutine's logger calls can correlate with app-level traces.
Call Stop() to gracefully drain and close. ctx cancellation is NOT the shutdown trigger today (Stop() closes the channel directly), but a future enhancement could honour ctx.Done() to abort in-flight writes; capturing the ctx now positions us for that without a public-API change.
func (*Store) StatsCacheHitRate ¶
StatsCacheHitRate exposes the UserOrderStats cache hit ratio for monitoring endpoints. Returns 0 before any queries have been made.
func (*Store) Stop ¶
func (s *Store) Stop()
Stop gracefully drains the buffer and waits for completion.
Stop is idempotent: calling it multiple times is safe and only the first call closes the worker channel. This guards against the case where more than one graceful-shutdown path (e.g. the HTTP signal handler and a test teardown) both try to Stop the same Store, which would otherwise produce `panic: close of closed channel`.
func (*Store) StopRetentionWorker ¶
func (s *Store) StopRetentionWorker()
StopRetentionWorker signals the retention goroutine to exit and blocks until it has finished. Safe to call when the worker was never started (no-op) and idempotent if called repeatedly.
func (*Store) UserOrderStats ¶
UserOrderStats returns the rolling mean and population standard deviation of a user's order values (qty * price) over the last `days` days. It also returns the raw count of rows found (regardless of the baseline floor) so callers can distinguish "unknown user" from "known user below threshold".
Semantics:
- Only `place_order` and `modify_order` rows are considered (the two tools where a user-triggered currency amount is actually at stake).
- Rows with missing/zero quantity or price are skipped (MARKET orders have no price at submission time and carry no useful value signal).
- When fewer than minBaselineOrders usable rows exist, mean and stdev are returned as zero. This is the "no baseline yet" sentinel.
- stdev uses the population formula (divide by N, not N-1). Population stdev is the correct choice here because we treat the observed window as the full sample of this user's trading behaviour for the purpose of anomaly detection — we are not inferring a population from a sample, we ARE the sample.
func (*Store) VerifyChain ¶
func (s *Store) VerifyChain() (*ChainVerification, error)
VerifyChain walks every entry in id order, recomputes HMAC hashes, and compares them with the stored entry_hash. Chain-break markers (tool_name = "__chain_break") are expected discontinuities and reset the expected prev_hash.
type ToolCall ¶
type ToolCall struct {
ID int64 `json:"id"`
CallID string `json:"call_id"`
Email string `json:"email"`
SessionID string `json:"session_id"`
ToolName string `json:"tool_name"`
ToolCategory string `json:"tool_category"`
InputParams string `json:"input_params"` // JSON-encoded parameters
InputSummary string `json:"input_summary"`
OutputSummary string `json:"output_summary"`
OutputSize int `json:"output_size"`
IsError bool `json:"is_error"`
ErrorMessage string `json:"error_message"`
ErrorType string `json:"error_type"`
OrderID string `json:"order_id,omitempty"` // extracted from place_order/place_gtt_order responses
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
DurationMs int64 `json:"duration_ms"`
PrevHash string `json:"prev_hash,omitempty"`
EntryHash string `json:"entry_hash,omitempty"`
IPAddress string `json:"ip_address,omitempty"` // SEBI Annexure-I — client IP at tool-call time
UserAgent string `json:"user_agent,omitempty"` // SEBI Annexure-I — client UA at tool-call time
}
ToolCall represents a single MCP tool invocation record.
IPAddress + UserAgent are populated from the originating HTTP request (X-Forwarded-For preferred; falls back to RemoteAddr) so the audit trail satisfies SEBI Annexure-I compliance: every order-placing tool invocation must carry a verifiable client identifier. Empty strings indicate either a non-HTTP transport (stdio MCP) or an upstream that didn't propagate the client IP.
type ToolHistogram ¶
type ToolHistogram struct {
ToolName string `json:"tool_name"`
CallCount int `json:"call_count"`
SumMs float64 `json:"sum_ms"`
Buckets []ToolHistogramBucket `json:"buckets"`
}
ToolHistogram holds the per-tool latency distribution since a given time. Closes the §1.2 metrics-axis gap surfaced in observability-audit-and-roadmap.md (per-tool latency p50/p95/p99 previously available only via the server_metrics MCP tool — NOT in Prometheus exposition format, blocking Grafana / Datadog Agent / Loki integrations).
The histogram is computed at query time via SQL CASE binning. No in-memory histogram state. This trades a small per-scrape SQL cost (~1ms at 100K rows) for zero ongoing memory overhead — appropriate at the codebase's current scale and matches the existing query-time-aggregation pattern in GetToolMetrics.
type ToolHistogramBucket ¶
ToolHistogramBucket is a single Prometheus-style cumulative bucket for the tool latency histogram. LeMs is the bucket upper bound in milliseconds (Prometheus `le` label); Count is the cumulative count of calls with duration ≤ LeMs.
The +Inf bucket is NOT in this slice — it equals the parent ToolHistogram.CallCount. Prometheus exposition format expects the +Inf bucket to be emitted explicitly with `le="+Inf"` matching total count.
type ToolMetric ¶
type ToolMetric struct {
ToolName string `json:"tool_name"`
CallCount int `json:"call_count"`
AvgMs float64 `json:"avg_ms"`
MaxMs int64 `json:"max_ms"`
ErrorCount int `json:"error_count"`
}
ToolMetric holds per-tool aggregate metrics for observability.
type UserErrorCount ¶
UserErrorCount holds a per-user error count.