Harden audit log store failures

Refs #1464
This commit is contained in:
rcourtman 2026-05-28 13:44:20 +01:00
parent 80aab47da6
commit 5a22b04c4c
19 changed files with 689 additions and 59 deletions

View file

@ -0,0 +1,61 @@
# Known RC Issue Closure For GA Audit Log Store Resilience Record
- Date: `2026-05-28`
- Gate: `known-rc-issue-closure-for-ga`
- Lanes: `L6`, `L8`, `L14`
- Issue: `https://github.com/rcourtman/Pulse/issues/1464`
- Result: `fixed-local-proof`
## Context
Issue `#1464` reports Pulse v6.0.0-rc.4 returning `Failed to fetch audit
events: Internal Server Error` when the operator opens Settings > Security >
Audit Log. The original server logs did not include the underlying audit query
error, and the follow-up asked for an rc5 retest to capture the exact cause.
Even without that rc5 log line, the v6 audit path had clear canonical gaps:
audit SQLite busy/locked failures were not retried or classified, missing or
corrupt audit-store failures collapsed into generic 500 responses, the list
endpoint accepted unbounded limits, and the frontend surfaced raw internal
server errors instead of operator-facing audit-log recovery guidance.
## Disposition
The v6 audit-log path now treats audit storage as a first-class runtime
boundary:
- SQLite audit reads, writes, schema initialization, and webhook config writes
retry transient busy/locked failures before surfacing an error.
- The audit package classifies transient store-busy errors separately from
unavailable, corrupt, missing, readonly, or uninitialized audit stores.
- Audit list and verification endpoints return structured `503` responses with
`audit_store_busy` or `audit_store_unavailable` codes instead of generic
`query_failed` 500s for those store conditions.
- Audit list pagination defaults to 100 rows and clamps oversized limits to
1000 rows.
- Persistent audit logger detection unwraps `AsyncLogger`, so an async console
backend is no longer treated as queryable audit storage.
- The Audit Log settings panel maps structured audit fetch failures to
operator-facing recovery copy instead of displaying raw internal server
errors.
This does not prove the reporter's rc4 instance hit one exact SQLite failure
mode, because the rc5 query-error log line has not been provided. It does fix
the canonical resilience and presentation gaps that could turn audit-store
pressure or initialization failures into the reported generic 500 experience.
## Proof
- `go test ./pkg/audit ./internal/api`
- `npm --prefix frontend-modern test -- --run src/utils/__tests__/auditLogPresentation.test.ts`
- `npm --prefix frontend-modern test -- --run src/components/Settings/__tests__/settingsArchitecture.test.ts`
- `npm --prefix frontend-modern run type-check`
- Browser inspection of `http://127.0.0.1:5173/settings/security-audit`
## Outcome
The v6 code path for generic Audit Log internal-server failures is hardened
with local automated proof and browser inspection. No public GitHub comment,
issue retitle, label change, or issue closure was made during this work.
Public issue closure should wait for normal maintainer issue hygiene or a
current rc retest if exact reporter-environment confirmation is required.

View file

@ -5940,6 +5940,12 @@
"kind": "file",
"evidence_tier": "managed-runtime-exercise"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-audit-log-store-resilience-2026-05-28.md",
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-authorized-keys-symlink-2026-05-01.md",

View file

@ -1566,6 +1566,10 @@ peer infrastructure roots.
Those unified audit list endpoints also clamp oversized `limit` requests to
the governed maximum, so audit history stays bounded even when callers ask
for arbitrarily large pages.
The adjacent enterprise audit-log read path now also preserves structured
store-failure codes (`audit_store_busy`, `audit_store_unavailable`) instead of
generic 500s; lifecycle surfaces may share that API layer, but they do not own
or reinterpret audit-store health.
That same shared `internal/api/` dependency also now assumes hosted runtime
websocket upgrades trust the cloud proxy only through explicit tenant
`PULSE_TRUSTED_PROXY_CIDRS` wiring, so first-session handoff and agent-facing

View file

@ -557,6 +557,13 @@ the canonical monitored-system blocked payload.
resource API JSON, and exercised with backend contract tests plus the
canonical `useUnifiedResources` frontend hook proof whenever it changes.
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
Enterprise audit-log reads are part of that same API boundary. Audit list
and verification handlers must preserve structured storage failure
semantics from `pkg/audit`: transient store pressure returns `503`
`audit_store_busy` with `Retry-After`, unavailable or corrupt audit storage
returns `503` `audit_store_unavailable`, and unrelated query failures remain
`query_failed`. List pagination must stay bounded so audit history reads
cannot become unbounded table scans through API parameters.
Plan-only unified action planning is part of that same API-first action
contract: `POST /api/actions/plan` must route through
`internal/api/actions.go`, `internal/actionplanner/planner.go`, and

View file

