Skip to content

Commit b4076db

Browse files
feat(audit): readable host-scoped Audit log tab + audit-events message/resource filters (Phase 2b) (#619)
api-audit-events-query v1.2.0 (C-07, AC-12) + frontend-host-detail v1.8.0 (AC-44). Phase 2b of the activity readability initiative. Backend: GET /api/v1/audit/events now returns a server-rendered readable 'message' ('<actor> <predicate>', e.g. 'alice@example.com created a host'), built by the shared activity.FormatAudit (exported from internal/activity so the audit-events endpoint and the activity feed's audit leg render audit the SAME way — no duplicate map). It also exposes actor_label and accepts resource_type + resource_id query filters for a single resource's trail. Frontend: the host-detail Audit log tab goes live (HostAuditLogTab) — the host-scoped forensic trail via /audit/events?resource_type=host&resource_id=X, rendered with the readable message, gated on audit:read. Removed from the stub registry. Both deferred host tabs (Activity in 2a, Audit log here) are now live. This is decision #2 from the Phase 2 discussion (keep the Audit log tab, make it a real per-host audit view) and de-risks Phase 3 (the settings audit log gets the same readable message field for free). Verified live: owas-tst01 Audit log shows 'Someone completed host discovery', 'Someone mounted a filesystem' — readable, host-scoped. Full frontend suite (321) + backend audit suite + specter (111, structural 100%) green.
1 parent fa2ac15 commit b4076db

13 files changed

Lines changed: 797 additions & 480 deletions

File tree

api/openapi.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,14 @@ paths:
11011101
- name: actor_type
11021102
in: query
11031103
schema: {type: string}
1104+
- name: resource_type
1105+
in: query
1106+
description: Filter to events whose resource_type matches (e.g. "host"). Pair with resource_id for a single resource's audit trail.
1107+
schema: {type: string}
1108+
- name: resource_id
1109+
in: query
1110+
description: Filter to events whose resource_id matches (e.g. a host UUID). Typically paired with resource_type.
1111+
schema: {type: string}
11041112
- name: since
11051113
in: query
11061114
schema: {type: string, format: date-time}
@@ -4673,9 +4681,15 @@ components:
46734681
id: {type: string, format: uuid}
46744682
correlation_id: {type: string}
46754683
action: {type: string}
4684+
# Server-rendered human-readable sentence for the event
4685+
# ("<actor> <predicate>", e.g. "alice@example.com created a host"),
4686+
# built from action + actor_label + actor_type. Lets every audit
4687+
# surface show a sentence instead of the raw dotted action code.
4688+
message: {type: string}
46764689
severity: {type: string}
46774690
actor_type: {type: string}
46784691
actor_id: {type: string, nullable: true}
4692+
actor_label: {type: string, nullable: true}
46794693
resource_type: {type: string, nullable: true}
46804694
resource_id: {type: string, nullable: true}
46814695
occurred_at: {type: string, format: date-time}

frontend/src/api/schema.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3102,9 +3102,11 @@ export interface components {
31023102
id: string;
31033103
correlation_id: string;
31043104
action: string;
3105+
message?: string;
31053106
severity?: string;
31063107
actor_type: string;
31073108
actor_id?: string | null;
3109+
actor_label?: string | null;
31083110
resource_type?: string | null;
31093111
resource_id?: string | null;
31103112
/** Format: date-time */
@@ -5621,6 +5623,10 @@ export interface operations {
56215623
action?: string;
56225624
correlation_id?: string;
56235625
actor_type?: string;
5626+
/** @description Filter to events whose resource_type matches (e.g. "host"). Pair with resource_id for a single resource's audit trail. */
5627+
resource_type?: string;
5628+
/** @description Filter to events whose resource_id matches (e.g. a host UUID). Typically paired with resource_type. */
5629+
resource_id?: string;
56245630
since?: string;
56255631
until?: string;
56265632
cursor?: string;

frontend/src/pages/HostDetailPage.tsx

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,13 @@ const TAB_ORDER: { id: TabId; label: string; icon: LucideIcon }[] = [
182182
// Backend subsystem that populates each tab when it lands. Surfaces
183183
// inside the per-tab empty state so operators know what's deferred.
184184
const TAB_BACKEND_SUBSYSTEM: Record<
185-
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>,
185+
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity' | 'audit_log'>,
186186
string
187187
> = {
188188
packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).',
189189
services: 'Server Intelligence collection — running services inventory deferred (BACKLOG).',
190190
users: 'Server Intelligence collection — user accounts inventory deferred (BACKLOG).',
191191
network: 'Server Intelligence collection — interfaces and firewall rules deferred (BACKLOG).',
192-
audit_log:
193-
'Audit query API — host-scoped audit feed deferred to the unified /activity page (BACKLOG).',
194192
terminal: 'Web terminal — SSH-in-browser deferred; use a host-side SSH client in the meantime.',
195193
};
196194

@@ -488,6 +486,8 @@ export function HostDetailPage() {
488486
<RemediationTab hostId={detailQuery.data.host.id} />
489487
) : activeTab === 'activity' ? (
490488
<HostActivityTab hostId={detailQuery.data.host.id} />
489+
) : activeTab === 'audit_log' ? (
490+
<HostAuditLogTab hostId={detailQuery.data.host.id} />
491491
) : (
492492
<TabStub tab={activeTab} subsystem={TAB_BACKEND_SUBSYSTEM[activeTab]} />
493493
)}
@@ -2675,6 +2675,137 @@ function HostActivityTab({ hostId }: { hostId: string }) {
26752675
);
26762676
}
26772677

2678+
// AuditLogItem mirrors the api.AuditEvent envelope returned by
2679+
// GET /api/v1/audit/events. `message` is the server-rendered readable
2680+
// sentence ("<actor> <predicate>"); we never show the raw action code.
2681+
interface AuditLogItem {
2682+
id: string;
2683+
action: string;
2684+
message?: string;
2685+
actor_label?: string | null;
2686+
occurred_at: string;
2687+
}
2688+
2689+
// HostAuditLogTab is the host-scoped forensic audit trail: audit events
2690+
// whose resource is this host (resource_type=host, resource_id=hostId),
2691+
// rendered with the server's readable message. Distinct from the Activity
2692+
// tab (operational feed) — this is the "who did what to this host" record.
2693+
// Gated on audit:read. Spec frontend-host-detail.
2694+
function HostAuditLogTab({ hostId }: { hostId: string }) {
2695+
const canRead = useAuthStore((s) => s.hasPermission('audit:read'));
2696+
const q = useInfiniteQuery({
2697+
queryKey: ['host', hostId, 'audit'],
2698+
initialPageParam: undefined as string | undefined,
2699+
enabled: !!hostId && canRead,
2700+
queryFn: async ({ pageParam }) => {
2701+
const { data, error, response } = await api.GET('/api/v1/audit/events', {
2702+
params: {
2703+
query: {
2704+
resource_type: 'host',
2705+
resource_id: hostId,
2706+
limit: 50,
2707+
...(pageParam ? { cursor: pageParam } : {}),
2708+
},
2709+
},
2710+
});
2711+
if (error || !response.ok) {
2712+
throw new Error(apiErrorMessage(error, `Failed to load audit log (${response.status})`));
2713+
}
2714+
return data as unknown as { items: AuditLogItem[]; next_cursor?: string | null };
2715+
},
2716+
getNextPageParam: (last) => last.next_cursor ?? undefined,
2717+
});
2718+
2719+
if (!canRead) {
2720+
return (
2721+
<Card title="Audit log">
2722+
<EmptyState
2723+
primary="Audit log not available"
2724+
secondary="Viewing the audit trail requires the audit:read permission."
2725+
/>
2726+
</Card>
2727+
);
2728+
}
2729+
2730+
const items = q.data?.pages.flatMap((p) => p.items ?? []) ?? [];
2731+
2732+
return (
2733+
<Card title="Audit log">
2734+
{q.isLoading ? (
2735+
<div style={{ color: 'var(--ow-fg-2)', fontSize: 12 }}>Loading…</div>
2736+
) : q.isError ? (
2737+
<div
2738+
style={{
2739+
color: 'var(--ow-crit)',
2740+
fontSize: 12,
2741+
display: 'flex',
2742+
gap: 8,
2743+
alignItems: 'center',
2744+
}}
2745+
>
2746+
{apiErrorMessage(q.error, 'Failed to load audit log')}{' '}
2747+
<button type="button" onClick={() => q.refetch()} style={smallTextBtn}>
2748+
<RefreshCw size={11} /> Retry
2749+
</button>
2750+
</div>
2751+
) : items.length === 0 ? (
2752+
<EmptyState
2753+
primary="No audit events yet"
2754+
secondary="Records who did what to this host (created, updated, scanned, remediated). Entries appear as operators act on the host."
2755+
/>
2756+
) : (
2757+
<>
2758+
<ol
2759+
style={{
2760+
listStyle: 'none',
2761+
padding: 0,
2762+
margin: 0,
2763+
display: 'flex',
2764+
flexDirection: 'column',
2765+
gap: 8,
2766+
}}
2767+
>
2768+
{items.map((it) => (
2769+
<li
2770+
key={it.id}
2771+
style={{
2772+
display: 'flex',
2773+
gap: 10,
2774+
alignItems: 'flex-start',
2775+
padding: '8px 0',
2776+
borderBottom: '1px solid var(--ow-line)',
2777+
}}
2778+
>
2779+
<FileText
2780+
size={14}
2781+
color="var(--ow-fg-2)"
2782+
style={{ marginTop: 2, flexShrink: 0 }}
2783+
/>
2784+
<div style={{ flex: 1, minWidth: 0, color: 'var(--ow-fg-0)', fontSize: 13 }}>
2785+
{it.message || it.action}
2786+
</div>
2787+
<div style={{ color: 'var(--ow-fg-3)', fontSize: 11, whiteSpace: 'nowrap' }}>
2788+
{relativeTime(it.occurred_at)}
2789+
</div>
2790+
</li>
2791+
))}
2792+
</ol>
2793+
{q.hasNextPage ? (
2794+
<button
2795+
type="button"
2796+
onClick={() => q.fetchNextPage()}
2797+
disabled={q.isFetchingNextPage}
2798+
style={{ ...smallTextBtn, marginTop: 12 }}
2799+
>
2800+
{q.isFetchingNextPage ? 'Loading…' : 'Load more'}
2801+
</button>
2802+
) : null}
2803+
</>
2804+
)}
2805+
</Card>
2806+
);
2807+
}
2808+
26782809
// activitySeverityColors maps the closed severity enum onto the
26792810
// existing OW color tokens.
26802811
function activitySeverityColors(s: ActivitySeverity): { fg: string; dot: string } {

frontend/tests/pages/host-detail-compliance-tab.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,9 @@ describe('frontend-host-compliance-tab — structural', () => {
156156
expect(PAGE_SRC).toContain('<ComplianceTab');
157157
// The stub registry no longer carries a compliance entry.
158158
expect(PAGE_SRC).not.toMatch(/^\s*compliance:\s*'/m);
159-
// remediation + activity joined overview + compliance as live (non-stub) tabs.
159+
// remediation + activity + audit_log joined overview + compliance as live tabs.
160160
expect(PAGE_SRC).toContain(
161-
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>",
161+
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity' | 'audit_log'>",
162162
);
163163
});
164164

frontend/tests/pages/host-detail-shell.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon
490490
expect(PAGE_SRC).toMatch(/activeTab === 'activity' \? \(\s*<HostActivityTab/);
491491
// The stub registry no longer carries an `activity` entry.
492492
expect(PAGE_SRC).not.toMatch(/activity:\s*'Unified Activity feed/);
493-
expect(PAGE_SRC).toMatch(/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity'>/);
493+
expect(PAGE_SRC).toMatch(/Exclude<TabId,[^>]*\| 'activity'[^>]*>/);
494494
// Paged host-scoped feed via useInfiniteQuery with cursor pagination.
495495
expect(PAGE_SRC).toContain('useInfiniteQuery');
496496
expect(PAGE_SRC).toMatch(/getNextPageParam:\s*\(last\)\s*=>\s*last\.next_cursor/);
@@ -499,7 +499,22 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon
499499
expect(PAGE_SRC).toContain('HOST_ACTIVITY_SOURCES');
500500
expect(PAGE_SRC).toMatch(/hasNextPage/);
501501
expect(PAGE_SRC).toMatch(/Load more/);
502-
// The Audit log tab remains a stub.
503-
expect(PAGE_SRC).toMatch(/audit_log:\s*\n?\s*'Audit query API/);
502+
});
503+
504+
// @ac AC-44
505+
test('frontend-host-detail/AC-44 — Audit log tab is live (HostAuditLogTab), host-scoped + readable', () => {
506+
expect(PAGE_SRC).toContain('function HostAuditLogTab');
507+
expect(PAGE_SRC).toMatch(/activeTab === 'audit_log' \? \(\s*<HostAuditLogTab/);
508+
// Removed from the deferred-stub registry.
509+
expect(PAGE_SRC).not.toMatch(/audit_log:\s*\n?\s*'Audit query API/);
510+
expect(PAGE_SRC).toMatch(
511+
/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity' \| 'audit_log'>/,
512+
);
513+
// Host-scoped audit query + readable server message + audit:read gate.
514+
expect(PAGE_SRC).toContain("'/api/v1/audit/events'");
515+
expect(PAGE_SRC).toMatch(/resource_type:\s*'host'/);
516+
expect(PAGE_SRC).toMatch(/resource_id:\s*hostId/);
517+
expect(PAGE_SRC).toMatch(/it\.message \|\| it\.action/);
518+
expect(PAGE_SRC).toMatch(/hasPermission\('audit:read'\)/);
504519
});
505520
});

internal/activity/format.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ func formatIntelligence(eventCode string, detail []byte) (title, summary string)
5454
return title, intelSummary(detail)
5555
}
5656

57-
// formatAudit renders an audit row as "<actor> <predicate>". The actor is
57+
// FormatAudit renders an audit row as "<actor> <predicate>". The actor is
5858
// the recorded actor_label, falling back to a readable actor_type. The raw
5959
// resource_id (a UUID) is intentionally NOT placed in the title; the
6060
// resource_type provides lightweight context in the summary.
61-
func formatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) {
61+
func FormatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) {
6262
actor := strings.TrimSpace(actorLabel)
6363
if actor == "" {
6464
actor = actorWord(actorType)

internal/activity/format_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,15 @@ func TestFormatters_HumanReadable(t *testing.T) {
4545
}
4646

4747
// --- audit ---
48-
title, summary = formatAudit("host.created", "alice@example.com", "user", "host")
48+
title, summary = FormatAudit("host.created", "alice@example.com", "user", "host")
4949
if title != "alice@example.com created a host" {
5050
t.Errorf("audit title = %q", title)
5151
}
5252
if summary != "Host" {
5353
t.Errorf("audit summary = %q, want Host", summary)
5454
}
5555
// actor_label empty -> readable actor_type; no UUID anywhere.
56-
title, _ = formatAudit("authz.permission.denied", "", "system", "")
56+
title, _ = FormatAudit("authz.permission.denied", "", "system", "")
5757
if title != "System was denied permission" {
5858
t.Errorf("audit fallback title = %q", title)
5959
}
@@ -75,7 +75,7 @@ func TestFormatters_GracefulFallback(t *testing.T) {
7575
}
7676

7777
// Unknown audit action.
78-
title, _ = formatAudit("widget.frobnicated", "bob", "user", "")
78+
title, _ = FormatAudit("widget.frobnicated", "bob", "user", "")
7979
if containsDot(title) {
8080
t.Errorf("audit title %q still contains a raw dotted code", title)
8181
}

internal/activity/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu
334334
case SourceIntelligence:
335335
r.Title, r.Summary = formatIntelligence(code, detail)
336336
case SourceAudit:
337-
r.Title, r.Summary = formatAudit(code, ctxA, ctxB, ctxC)
337+
r.Title, r.Summary = FormatAudit(code, ctxA, ctxB, ctxC)
338338
}
339339
out = append(out, r)
340340
}

0 commit comments

Comments
 (0)