Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,14 @@ paths:
- name: actor_type
in: query
schema: {type: string}
- name: resource_type
in: query
description: Filter to events whose resource_type matches (e.g. "host"). Pair with resource_id for a single resource's audit trail.
schema: {type: string}
- name: resource_id
in: query
description: Filter to events whose resource_id matches (e.g. a host UUID). Typically paired with resource_type.
schema: {type: string}
- name: since
in: query
schema: {type: string, format: date-time}
Expand Down Expand Up @@ -4673,9 +4681,15 @@ components:
id: {type: string, format: uuid}
correlation_id: {type: string}
action: {type: string}
# Server-rendered human-readable sentence for the event
# ("<actor> <predicate>", e.g. "alice@example.com created a host"),
# built from action + actor_label + actor_type. Lets every audit
# surface show a sentence instead of the raw dotted action code.
message: {type: string}
severity: {type: string}
actor_type: {type: string}
actor_id: {type: string, nullable: true}
actor_label: {type: string, nullable: true}
resource_type: {type: string, nullable: true}
resource_id: {type: string, nullable: true}
occurred_at: {type: string, format: date-time}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3102,9 +3102,11 @@ export interface components {
id: string;
correlation_id: string;
action: string;
message?: string;
severity?: string;
actor_type: string;
actor_id?: string | null;
actor_label?: string | null;
resource_type?: string | null;
resource_id?: string | null;
/** Format: date-time */
Expand Down Expand Up @@ -5621,6 +5623,10 @@ export interface operations {
action?: string;
correlation_id?: string;
actor_type?: string;
/** @description Filter to events whose resource_type matches (e.g. "host"). Pair with resource_id for a single resource's audit trail. */
resource_type?: string;
/** @description Filter to events whose resource_id matches (e.g. a host UUID). Typically paired with resource_type. */
resource_id?: string;
since?: string;
until?: string;
cursor?: string;
Expand Down
137 changes: 134 additions & 3 deletions frontend/src/pages/HostDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,13 @@ const TAB_ORDER: { id: TabId; label: string; icon: LucideIcon }[] = [
// Backend subsystem that populates each tab when it lands. Surfaces
// inside the per-tab empty state so operators know what's deferred.
const TAB_BACKEND_SUBSYSTEM: Record<
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>,
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity' | 'audit_log'>,
string
> = {
packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).',
services: 'Server Intelligence collection — running services inventory deferred (BACKLOG).',
users: 'Server Intelligence collection — user accounts inventory deferred (BACKLOG).',
network: 'Server Intelligence collection — interfaces and firewall rules deferred (BACKLOG).',
audit_log:
'Audit query API — host-scoped audit feed deferred to the unified /activity page (BACKLOG).',
terminal: 'Web terminal — SSH-in-browser deferred; use a host-side SSH client in the meantime.',
};

Expand Down Expand Up @@ -488,6 +486,8 @@ export function HostDetailPage() {
<RemediationTab hostId={detailQuery.data.host.id} />
) : activeTab === 'activity' ? (
<HostActivityTab hostId={detailQuery.data.host.id} />
) : activeTab === 'audit_log' ? (
<HostAuditLogTab hostId={detailQuery.data.host.id} />
) : (
<TabStub tab={activeTab} subsystem={TAB_BACKEND_SUBSYSTEM[activeTab]} />
)}
Expand Down Expand Up @@ -2675,6 +2675,137 @@ function HostActivityTab({ hostId }: { hostId: string }) {
);
}

// AuditLogItem mirrors the api.AuditEvent envelope returned by
// GET /api/v1/audit/events. `message` is the server-rendered readable
// sentence ("<actor> <predicate>"); we never show the raw action code.
interface AuditLogItem {
id: string;
action: string;
message?: string;
actor_label?: string | null;
occurred_at: string;
}