@ -2345,6 +2345,11 @@ When the shared runtime-capabilities store reports `paid_runtime_required` for
and the panel must render paid-runtime-required copy with the private Pulse Pro
download action. The runtime mismatch is not a plan upsell and must not be
hidden by ordinary missing-feature navigation filtering.
Audit-log fetch failures must preserve the structured backend error object
through `apiErrorFromResponse` and render customer-facing copy from
`frontend-modern/src/utils/auditLogPresentation.ts`; the settings shell may
own refresh and pagination state, but it must not show raw `Internal Server
Error` strings or unbounded page sizes as local hook behavior.
That shared filter-option primitive is also the canonical owner for default
`All <scope>` option wording wherever a product surface exposes filter selects
or segmented filter choices. Workloads filters, storage source

View file

@ -4565,7 +4565,9 @@
"id": "security-privacy",
"lane": "L14",
"contract": "docs/release-control/v6/internal/subsystems/security-privacy.md",
"owned_prefixes": [],
"owned_prefixes": [
"pkg/audit/"
],
"owned_files": [
"docs/PRIVACY.md",
"frontend-modern/src/api/security.ts",
@ -4598,6 +4600,9 @@
"internal/crypto/crypto.go",
"internal/securityutil/secure_storage_dir.go",
"internal/telemetry/telemetry.go",
"pkg/audit/async_logger.go",
"pkg/audit/audit.go",
"pkg/audit/sqlite_logger.go",
"pkg/tlsutil/fingerprint.go",
"scripts/telemetry_adoption_report.py",
"SECURITY.md"
@ -4711,6 +4716,28 @@
"frontend-modern/src/utils/__tests__/apiTokenPresentation.test.ts"
]
},
{
"id": "audit-log-runtime-store",
"label": "audit log runtime store proof",
"match_prefixes": [
"pkg/audit/"
],
"match_files": [],
"allow_same_subsystem_tests": true,
"test_prefixes": [],
"exact_files": [
"internal/api/audit_handlers_test.go",
"pkg/audit/audit_test.go",
"pkg/audit/export_test.go",
"pkg/audit/signer_test.go",
"pkg/audit/sqlite_factory_test.go",
"pkg/audit/sqlite_logger_queryplan_test.go",
"pkg/audit/sqlite_logger_test.go",
"pkg/audit/tenant_logger_manager_test.go",
"pkg/audit/webhook_delivery_test.go",
"pkg/audit/webhook_validation_test.go"
]
},
{
"id": "security-runtime-auth-config-and-tls",
"label": "security runtime auth config and TLS proof",

View file

@ -60,10 +60,13 @@ controls as normal product settings.
32. `internal/cloudcp/auth/magiclink.go`
33. `internal/cloudcp/auth/magiclink_store.go`
34. `pkg/tlsutil/fingerprint.go`
35. `scripts/telemetry_adoption_report.py`
36. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
37. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
38. `internal/api/agent_exec_token_binding.go`
35. `pkg/audit/audit.go`
36. `pkg/audit/async_logger.go`
37. `pkg/audit/sqlite_logger.go`
38. `scripts/telemetry_adoption_report.py`
39. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
40. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
41. `internal/api/agent_exec_token_binding.go`
## Shared Boundaries
@ -296,6 +299,14 @@ failure. If `runtime-capabilities` blocks `audit_logging` with
active Pro license needs the private Pulse Pro runtime, but they must not
expose license keys, billing identity, or plan-upgrade copy as part of that
security/privacy feature gate.
Audit-log storage availability is also a security/privacy trust boundary.
The `pkg/audit/` runtime package owns persistent audit-store classification:
transient SQLite busy/locked conditions must be retried and surfaced as
structured `audit_store_busy`, while missing, corrupt, readonly, or
uninitialized audit stores must surface as `audit_store_unavailable`. The
Audit Log settings surface may translate those stable API codes into recovery
copy, but it must not show raw internal server errors or collapse audit-store
state into a generic frontend failure.
That shared token-management boundary now also includes
`frontend-modern/src/utils/apiTokenPresentation.ts`, so API-token load,
generate, and revoke errors stay on one governed customer-facing wording path

View file

@ -1847,6 +1847,11 @@ changes.
Those unified audit list endpoints also clamp oversized `limit` requests to
the governed maximum, so adjacent recovery and storage workflows do not turn
bounded history reads into unbounded collection scans.
The adjacent enterprise audit-log read path now also preserves structured
store-failure codes (`audit_store_busy`, `audit_store_unavailable`) instead of
generic 500s. Storage and recovery surfaces may coexist with that API layer,
but they must not reinterpret audit-store health as backup, restore, or
recovery-state evidence.
The same shared API runtime now also exposes dedicated
`/api/resources/{id}/timeline` reads plus the bundled
`/api/resources/{id}/facets` surface, but storage and recovery must continue

View file

@ -445,6 +445,16 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps audit-log fetch errors and page-size normalization on shared owners', () => {
expect(auditLogStateSource).toContain('apiErrorFromResponse');
expect(auditLogStateSource).toContain('getAuditLogFetchErrorMessage');
expect(auditLogStateSource).toContain('normalizeAuditPageSize');
expect(auditLogStateSource).toContain('ALLOWED_AUDIT_PAGE_SIZES');
expect(auditLogPresentationSource).toContain("code === 'audit_store_busy'");
expect(auditLogPresentationSource).toContain("code === 'audit_store_unavailable'");
expect(auditLogPresentationSource).toContain("code === 'query_failed'");
});
it('keeps API token Docker and Podman copy on shared presentation helpers', () => {
expect(apiAccessPanelSource).toContain('API_TOKEN_ACCESS_PANEL_DESCRIPTION');
expect(apiTokenManagerModelSource).toContain('API_TOKEN_DOCKER_REPORT_PRESET_DESCRIPTION');

View file

@ -6,13 +6,14 @@ import {
createLocalStorageNumberSignal,
STORAGE_KEYS,
} from '@/utils/localStorage';
import { apiFetch } from '@/utils/apiClient';
import { apiErrorFromResponse, apiFetch } from '@/utils/apiClient';
import { showSuccess, showToast, showWarning } from '@/utils/toast';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { getRuntimeCapabilityBlock, loadRuntimeCapabilities } from '@/stores/license';
import { resolveUpgradeDestination } from '@/utils/upgradeNavigation';
import { getAuditLogFetchErrorMessage } from '@/utils/auditLogPresentation';
export interface AuditEvent {
id: string;
@ -62,6 +63,8 @@ const ALLOWED_VERIFICATION_FILTERS = new Set(['all', 'needs', 'verified', 'faile
const ALLOWED_SUCCESS_FILTERS = new Set(['all', 'success', 'failed']);
const ALLOWED_EVENT_FILTERS = new Set(['', 'login', 'logout', 'config_change', 'startup']);
const USER_FILTER_DEBOUNCE_MS = 300;
const DEFAULT_AUDIT_PAGE_SIZE = 100;
const ALLOWED_AUDIT_PAGE_SIZES = new Set([25, 50, 100, 200]);
const parseEventFilter = (raw: string | null | undefined): string =>
ALLOWED_EVENT_FILTERS.has(raw ?? '') ? (raw ?? '') : '';
@ -69,6 +72,8 @@ const parseSuccessFilter = (raw: string | null | undefined): string =>
ALLOWED_SUCCESS_FILTERS.has(raw ?? '') ? (raw ?? 'all') : 'all';
const parseVerificationFilter = (raw: string | null | undefined): string =>
ALLOWED_VERIFICATION_FILTERS.has(raw ?? '') ? (raw ?? 'all') : 'all';
const normalizeAuditPageSize = (value: number): number =>
ALLOWED_AUDIT_PAGE_SIZES.has(value) ? value : DEFAULT_AUDIT_PAGE_SIZE;
export const useAuditLogPanelState = () => {
const location = useLocation();
@ -94,8 +99,7 @@ export const useAuditLogPanelState = () => {
else params.set('event', value);
});
};
const userFilter: Accessor<string> = () =>
new URLSearchParams(location.search).get('user') ?? '';
const userFilter: Accessor<string> = () => new URLSearchParams(location.search).get('user') ?? '';
const setUserFilter = (value: string): void => {
updateSearchParam((params) => {
if (value === '') params.delete('user');
@ -118,7 +122,14 @@ export const useAuditLogPanelState = () => {
else params.set('verification', value);
});
};
const [pageSize, setPageSize] = createLocalStorageNumberSignal(STORAGE_KEYS.AUDIT_PAGE_SIZE, 100);
const [storedPageSize, setStoredPageSize] = createLocalStorageNumberSignal(
STORAGE_KEYS.AUDIT_PAGE_SIZE,
DEFAULT_AUDIT_PAGE_SIZE,
);
const pageSize = () => normalizeAuditPageSize(storedPageSize());
const setPageSize = (value: number): void => {
setStoredPageSize(normalizeAuditPageSize(value));
};
const [pageOffset, setPageOffset] = createLocalStorageNumberSignal(
STORAGE_KEYS.AUDIT_PAGE_OFFSET,
0,
@ -202,7 +213,10 @@ export const useAuditLogPanelState = () => {
return;
}
if (!response.ok) {
throw new Error(`Failed to fetch audit events: ${response.statusText}`);
throw await apiErrorFromResponse(
response,
`Failed to fetch audit events: ${response.statusText}`,
);
}
const data: AuditResponse = await response.json();
@ -228,7 +242,7 @@ export const useAuditLogPanelState = () => {
}, 0);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const message = getAuditLogFetchErrorMessage(err);
if (typeof message === 'string' && /feature not included in license/i.test(message)) {
setEvents([]);
setTotalEvents(0);

View file

@ -12,6 +12,7 @@ import {
AUDIT_VERIFICATION_FILTER_NEEDS_LABEL,
getAuditEventStatusPresentation,
getAuditEventTypeBadgeClass,
getAuditLogFetchErrorMessage,
getAuditLogFeatureGateCopy,
getAuditVerificationBadgePresentation,
} from '@/utils/auditLogPresentation';
@ -55,6 +56,19 @@ describe('auditLogPresentation', () => {
});
});
it('maps structured audit fetch errors to operator-facing copy', () => {
expect(getAuditLogFetchErrorMessage({ code: 'audit_store_busy' })).toContain('storage is busy');
expect(getAuditLogFetchErrorMessage({ code: 'audit_store_unavailable' })).toContain(
'storage is unavailable',
);
expect(getAuditLogFetchErrorMessage({ code: 'query_failed' })).toContain('audit query failure');
expect(
getAuditLogFetchErrorMessage(
new Error('Failed to fetch audit events: Internal Server Error'),
),
).toContain('internal error');
});
it('exposes canonical audit action button classes', () => {
expect(AUDIT_TOOLBAR_BUTTON_CLASS).toContain('border border-border');
expect(AUDIT_REFRESH_BUTTON_CLASS).toContain('text-base-content');

View file

@ -24,6 +24,12 @@ export interface AuditLogFeatureGateCopyOptions {
paidRuntimeRequired?: boolean;
}
type AuditLogFetchErrorLike = {
code?: string;
message?: string;
status?: number;
};
export const AUDIT_TOOLBAR_BUTTON_CLASS =
'flex min-h-10 sm:min-h-10 items-center gap-2 px-3 py-2 text-sm font-medium bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50';
export const AUDIT_REFRESH_BUTTON_CLASS = `${AUDIT_TOOLBAR_BUTTON_CLASS} text-base-content`;
@ -99,6 +105,26 @@ export function getAuditLogLoadingState() {
} as const;
}
export function getAuditLogFetchErrorMessage(error: unknown): string {
const errorLike = error as AuditLogFetchErrorLike;
if (errorLike?.code === 'audit_store_busy') {
return 'Audit log storage is busy. Wait a moment, then refresh the audit log.';
}
if (errorLike?.code === 'audit_store_unavailable') {
return 'Audit log storage is unavailable. Check the server logs for audit database initialization or disk errors, then refresh the audit log.';
}
if (errorLike?.code === 'query_failed') {
return 'Audit events could not be loaded. Check the server logs for the audit query failure, then refresh the audit log.';
}
if (
errorLike?.status === 500 ||
/failed to fetch audit events:\s*internal server error/i.test(errorLike?.message ?? '')
) {
return 'Audit events could not be loaded because the server returned an internal error. Check the server logs, then refresh the audit log.';
}
return error instanceof Error ? error.message : 'Unknown error';
}
export function getAuditLogFeatureGateCopy(options: AuditLogFeatureGateCopyOptions = {}) {
if (options.paidRuntimeRequired) {
return {

View file

@ -23,6 +23,9 @@ var validAuditEventID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var resolveWebhookIPs = net.DefaultResolver.LookupIPAddr
const maxUnifiedAuditLimit = 1000
const defaultAuditEventListLimit = 100
const maxAuditEventListLimit = 1000
const auditStoreBusyRetryAfterSeconds = 2
// AuditHandlers provides HTTP handlers for audit log endpoints.
type AuditHandlers struct {
@ -58,6 +61,11 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
orgID := GetOrgID(r.Context())
logger := getLoggerForOrg(orgID)
if !isPersistentLogger(logger) {
log.Error().Str("org_id", orgID).Msg("audit list: persistent audit logger unavailable")
writeAuditStoreUnavailableResponse(w)
return
}
query := r.URL.Query()
@ -66,13 +74,11 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
User: query.Get("user"),
}
// Parse limit
filter.Limit = defaultAuditEventListLimit
if limitStr := query.Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filter.Limit = limit
filter.Limit = min(limit, maxAuditEventListLimit)
}
} else {
filter.Limit = 100 // Default limit
}
// Parse offset
@ -106,7 +112,7 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
events, err := logger.Query(filter)
if err != nil {
log.Error().Err(err).Str("org_id", orgID).Msg("audit list: query failed")
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events", nil)
writeAuditReadErrorResponse(w, err, "Failed to query audit events")
return
}
@ -117,16 +123,14 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
totalCount, err := logger.Count(countFilter)
if err != nil {
log.Error().Err(err).Str("org_id", orgID).Msg("audit list: count failed")
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to count audit events", nil)
writeAuditReadErrorResponse(w, err, "Failed to count audit events")
return
}
// For OSS (ConsoleLogger), events will be empty
// Return a response indicating the feature status
response := map[string]interface{}{
"events": events,
"total": totalCount,
"persistentLogging": len(events) > 0 || isPersistentLogger(logger),
"persistentLogging": true,
}
w.Header().Set("Content-Type", "application/json")
@ -178,7 +182,8 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re
Limit: 1,
})
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit event", nil)
log.Error().Err(err).Str("org_id", orgID).Str("audit_id", eventID).Msg("audit verify: query failed")
writeAuditReadErrorResponse(w, err, "Failed to query audit event")
return
}
if len(events) == 0 {
@ -353,8 +358,26 @@ func isPrivateOrReservedIP(ip net.IP) bool {
// isPersistentLogger checks if we're using a persistent audit logger (enterprise).
func isPersistentLogger(logger audit.Logger) bool {
_, isConsole := logger.(*audit.ConsoleLogger)
return !isConsole
return audit.IsPersistentLogger(logger)
}
func writeAuditReadErrorResponse(w http.ResponseWriter, err error, fallbackMessage string) {
if audit.IsStoreBusyError(err) {
w.Header().Set("Retry-After", strconv.Itoa(auditStoreBusyRetryAfterSeconds))
writeErrorResponse(w, http.StatusServiceUnavailable, "audit_store_busy",
"Audit log storage is temporarily busy. Try again shortly.", nil)
return
}
if audit.IsStoreUnavailableError(err) {
writeAuditStoreUnavailableResponse(w)
return
}
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", fallbackMessage, nil)
}
func writeAuditStoreUnavailableResponse(w http.ResponseWriter) {
writeErrorResponse(w, http.StatusServiceUnavailable, "audit_store_unavailable",
"Audit log storage is unavailable. Check server logs for audit database initialization or disk errors.", nil)
}
// HandleExportAuditEvents handles GET /api/audit/export

View file

@ -28,6 +28,8 @@ type testAuditLogger struct {
queryErr error
updateErr error
countErr error
lastQuery audit.QueryFilter
lastCount audit.QueryFilter
}
func (l *testAuditLogger) Log(event audit.Event) error {
@ -36,6 +38,7 @@ func (l *testAuditLogger) Log(event audit.Event) error {
}
func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) {
l.lastQuery = filter
if l.queryErr != nil {
return nil, l.queryErr
}
@ -51,14 +54,11 @@ func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error)
}
func (l *testAuditLogger) Count(filter audit.QueryFilter) (int, error) {
l.lastCount = filter
if l.countErr != nil {
return 0, l.countErr
}
events, err := l.Query(filter)
if err != nil {
return 0, err
}
return len(events), nil
return len(l.events), nil
}
func (l *testAuditLogger) Close() error {
@ -525,6 +525,45 @@ func TestHandleListAuditEvents_Filters(t *testing.T) {
}
}
func TestHandleListAuditEvents_PaginationDefaults(t *testing.T) {
logger := &testAuditLogger{
events: []audit.Event{{ID: "1", EventType: "login", Success: true}},
}
setAuditLogger(t, logger)
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit?limit=0&offset=-4", nil)
rec := httptest.NewRecorder()
handler.HandleListAuditEvents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if logger.lastQuery.Limit != defaultAuditEventListLimit {
t.Fatalf("limit = %d, want default %d", logger.lastQuery.Limit, defaultAuditEventListLimit)
}
if logger.lastQuery.Offset != 0 {
t.Fatalf("offset = %d, want 0", logger.lastQuery.Offset)
}
req = httptest.NewRequest(http.MethodGet, "/api/audit?limit=5000&offset=10", nil)
rec = httptest.NewRecorder()
handler.HandleListAuditEvents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if logger.lastQuery.Limit != maxAuditEventListLimit {
t.Fatalf("limit = %d, want max %d", logger.lastQuery.Limit, maxAuditEventListLimit)
}
if logger.lastQuery.Offset != 10 {
t.Fatalf("offset = %d, want 10", logger.lastQuery.Offset)
}
if logger.lastCount.Limit != 0 || logger.lastCount.Offset != 0 {
t.Fatalf("count filter must clear pagination, got limit=%d offset=%d", logger.lastCount.Limit, logger.lastCount.Offset)
}
}
func TestHandleListAuditEvents_QueryError(t *testing.T) {
setAuditLogger(t, &testAuditLogger{
queryErr: fmt.Errorf("db error"),
@ -550,6 +589,80 @@ func TestHandleListAuditEvents_QueryError(t *testing.T) {
t.Errorf("expected count error status %d, got %d", http.StatusInternalServerError, rec.Code)
}
}
func TestHandleListAuditEvents_StoreBusyError(t *testing.T) {
setAuditLogger(t, &testAuditLogger{
queryErr: fmt.Errorf("database is locked (5) (SQLITE_BUSY)"),
})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
rec := httptest.NewRecorder()
handler.HandleListAuditEvents(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After header")
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Code != "audit_store_busy" {
t.Fatalf("code = %q, want audit_store_busy", resp.Code)
}
}
func TestHandleListAuditEvents_StoreUnavailableError(t *testing.T) {
setAuditLogger(t, &testAuditLogger{
queryErr: fmt.Errorf("no such table: audit_events"),
})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
rec := httptest.NewRecorder()
handler.HandleListAuditEvents(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Code != "audit_store_unavailable" {
t.Fatalf("code = %q, want audit_store_unavailable", resp.Code)
}
}
func TestHandleListAuditEvents_NonPersistentLoggerUnavailable(t *testing.T) {
asyncLogger := audit.NewAsyncLogger(audit.NewConsoleLogger(), audit.AsyncLoggerConfig{BufferSize: 1})
t.Cleanup(func() {
_ = asyncLogger.Close()
})
setAuditLogger(t, asyncLogger)
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
rec := httptest.NewRecorder()
handler.HandleListAuditEvents(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Code != "audit_store_unavailable" {
t.Fatalf("code = %q, want audit_store_unavailable", resp.Code)
}
}
func TestHandleExportAuditEvents(t *testing.T) {
oldLogger := audit.GetLogger()
defer audit.SetLogger(oldLogger)

View file

@ -45,6 +45,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
@ -13328,6 +13329,69 @@ func TestContract_UnifiedAuditLimitCapsOversizedRequests(t *testing.T) {
}
}
func TestContract_AuditStoreReadErrorsUseStructuredAPIErrorCodes(t *testing.T) {
cases := []struct {
name string
err error
wantStatus int
wantCode string
wantRetryAfter bool
}{
{
name: "busy",
err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("database is locked (5) (SQLITE_BUSY)")),
wantStatus: http.StatusServiceUnavailable,
wantCode: "audit_store_busy",
wantRetryAfter: true,
},
{
name: "unavailable",
err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("no such table: audit_events")),
wantStatus: http.StatusServiceUnavailable,
wantCode: "audit_store_unavailable",
},
{
name: "query_failed",
err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("unexpected query failure")),
wantStatus: http.StatusInternalServerError,
wantCode: "query_failed",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.wantCode == "audit_store_busy" && !audit.IsStoreBusyError(tc.err) {
t.Fatal("busy fixture must satisfy audit store busy classification")
}
if tc.wantCode == "audit_store_unavailable" && !audit.IsStoreUnavailableError(tc.err) {
t.Fatal("unavailable fixture must satisfy audit store unavailable classification")
}
rec := httptest.NewRecorder()
writeAuditReadErrorResponse(rec, tc.err, "Failed to query audit events")
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if got := rec.Header().Get("Retry-After"); tc.wantRetryAfter && got == "" {
t.Fatal("busy audit-store errors must include Retry-After")
} else if !tc.wantRetryAfter && got != "" {
t.Fatalf("Retry-After = %q, want empty", got)
}
var payload APIError
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode APIError: %v", err)
}
if payload.Code != tc.wantCode {
t.Fatalf("code = %q, want %q", payload.Code, tc.wantCode)
}
if payload.StatusCode != tc.wantStatus {
t.Fatalf("status_code = %d, want %d", payload.StatusCode, tc.wantStatus)
}
})
}
}
func TestContract_EmbeddedFrontendWarningUsesCanonicalDevEntrypoints(t *testing.T) {
path := filepath.Join("DO_NOT_EDIT_FRONTEND_HERE.md")
body, err := os.ReadFile(path)

View file

@ -81,6 +81,14 @@ func (l *AsyncLogger) UpdateWebhookURLs(urls []string) error {
return l.backend.UpdateWebhookURLs(urls)
}
// IsPersistentAuditLogger reports whether the wrapped backend is queryable storage.
func (l *AsyncLogger) IsPersistentAuditLogger() bool {
if l == nil {
return false
}
return IsPersistentLogger(l.backend)
}
// Close drains queued events, stops the worker, and closes the backend logger.
func (l *AsyncLogger) Close() error {
if l == nil {

View file

@ -65,6 +65,22 @@ type Logger interface {
Close() error
}
type persistentAuditLogger interface {
IsPersistentAuditLogger() bool
}
// IsPersistentLogger reports whether a logger provides queryable audit storage.
func IsPersistentLogger(logger Logger) bool {
if logger == nil {
return false
}
if persistent, ok := logger.(persistentAuditLogger); ok {
return persistent.IsPersistentAuditLogger()
}
_, isConsole := logger.(*ConsoleLogger)
return !isConsole
}
// Global logger instance with thread-safe access
var (
globalLogger Logger
@ -159,6 +175,11 @@ func NewConsoleLogger() *ConsoleLogger {
return &ConsoleLogger{}
}
// IsPersistentAuditLogger reports that console logging is not queryable storage.
func (c *ConsoleLogger) IsPersistentAuditLogger() bool {
return false
}
// Log writes an audit event to zerolog.
func (c *ConsoleLogger) Log(event Event) error {
logContext := log.With().

View file

@ -2,6 +2,7 @@ package audit
import (
"database/sql"
"errors"
"fmt"
"net/url"
"os"
@ -11,7 +12,7 @@ import (
"time"
"github.com/rs/zerolog/log"
_ "modernc.org/sqlite"
sqlite "modernc.org/sqlite"
)
// SQLiteLoggerConfig configures the SQLite audit logger.
@ -40,6 +41,97 @@ var retentionStartupCleanup = func(l *SQLiteLogger) {
l.cleanupOldEvents()
}
const (
sqliteCodeBusy = 5
sqliteCodeLocked = 6
sqliteCodeReadonly = 8
sqliteCodeIOErr = 10
sqliteCodeCorrupt = 11
sqliteCodeCantOpen = 14
sqliteCodeNotADB = 26
sqliteCodeBusyRecovery = sqliteCodeBusy | (1 << 8)
sqliteCodeBusySnapshot = sqliteCodeBusy | (2 << 8)
sqliteCodeBusyTimeout = sqliteCodeBusy | (3 << 8)
sqliteCodeLockedSharedCache = sqliteCodeLocked | (1 << 8)
sqliteCodeLockedVTab = sqliteCodeLocked | (2 << 8)
)
var auditSQLiteRetryDelays = []time.Duration{25 * time.Millisecond, 75 * time.Millisecond}
var auditSQLiteRetrySleep = time.Sleep
// IsStoreBusyError reports whether an audit store error is a transient SQLite lock.
func IsStoreBusyError(err error) bool {
if err == nil {
return false
}
var sqliteErr *sqlite.Error
if errors.As(err, &sqliteErr) {
switch sqliteErr.Code() {
case sqliteCodeBusy,
sqliteCodeLocked,
sqliteCodeBusyRecovery,
sqliteCodeBusySnapshot,
sqliteCodeBusyTimeout,
sqliteCodeLockedSharedCache,
sqliteCodeLockedVTab:
return true
}
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "sqlite_busy") ||
strings.Contains(message, "sqlite_locked") ||
strings.Contains(message, "database is locked") ||
strings.Contains(message, "database table is locked")
}
// IsStoreUnavailableError reports whether an audit store error means the
// backing database is missing, corrupt, unreadable, or not initialized.
func IsStoreUnavailableError(err error) bool {
if err == nil {
return false
}
if IsStoreBusyError(err) {
return false
}
var sqliteErr *sqlite.Error
if errors.As(err, &sqliteErr) {
switch sqliteErr.Code() {
case sqliteCodeReadonly, sqliteCodeIOErr, sqliteCodeCorrupt, sqliteCodeCantOpen, sqliteCodeNotADB:
return true
}
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "database disk image is malformed") ||
strings.Contains(message, "file is not a database") ||
strings.Contains(message, "unable to open database file") ||
strings.Contains(message, "attempt to write a readonly database") ||
strings.Contains(message, "no such table: audit_events") ||
strings.Contains(message, "no such table: schema_version")
}
func withSQLiteRetry(operation string, run func() error) error {
var err error
for attempt := 0; ; attempt++ {
err = run()
if err == nil || !IsStoreBusyError(err) || attempt >= len(auditSQLiteRetryDelays) {
return err
}
delay := auditSQLiteRetryDelays[attempt]
log.Warn().
Err(err).
Str("operation", operation).
Int("attempt", attempt+1).
Dur("retryIn", delay).
Msg("Audit SQLite store busy; retrying")
auditSQLiteRetrySleep(delay)
}
}
// NewSQLiteLogger creates a new SQLite-backed audit logger.
func NewSQLiteLogger(cfg SQLiteLoggerConfig) (*SQLiteLogger, error) {
if cfg.DataDir == "" {
@ -122,6 +214,11 @@ func NewSQLiteLogger(cfg SQLiteLoggerConfig) (*SQLiteLogger, error) {
return l, nil
}
// IsPersistentAuditLogger reports that SQLite provides queryable audit storage.
func (l *SQLiteLogger) IsPersistentAuditLogger() bool {
return l != nil
}
// initSchema creates the database tables and runs migrations.
func (l *SQLiteLogger) initSchema() error {
schema := `
@ -154,22 +251,26 @@ func (l *SQLiteLogger) initSchema() error {
);
`
_, err := l.db.Exec(schema)
var err error
err = withSQLiteRetry("init_schema", func() error {
_, err = l.db.Exec(schema)
return err
})
if err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
// Record schema version
_, err = l.db.Exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, ?)`,
time.Now().Unix())
err = withSQLiteRetry("record_schema_version", func() error {
_, err = l.db.Exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, ?)`,
time.Now().Unix())
return err
})
return err
}
// Log records an audit event with HMAC signature.
func (l *SQLiteLogger) Log(event Event) error {
l.mu.Lock()
defer l.mu.Unlock()
// Sign the event
event.Signature = l.signer.Sign(event)
@ -179,19 +280,26 @@ func (l *SQLiteLogger) Log(event Event) error {
success = 1
}
_, err := l.db.Exec(`
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
event.ID,
event.Timestamp.Unix(),
event.EventType,
event.User,
event.IP,
event.Path,
success,
event.Details,
event.Signature,
)
var err error
err = withSQLiteRetry("insert_audit_event", func() error {
l.mu.Lock()
defer l.mu.Unlock()
_, err = l.db.Exec(`
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
event.ID,
event.Timestamp.Unix(),
event.EventType,
event.User,
event.IP,
event.Path,
success,
event.Details,
event.Signature,
)
return err
})
if err != nil {
return fmt.Errorf("failed to insert audit event: %w", err)
@ -223,9 +331,25 @@ func (l *SQLiteLogger) Log(event Event) error {
// Query retrieves audit events matching the filter.
func (l *SQLiteLogger) Query(filter QueryFilter) ([]Event, error) {
l.mu.RLock()
defer l.mu.RUnlock()
var events []Event
err := withSQLiteRetry("query_audit_events", func() error {
l.mu.RLock()
defer l.mu.RUnlock()
result, err := l.queryLocked(filter)
if err != nil {
return err
}
events = result
return nil
})
if err != nil {
return nil, err
}
return events, nil
}
func (l *SQLiteLogger) queryLocked(filter QueryFilter) ([]Event, error) {
query := "SELECT id, timestamp, event_type, user, ip, path, success, details, signature FROM audit_events WHERE 1=1"
args := []interface{}{}
@ -307,9 +431,25 @@ func (l *SQLiteLogger) Query(filter QueryFilter) ([]Event, error) {
// Count returns the number of events matching the filter.
func (l *SQLiteLogger) Count(filter QueryFilter) (int, error) {
l.mu.RLock()
defer l.mu.RUnlock()
var count int
err := withSQLiteRetry("count_audit_events", func() error {
l.mu.RLock()
defer l.mu.RUnlock()
result, err := l.countLocked(filter)
if err != nil {
return err
}
count = result
return nil
})
if err != nil {
return 0, err
}
return count, nil
}
func (l *SQLiteLogger) countLocked(filter QueryFilter) (int, error) {
query := "SELECT COUNT(*) FROM audit_events WHERE 1=1"
args := []interface{}{}
@ -361,15 +501,19 @@ func (l *SQLiteLogger) GetWebhookURLs() []string {
// UpdateWebhookURLs updates the webhook configuration.
func (l *SQLiteLogger) UpdateWebhookURLs(urls []string) error {
l.mu.Lock()
defer l.mu.Unlock()
// Save to config table
value := strings.Join(urls, ",")
_, err := l.db.Exec(`
INSERT INTO audit_config (key, value, updated_at) VALUES ('webhook_urls', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
value, time.Now().Unix())
var err error
err = withSQLiteRetry("update_webhook_urls", func() error {
l.mu.Lock()
defer l.mu.Unlock()
_, err = l.db.Exec(`
INSERT INTO audit_config (key, value, updated_at) VALUES ('webhook_urls', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
value, time.Now().Unix())
return err
})
if err != nil {
return fmt.Errorf("failed to save webhook URLs: %w", err)
}

View file

@ -1,6 +1,8 @@
package audit
import (
"errors"
"fmt"
"testing"
"time"
@ -43,6 +45,71 @@ func TestNewSQLiteLoggerDefaultRetention(t *testing.T) {
}
}
func TestIsPersistentLoggerUnwrapsAsyncConsoleBackend(t *testing.T) {
console := NewConsoleLogger()
if IsPersistentLogger(console) {
t.Fatal("console logger must not be persistent")
}
asyncConsole := NewAsyncLogger(console, AsyncLoggerConfig{BufferSize: 1})
defer asyncConsole.Close()
if IsPersistentLogger(asyncConsole) {
t.Fatal("async console logger must not be persistent")
}
}
func TestAuditSQLiteStoreErrorClassification(t *testing.T) {
if !IsStoreBusyError(fmt.Errorf("query failed: %w", errors.New("database is locked (5) (SQLITE_BUSY)"))) {
t.Fatal("expected SQLITE_BUSY error to be classified as store busy")
}
if IsStoreBusyError(errors.New("no such table: audit_events")) {
t.Fatal("missing audit table must not be classified as transient busy")
}
if !IsStoreUnavailableError(errors.New("no such table: audit_events")) {
t.Fatal("missing audit table must be classified as store unavailable")
}
if !IsStoreUnavailableError(errors.New("database disk image is malformed")) {
t.Fatal("corrupt audit database must be classified as store unavailable")
}
}
func TestWithSQLiteRetryRetriesTransientBusyErrors(t *testing.T) {
previousSleep := auditSQLiteRetrySleep
auditSQLiteRetrySleep = func(time.Duration) {}
t.Cleanup(func() {
auditSQLiteRetrySleep = previousSleep
})
attempts := 0
err := withSQLiteRetry("test_retry", func() error {
attempts++
if attempts < 3 {
return errors.New("database is locked (5) (SQLITE_BUSY)")
}
return nil
})
if err != nil {
t.Fatalf("expected retry to recover, got %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestWithSQLiteRetryDoesNotRetryPermanentErrors(t *testing.T) {
attempts := 0
err := withSQLiteRetry("test_no_retry", func() error {
attempts++
return errors.New("no such table: audit_events")
})
if err == nil {
t.Fatal("expected permanent error")
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestSQLiteLoggerLog(t *testing.T) {
tempDir := t.TempDir()