mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
parent
80aab47da6
commit
5a22b04c4c
19 changed files with 689 additions and 59 deletions
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue