fleet-memory/hindsight-clients/go/api_audit.go
Nicolò Boschi 083295dc6f
feat: add audit log for feature usage tracking (#717)
* feat: add audit log for feature usage tracking

Add full auditability for all mutating and core API operations across
HTTP, MCP, and system (worker) transports. Audit entries record raw
request/response as JSONB, timing (started_at/ended_at), action, and
transport type.

Backend:
- New audit_log table with JSONB columns for expandability without
  future migrations (merge migration of 3 existing heads)
- AuditLogger with fire-and-forget writes via asyncio.create_task
- @audited decorator on 28 HTTP route handlers
- MCP tool audit wrapping for 16 auditable tools
- Worker task execution wrapped with audit_context
- List endpoint with action, transport, date range filters + pagination
- Stats endpoint with per-day counts for charting
- Configurable retention sweep (concurrent-safe DELETE)

Config (env-only, static):
- HINDSIGHT_API_AUDIT_LOG_ENABLED (default: false)
- HINDSIGHT_API_AUDIT_LOG_ACTIONS (comma-separated allowlist, empty=all)
- HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS (default: -1, keep forever)

Control Plane:
- New "Audit Logs" tab on bank configuration page
- Line chart showing request volume (today/7d/30d) with action filter
- Filterable table with action, transport, date range filters
- Paginated list with detail dialog showing raw request/response JSON

Tests:
- 13 tests covering list, filters, pagination, stats, disabled mode,
  action allowlist, and ordering

* fix: split 3-way merge migration into two 2-way merges

Alembic doesn't support 3-parent merge migrations. Split into a no-op
merge of 2 heads (b1c2d3e4f5g6) followed by the audit_log table
migration merging the third head.

* fix: correct merge migration to merge actual 2 heads

The original analysis incorrectly identified 3 heads. There were only 2
(a3b4c5d6e7f8 and c8e5f2a3b4d1). Remove the unnecessary intermediate
merge migration and fix the audit_log migration to merge these 2 heads.

* fix: use 'heads' instead of 'head' in migration runner

Alembic's upgrade('head') fails when multiple heads exist (e.g. from
namespace package overlaps between hindsight-api and hindsight-api-slim).
Using 'heads' (plural) handles this gracefully by upgrading all branches.

* chore: regenerate OpenAPI spec with audit log endpoints

* chore: regenerate TypeScript client and docs skill OpenAPI spec

Python and Go clients still need regeneration (requires Docker).

* chore: regenerate all client SDKs (Python, Go, TypeScript)

Adds generated audit log API clients for Python (audit_api.py),
Go (api_audit.go), and TypeScript client type updates.
2026-03-27 09:52:03 +01:00

357 lines
10 KiB
Go

/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strings"
)
// AuditAPIService AuditAPI service
type AuditAPIService service
type ApiAuditLogStatsRequest struct {
ctx context.Context
ApiService *AuditAPIService
bankId string
action *string
period *string
authorization *string
}
// Filter by action type
func (r ApiAuditLogStatsRequest) Action(action string) ApiAuditLogStatsRequest {
r.action = &action
return r
}
// Time period: 1d, 7d, or 30d
func (r ApiAuditLogStatsRequest) Period(period string) ApiAuditLogStatsRequest {
r.period = &period
return r
}
func (r ApiAuditLogStatsRequest) Authorization(authorization string) ApiAuditLogStatsRequest {
r.authorization = &authorization
return r
}
func (r ApiAuditLogStatsRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.AuditLogStatsExecute(r)
}
/*
AuditLogStats Audit log statistics
Get audit log counts grouped by time bucket for charting.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiAuditLogStatsRequest
*/
func (a *AuditAPIService) AuditLogStats(ctx context.Context, bankId string) ApiAuditLogStatsRequest {
return ApiAuditLogStatsRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return interface{}
func (a *AuditAPIService) AuditLogStatsExecute(r ApiAuditLogStatsRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuditAPIService.AuditLogStats")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/audit-logs/stats"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.action != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "action", r.action, "form", "")
}
if r.period != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "period", r.period, "form", "")
} else {
var defaultValue string = "7d"
r.period = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListAuditLogsRequest struct {
ctx context.Context
ApiService *AuditAPIService
bankId string
action *string
transport *string
startDate *string
endDate *string
limit *int32
offset *int32
authorization *string
}
// Filter by action type
func (r ApiListAuditLogsRequest) Action(action string) ApiListAuditLogsRequest {
r.action = &action
return r
}
// Filter by transport (http, mcp, system)
func (r ApiListAuditLogsRequest) Transport(transport string) ApiListAuditLogsRequest {
r.transport = &transport
return r
}
// Filter from this ISO datetime (inclusive)
func (r ApiListAuditLogsRequest) StartDate(startDate string) ApiListAuditLogsRequest {
r.startDate = &startDate
return r
}
// Filter until this ISO datetime (exclusive)
func (r ApiListAuditLogsRequest) EndDate(endDate string) ApiListAuditLogsRequest {
r.endDate = &endDate
return r
}
// Max items to return
func (r ApiListAuditLogsRequest) Limit(limit int32) ApiListAuditLogsRequest {
r.limit = &limit
return r
}
// Offset for pagination
func (r ApiListAuditLogsRequest) Offset(offset int32) ApiListAuditLogsRequest {
r.offset = &offset
return r
}
func (r ApiListAuditLogsRequest) Authorization(authorization string) ApiListAuditLogsRequest {
r.authorization = &authorization
return r
}
func (r ApiListAuditLogsRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.ListAuditLogsExecute(r)
}
/*
ListAuditLogs List audit logs
List audit log entries for a bank, ordered by most recent first.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiListAuditLogsRequest
*/
func (a *AuditAPIService) ListAuditLogs(ctx context.Context, bankId string) ApiListAuditLogsRequest {
return ApiListAuditLogsRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return interface{}
func (a *AuditAPIService) ListAuditLogsExecute(r ApiListAuditLogsRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuditAPIService.ListAuditLogs")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/audit-logs"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.action != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "action", r.action, "form", "")
}
if r.transport != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "transport", r.transport, "form", "")
}
if r.startDate != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "start_date", r.startDate, "form", "")
}
if r.endDate != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "end_date", r.endDate, "form", "")
}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {
var defaultValue int32 = 50
r.limit = &defaultValue
}
if r.offset != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "offset", r.offset, "form", "")
} else {
var defaultValue int32 = 0
r.offset = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}