Skip to content

Commit a81d62f

Browse files
Add audit status tracking, fix duration/rewritten query, and audit write rejections
- Add `status` ("success" | "error" | "denied") and `error_message` columns to `query_audit_log` via two new migrations (017, 018) - Restructure `PolicyHook::handle_query` with a labeled block so all paths (success, error, policy-denied) reach a single audit write site - Fix `elapsed_ms` to be captured after the labeled block, covering encoding too - Fix `rewritten_query` to use DataFusion `Unparser` + `BetweenRowsPostgresDialect` instead of the fake `/* policy-rewritten */` comment - Swap hook order to [PolicyHook, ReadOnlyHook] and add `audit_write_rejected()` so write statements rejected by ReadOnlyHook are audited with status "denied" - Extract `is_allowed_statement()` from ReadOnlyHook as shared public helper - Add `status` filter to `GET /audit/queries` API - Add Status column + filter dropdown + error_message detail block to QueryAuditPage - Add `sql-formatter` for pretty-printing SQL in the detail panel - Add TC-AUDIT-01 through TC-AUDIT-05 integration tests - Remove resolved roadmap bug entry; add security vectors 21-24
1 parent 60558a6 commit a81d62f

17 files changed

Lines changed: 865 additions & 81 deletions

admin-ui/package-lock.json