// HostAuditLogTab is the host-scoped forensic audit trail: audit events
// whose resource is this host (resource_type=host, resource_id=hostId),
// rendered with the server's readable message. Distinct from the Activity
// tab (operational feed) — this is the "who did what to this host" record.
// Gated on audit:read. Spec frontend-host-detail.
function HostAuditLogTab({ hostId }: { hostId: string }) {
const canRead = useAuthStore((s) => s.hasPermission('audit:read'));
const q = useInfiniteQuery({
queryKey: ['host', hostId, 'audit'],
initialPageParam: undefined as string | undefined,
enabled: !!hostId && canRead,
queryFn: async ({ pageParam }) => {
const { data, error, response } = await api.GET('/api/v1/audit/events', {
params: {
query: {
resource_type: 'host',
resource_id: hostId,
limit: 50,
...(pageParam ? { cursor: pageParam } : {}),
},
},
});
if (error || !response.ok) {
throw new Error(apiErrorMessage(error, `Failed to load audit log (${response.status})`));
}
return data as unknown as { items: AuditLogItem[]; next_cursor?: string | null };
},
getNextPageParam: (last) => last.next_cursor ?? undefined,
});

if (!canRead) {
return (
<Card title="Audit log">
<EmptyState
primary="Audit log not available"
secondary="Viewing the audit trail requires the audit:read permission."
/>
</Card>
);
}

const items = q.data?.pages.flatMap((p) => p.items ?? []) ?? [];

return (
<Card title="Audit log">
{q.isLoading ? (
<div style={{ color: 'var(--ow-fg-2)', fontSize: 12 }}>Loading…</div>
) : q.isError ? (
<div
style={{
color: 'var(--ow-crit)',
fontSize: 12,
display: 'flex',
gap: 8,
alignItems: 'center',
}}
>
{apiErrorMessage(q.error, 'Failed to load audit log')}{' '}
<button type="button" onClick={() => q.refetch()} style={smallTextBtn}>
<RefreshCw size={11} /> Retry
</button>
</div>
) : items.length === 0 ? (
<EmptyState
primary="No audit events yet"
secondary="Records who did what to this host (created, updated, scanned, remediated). Entries appear as operators act on the host."
/>
) : (
<>
<ol
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{items.map((it) => (
<li
key={it.id}
style={{
display: 'flex',
gap: 10,
alignItems: 'flex-start',
padding: '8px 0',
borderBottom: '1px solid var(--ow-line)',
}}
>
<FileText
size={14}
color="var(--ow-fg-2)"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0, color: 'var(--ow-fg-0)', fontSize: 13 }}>
{it.message || it.action}
</div>
<div style={{ color: 'var(--ow-fg-3)', fontSize: 11, whiteSpace: 'nowrap' }}>
{relativeTime(it.occurred_at)}
</div>
</li>
))}
</ol>
{q.hasNextPage ? (
<button
type="button"
onClick={() => q.fetchNextPage()}
disabled={q.isFetchingNextPage}
style={{ ...smallTextBtn, marginTop: 12 }}
>
{q.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
) : null}
</>
)}
</Card>
);
}