Lines changed: 91 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin-ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"axios": "^1.7.9",
1818
"react": "^19.0.0",
1919
"react-dom": "^19.0.0",
20-
"react-router-dom": "^7.1.5"
20+
"react-router-dom": "^7.1.5",
21+
"sql-formatter": "^15.7.2"
2122
},
2223
"devDependencies": {
2324
"@tailwindcss/vite": "^4.0.6",

admin-ui/src/api/audit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface AuditLogEntry {
1414
client_ip: string | null
1515
client_info: string | null
1616
created_at: string
17+
status: 'success' | 'error' | 'denied'
18+
error_message: string | null
1719
}
1820

1921
export async function listAuditLogs(params?: {
@@ -23,6 +25,7 @@ export async function listAuditLogs(params?: {
2325
datasource_id?: string
2426
from?: string
2527
to?: string
28+
status?: string
2629
}): Promise<PaginatedResponse<AuditLogEntry>> {
2730
const { data } = await client.get<PaginatedResponse<AuditLogEntry>>('/audit/queries', { params })
2831
return data

admin-ui/src/pages/QueryAuditPage.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import { useState } from 'react'
22
import { useQuery } from '@tanstack/react-query'
3+
import { format } from 'sql-formatter'
34
import { listAuditLogs } from '../api/audit'
45
import type { AuditLogEntry } from '../api/audit'
56

7+
function formatSql(sql: string): string {
8+
try {
9+
return format(sql, { language: 'postgresql', tabWidth: 2, keywordCase: 'upper' })
10+
} catch {
11+
return sql
12+
}
13+
}
14+
615
function formatDate(iso: string) {
716
return new Date(iso).toLocaleString()
817
}
@@ -11,18 +20,42 @@ function truncate(s: string, n: number) {
1120
return s.length > n ? s.slice(0, n) + '…' : s
1221
}
1322

23+
function StatusBadge({ status }: { status: AuditLogEntry['status'] }) {
24+
if (status === 'success') {
25+
return (
26+
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-50 text-green-700">
27+
success
28+
</span>
29+
)
30+
}
31+
if (status === 'denied') {
32+
return (
33+
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-amber-50 text-amber-700">
34+
denied
35+
</span>
36+
)
37+
}
38+
return (
39+
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-red-50 text-red-700">
40+
error
41+
</span>
42+
)
43+
}
44+
1445
export function QueryAuditPage() {
1546
const [page, setPage] = useState(1)
1647
const [expanded, setExpanded] = useState<Set<string>>(new Set())
1748
const [filterUser, setFilterUser] = useState('')
1849
const [filterDs, setFilterDs] = useState('')
1950
const [filterFrom, setFilterFrom] = useState('')
2051
const [filterTo, setFilterTo] = useState('')
52+
const [filterStatus, setFilterStatus] = useState('')
2153
const [appliedFilters, setAppliedFilters] = useState<{
2254
user_id?: string
2355
datasource_id?: string
2456
from?: string
2557
to?: string
58+
status?: string
2659
}>({})
2760

2861
const { data, isLoading, isError } = useQuery({
@@ -42,6 +75,7 @@ export function QueryAuditPage() {
4275
datasource_id: filterDs || undefined,
4376
from: filterFrom || undefined,
4477
to: filterTo || undefined,
78+
status: filterStatus || undefined,
4579
})
4680
setPage(1)
4781
}
@@ -51,6 +85,7 @@ export function QueryAuditPage() {
5185
setFilterDs('')
5286
setFilterFrom('')
5387
setFilterTo('')
88+
setFilterStatus('')
5489
setAppliedFilters({})
5590
setPage(1)
5691
}
@@ -81,7 +116,7 @@ export function QueryAuditPage() {
81116

82117
{/* Filters */}
83118
<form onSubmit={handleFilter} className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
84-
<div className="grid grid-cols-4 gap-3 mb-3">
119+
<div className="grid grid-cols-5 gap-3 mb-3">
85120
<div>
86121
<label className="block text-xs font-medium text-gray-600 mb-1">User ID</label>
87122
<input
@@ -120,6 +155,19 @@ export function QueryAuditPage() {
120155
className="w-full border border-gray-300 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
121156
/>
122157
</div>
158+
<div>
159+
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
160+
<select
161+
value={filterStatus}
162+
onChange={(e) => setFilterStatus(e.target.value)}
163+
className="w-full border border-gray-300 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
164+
>
165+
<option value="">All</option>
166+
<option value="success">Success</option>
167+
<option value="error">Error</option>
168+
<option value="denied">Denied</option>
169+
</select>
170+
</div>
123171
</div>
124172
<div className="flex gap-2">
125173
<button
@@ -156,6 +204,7 @@ export function QueryAuditPage() {
156204
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">User</th>
157205
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">Data Source</th>
158206
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">Query</th>
207+
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">Status</th>
159208
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">Policies</th>
160209
<th className="text-left px-4 py-3 font-medium text-gray-600 text-xs">Duration</th>
161210
<th className="px-4 py-3" />
@@ -173,6 +222,9 @@ export function QueryAuditPage() {
173222
<td className="px-4 py-3 text-xs font-mono text-gray-700 max-w-xs">
174223
{truncate(entry.original_query, 80)}
175224
</td>
225+
<td className="px-4 py-3 text-xs">
226+
<StatusBadge status={entry.status} />
227+
</td>
176228
<td className="px-4 py-3 text-xs text-gray-600">
177229
{entry.policies_applied.length > 0 ? (
178230
<span className="bg-blue-50 text-blue-700 rounded-full px-2 py-0.5 text-xs">
@@ -196,19 +248,25 @@ export function QueryAuditPage() {
196248
</tr>
197249
{expanded.has(entry.id) && (
198250
<tr key={`${entry.id}-detail`} className="bg-gray-50">
199-
<td colSpan={7} className="px-4 py-4">
251+
<td colSpan={8} className="px-4 py-4">
200252
<div className="space-y-3">
253+
{entry.error_message && (
254+
<div className="bg-red-50 border border-red-200 rounded p-3">
255+
<p className="text-xs font-semibold text-red-700 mb-1">Error</p>
256+
<p className="text-xs font-mono text-red-800">{entry.error_message}</p>
257+
</div>
258+
)}
201259
<div>
202260
<p className="text-xs font-semibold text-gray-600 mb-1">Original query</p>
203261
<pre className="text-xs font-mono text-gray-800 bg-white border border-gray-200 rounded p-3 overflow-auto whitespace-pre-wrap">
204-
{entry.original_query}
262+
{formatSql(entry.original_query)}
205263
</pre>
206264
</div>
207265
{entry.rewritten_query && (
208266
<div>
209267
<p className="text-xs font-semibold text-gray-600 mb-1">Rewritten query</p>
210268
<pre className="text-xs font-mono text-gray-800 bg-white border border-gray-200 rounded p-3 overflow-auto whitespace-pre-wrap">
211-
{entry.rewritten_query}
269+
{formatSql(entry.rewritten_query)}
212270
</pre>
213271
</div>
214272
)}

docs/permission-security-tests.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,52 @@ The `ColumnAccessDef` struct no longer has an `action` field. The API validation
257257
- Unit: `engine::tests::test_permit_column_access_wildcard_grants_full_visibility_policy_required` — permit policy with `column_access ["*"]` in a `policy_required` aliased datasource → table is visible, `visible_tables` non-empty.
258258
- Unit: `hooks::policy::tests::test_column_access_deny_no_table_permit` — deny policy with `column_access` in `policy_required` mode → `lit(false)` (deny policy alone does not grant table access).
259259
- Unit: `admin::policy_handlers::tests::create_policy_column_access_missing_columns_422``column_access` definition without `columns` field → `422`.
260+
261+
262+
---
263+
264+
### 21. Denied queries leave no audit trail (silent denial)
265+
266+
**Vector**: A user submits a query blocked by a deny policy. If the audit log is only written on the success path, there is no record of the denied access attempt — attackers can probe policy boundaries without leaving evidence.
267+
268+
**Bug**: The `tokio::spawn` audit write in `PolicyHook::handle_query` was placed after all `return Some(Err(...))` paths. Any failed or denied query short-circuited before the audit write.
269+
270+
**Defense**: `handle_query` now uses a labeled block (`'query: { ... }`) that returns `(result, status, error_message, rewritten_query)` on all paths. The audit write follows the block and runs unconditionally for every auditable query. Status values: `"success"`, `"error"`, `"denied"`.
271+
272+
**Test**:
273+
- Integration: `tc_audit_01_success_audit_status` — successful query → `status: "success"`, `error_message: null`, `execution_time_ms ≥ 0`, `rewritten_query` contains actual SQL (not fake comment).
274+
- Integration: `tc_audit_02_denied_audit_status` — deny-policy query → `status: "denied"`, `error_message` contains the policy name.
275+
- Integration: `tc_audit_03_error_audit_status` — query for non-existent table → `status: "error"`, `error_message` populated.
276+
- Integration: `tc_audit_04_status_filter``GET /audit/queries?status=denied` returns only denied entries.
277+
278+
---
279+
280+
### 22. Audit duration excludes encode phase (misleading timing)
281+
282+
**Vector**: `execution_time_ms` was captured after `execute_logical_plan` but before `encode_dataframe`. Since DataFusion returns a lazy `DataFrame`, the actual row-fetching happens during encoding. Timing was systematically under-reported.
283+
284+
**Defense**: `elapsed_ms` is now captured after the labeled block exits, covering planning + policy eval + execution + encoding (full user-perceived latency).
285+
286+
**Test**: Covered by `tc_audit_01_success_audit_status``execution_time_ms ≥ 0` (positive timing is asserted).
287+
288+
---
289+
290+
### 23. Rewritten query in audit log was fake (/* policy-rewritten */ comment only)
291+
292+
**Vector**: The audit log's `rewritten_query` field previously just prepended `/* policy-rewritten */` to the original query string. The actual row filters and column masks applied by policies were not visible, defeating the purpose of the audit trail.
293+
294+
**Defense**: DataFusion's `Unparser` with `BetweenRowsPostgresDialect` is used to serialize the final `LogicalPlan` (after all obligation rewrites) back to SQL. If unparsing fails, the fallback is `/* plan-to-sql failed */ {original_query}`.
295+
296+
**Test**: `tc_audit_01_success_audit_status``rewritten_query` must not contain `/* policy-rewritten */` and must be non-empty when a row filter was applied.
297+
298+
---
299+
300+
### 24. Write statement rejected by ReadOnlyHook leaves no audit trail
301+
302+
**Vector**: A user submits a write statement (INSERT, UPDATE, DELETE, DROP, SET, etc.). `ReadOnlyHook` rejects it before `PolicyHook` runs, so no audit record is created. An attacker can probe write access without evidence.
303+
304+
**Bug**: Hook execution order was `[ReadOnlyHook, PolicyHook]`. `ReadOnlyHook` returned `Some(Err(...))` and short-circuited the chain, so `PolicyHook` never saw the statement.
305+
306+
**Defense**: Hook order is now `[PolicyHook, ReadOnlyHook]`. `PolicyHook` runs first: for non-`Query` statements that are not on the read-only passthrough list, it calls `audit_write_rejected()` (writes a `"denied"` audit entry with `error_message: "Only read-only queries are allowed"`) then returns `None`. `ReadOnlyHook` then runs and enforces the rejection. A shared `is_allowed_statement()` function in `read_only.rs` is the single source of truth for the allowlist — `PolicyHook` uses it to decide which statements to audit without duplicating the logic.
307+
308+
**Test**: `tc_audit_05_write_rejected_audit_status``INSERT` against the proxy → audit entry has `status: "denied"`, `error_message` contains `"read-only"`.

0 commit comments

Comments
 (0)