// activitySeverityColors maps the closed severity enum onto the
// existing OW color tokens.
function activitySeverityColors(s: ActivitySeverity): { fg: string; dot: string } {
Expand Down
4 changes: 2 additions & 2 deletions frontend/tests/pages/host-detail-compliance-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ describe('frontend-host-compliance-tab — structural', () => {
expect(PAGE_SRC).toContain('<ComplianceTab');
// The stub registry no longer carries a compliance entry.
expect(PAGE_SRC).not.toMatch(/^\s*compliance:\s*'/m);
// remediation + activity joined overview + compliance as live (non-stub) tabs.
// remediation + activity + audit_log joined overview + compliance as live tabs.
expect(PAGE_SRC).toContain(
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>",
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity' | 'audit_log'>",
);
});

Expand Down
21 changes: 18 additions & 3 deletions frontend/tests/pages/host-detail-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon
expect(PAGE_SRC).toMatch(/activeTab === 'activity' \? \(\s*<HostActivityTab/);
// The stub registry no longer carries an `activity` entry.
expect(PAGE_SRC).not.toMatch(/activity:\s*'Unified Activity feed/);
expect(PAGE_SRC).toMatch(/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity'>/);
expect(PAGE_SRC).toMatch(/Exclude<TabId,[^>]*\| 'activity'[^>]*>/);
// Paged host-scoped feed via useInfiniteQuery with cursor pagination.
expect(PAGE_SRC).toContain('useInfiniteQuery');
expect(PAGE_SRC).toMatch(/getNextPageParam:\s*\(last\)\s*=>\s*last\.next_cursor/);
Expand All @@ -499,7 +499,22 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon
expect(PAGE_SRC).toContain('HOST_ACTIVITY_SOURCES');
expect(PAGE_SRC).toMatch(/hasNextPage/);
expect(PAGE_SRC).toMatch(/Load more/);
// The Audit log tab remains a stub.
expect(PAGE_SRC).toMatch(/audit_log:\s*\n?\s*'Audit query API/);
});

// @ac AC-44
test('frontend-host-detail/AC-44 — Audit log tab is live (HostAuditLogTab), host-scoped + readable', () => {
expect(PAGE_SRC).toContain('function HostAuditLogTab');
expect(PAGE_SRC).toMatch(/activeTab === 'audit_log' \? \(\s*<HostAuditLogTab/);
// Removed from the deferred-stub registry.
expect(PAGE_SRC).not.toMatch(/audit_log:\s*\n?\s*'Audit query API/);
expect(PAGE_SRC).toMatch(
/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity' \| 'audit_log'>/,
);
// Host-scoped audit query + readable server message + audit:read gate.
expect(PAGE_SRC).toContain("'/api/v1/audit/events'");
expect(PAGE_SRC).toMatch(/resource_type:\s*'host'/);
expect(PAGE_SRC).toMatch(/resource_id:\s*hostId/);
expect(PAGE_SRC).toMatch(/it\.message \|\| it\.action/);
expect(PAGE_SRC).toMatch(/hasPermission\('audit:read'\)/);
});
});
4 changes: 2 additions & 2 deletions internal/activity/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ func formatIntelligence(eventCode string, detail []byte) (title, summary string)
return title, intelSummary(detail)
}

// formatAudit renders an audit row as "<actor> <predicate>". The actor is
// FormatAudit renders an audit row as "<actor> <predicate>". The actor is
// the recorded actor_label, falling back to a readable actor_type. The raw
// resource_id (a UUID) is intentionally NOT placed in the title; the
// resource_type provides lightweight context in the summary.
func formatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) {
func FormatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) {
actor := strings.TrimSpace(actorLabel)
if actor == "" {
actor = actorWord(actorType)
Expand Down
6 changes: 3 additions & 3 deletions internal/activity/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ func TestFormatters_HumanReadable(t *testing.T) {
}

// --- audit ---
title, summary = formatAudit("host.created", "alice@example.com", "user", "host")
title, summary = FormatAudit("host.created", "alice@example.com", "user", "host")
if title != "alice@example.com created a host" {
t.Errorf("audit title = %q", title)
}
if summary != "Host" {
t.Errorf("audit summary = %q, want Host", summary)
}
// actor_label empty -> readable actor_type; no UUID anywhere.
title, _ = formatAudit("authz.permission.denied", "", "system", "")
title, _ = FormatAudit("authz.permission.denied", "", "system", "")
if title != "System was denied permission" {
t.Errorf("audit fallback title = %q", title)
}
Expand All @@ -75,7 +75,7 @@ func TestFormatters_GracefulFallback(t *testing.T) {
}

// Unknown audit action.
title, _ = formatAudit("widget.frobnicated", "bob", "user", "")
title, _ = FormatAudit("widget.frobnicated", "bob", "user", "")
if containsDot(title) {
t.Errorf("audit title %q still contains a raw dotted code", title)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/activity/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu
case SourceIntelligence:
r.Title, r.Summary = formatIntelligence(code, detail)
case SourceAudit:
r.Title, r.Summary = formatAudit(code, ctxA, ctxB, ctxC)
r.Title, r.Summary = FormatAudit(code, ctxA, ctxB, ctxC)
}
out = append(out, r)
}
Expand Down
Loading
Loading