refactor: remove audit event filters

This commit is contained in:
beubax
2026-06-15 18:06:50 +05:30
parent 2a296e33a3
commit 71d52ad0f8
6 changed files with 61 additions and 253 deletions
+1 -7
View File
@@ -53,8 +53,7 @@ authsome log --json # Output JSON format
`GET /api/audit/events` is role-aware. Admin principals can review the global audit log.
Non-admin principals receive only events scoped to their own principal, including their
claimed identities, vault, providers, and credential lifecycle activity. Passing another
`principal_id`, vault, or identity in query parameters cannot widen a non-admin query.
claimed identities, vault, providers, and credential lifecycle activity.
Supported query parameters:
@@ -62,11 +61,6 @@ Supported query parameters:
| --- | --- |
| `limit` | Number of events to return, clamped to the server maximum. |
| `cursor` | Cursor returned by the previous page. |
| `event` | Exact event name. |
| `provider` | Exact provider name, such as `github`. |
| `identity` | Exact identity handle. |
| `from` | Inclusive lower timestamp bound. |
| `to` | Exclusive upper timestamp bound. |
Results are sorted newest-first and include `next_cursor` when another page is available.
+4 -16
View File
@@ -1,9 +1,8 @@
"""Audit event routes."""
from datetime import datetime
from typing import Annotated, Any, Literal
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from authsome import audit
from authsome.identity.principal import PrincipalRole
@@ -17,29 +16,18 @@ router = APIRouter(prefix="/audit", tags=["audit"])
@router.get("/events")
async def list_audit_events( # noqa: PLR0913
async def list_audit_events(
request: Request,
limit: int = 50,
cursor: str | None = None,
event: str | None = None,
provider: str | None = None,
identity: str | None = None,
principal_id: str | None = None,
from_: Annotated[datetime | None, Query(alias="from")] = None,
to: datetime | None = None,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> dict[str, Any]:
effective_principal_id = principal_id if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
effective_principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal"
try:
page = await request.app.state.audit_log.query_events(
limit=limit,
principal_id=effective_principal_id,
event=event,
provider=provider,
identity=identity,
since=from_,
until=to,
cursor=cursor,
)
except ValueError as exc:
+8 -27
View File
@@ -134,29 +134,20 @@ class AuditEventRegistry:
],
)
async def query_events( # noqa: PLR0913
async def query_events(
self,
*,
limit: int = 50,
principal_id: str | None = None,
event: str | None = None,
provider: str | None = None,
identity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
cursor: str | None = None,
) -> AuditEventPage:
bounded_limit = min(max(limit, 1), 500)
filter_specs = (
("principal_id = ?", principal_id, principal_id is not None),
("event = ?", event, bool(event)),
("provider = ?", provider, bool(provider)),
("identity = ?", identity, bool(identity)),
("timestamp >= ?", _dump_dt(since) if since is not None else None, since is not None),
("timestamp < ?", _dump_dt(until) if until is not None else None, until is not None),
)
conditions = [condition for condition, _value, enabled in filter_specs if enabled]
params = [value for _condition, value, enabled in filter_specs if enabled]
conditions: list[str] = []
params: list[Any] = []
if principal_id is not None:
conditions.append("principal_id = ?")
params.append(principal_id)
if cursor:
cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor)
@@ -339,27 +330,17 @@ class ServerAuditLog:
_delegating_audit_exporter.set_active(None)
self._exporter.close()
async def query_events( # noqa: PLR0913
async def query_events(
self,
*,
limit: int = 50,
principal_id: str | None = None,
event: str | None = None,
provider: str | None = None,
identity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
cursor: str | None = None,
) -> AuditEventPage:
await self.async_force_flush()
return await self._registry.query_events(
limit=limit,
principal_id=principal_id,
event=event,
provider=provider,
identity=identity,
since=since,
until=until,
cursor=cursor,
)
+35 -15
View File
@@ -45,7 +45,7 @@ def _emit_audit_event( # noqa: PLR0913
emit(
AuditEvent(
event_id=event_id,
timestamp=timestamp or datetime(2026, 6, 15, 8, 0, tzinfo=UTC),
timestamp=timestamp or datetime(2099, 1, 1, 8, 0, tzinfo=UTC),
event=event,
principal_id=principal_id,
identity=identity,
@@ -82,6 +82,17 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm
assert entries[0]["provider"] == "github"
def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
response = client.get("/openapi.json")
assert response.status_code == status.HTTP_200_OK
params = response.json()["paths"]["/api/audit/events"]["get"]["parameters"]
assert {param["name"] for param in params} == {"limit", "cursor"}
def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}}
@@ -156,7 +167,7 @@ def test_admin_sees_all_audit_events_and_user_sees_only_own_principal(monkeypatc
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in user_entries)
def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path: Path) -> None:
def test_non_admin_audit_query_params_do_not_filter_or_widen_scope(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
@@ -205,10 +216,9 @@ def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["scope"] == "principal"
assert body["next_cursor"] is None
assert [entry["event_id"] for entry in body["entries"]] == ["audit_002"]
assert body["entries"][0]["principal_id"] == user_whoami["principal_id"]
assert body["entries"][0]["provider"] == "github"
manual_entries = [entry for entry in body["entries"] if entry["event_id"].startswith("audit_00")]
assert [entry["event_id"] for entry in manual_entries] == ["audit_003", "audit_002"]
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"])
def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity(
@@ -259,10 +269,13 @@ def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity(
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["scope"] == "principal"
assert body["entries"] == []
event_ids = {entry["event_id"] for entry in body["entries"]}
assert "audit_011" in event_ids
assert "audit_010" not in event_ids
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"])
def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, tmp_path: Path) -> None:
def test_admin_audit_events_support_cursor_pagination(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
@@ -282,7 +295,15 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="github",
timestamp=datetime(2026, 6, 15, 8, 0, tzinfo=UTC),
timestamp=datetime(2099, 1, 1, 8, 0, tzinfo=UTC),
)
_emit_audit_event(
"audit_099",
"connection.logout",
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="linear",
timestamp=datetime(2099, 1, 1, 7, 59, tzinfo=UTC),
)
_emit_audit_event(
"audit_101",
@@ -290,7 +311,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
timestamp=datetime(2026, 6, 15, 8, 1, tzinfo=UTC),
timestamp=datetime(2099, 1, 1, 8, 1, tzinfo=UTC),
)
_emit_audit_event(
"audit_102",
@@ -298,10 +319,10 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
timestamp=datetime(2026, 6, 15, 8, 2, tzinfo=UTC),
timestamp=datetime(2099, 1, 1, 8, 2, tzinfo=UTC),
)
first_path = "/api/audit/events?provider=github&limit=2"
first_path = "/api/audit/events?limit=2"
first_response = client.get(
first_path,
headers=_auth_header(
@@ -313,7 +334,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t
)
assert first_response.status_code == status.HTTP_200_OK
first_body = first_response.json()
second_path = f"/api/audit/events?provider=github&limit=2&cursor={first_body['next_cursor']}"
second_path = f"/api/audit/events?limit=2&cursor={first_body['next_cursor']}"
second_response = client.get(
second_path,
headers=_auth_header(
@@ -331,8 +352,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t
assert second_response.status_code == status.HTTP_200_OK
second_body = second_response.json()
assert second_body["scope"] == "global"
assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"]
assert second_body["next_cursor"] is None
assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100", "audit_099"]
@pytest.mark.asyncio
+7 -172
View File
@@ -33,7 +33,6 @@ import useSWR from "swr";
import {
ApiError,
AuditFilters,
ConnectionDetail,
DashboardData,
GlobalConnectionRow,
@@ -1437,62 +1436,20 @@ export function PrincipalsView() {
);
}
function normalizeAuditFilters(filters: AuditFilters): AuditFilters {
return Object.fromEntries(
Object.entries(filters)
.map(([key, value]) => [key, typeof value === "string" ? value.trim() : value])
.filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""),
) as AuditFilters;
}
function localDateTimeToIso(value: string): string | undefined {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString();
}
export function AuditView({ data }: { data: DashboardData }) {
const [filters, setFilters] = useState<AuditFilters>({
event: "",
provider: "",
identity: "",
from: "",
to: "",
});
const [auditResult, setAuditResult] = useState<{
activeFilters: AuditFilters;
events: DashboardData["audit"]["events"];
nextCursor: string | null;
} | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [loadingAction, setLoadingAction] = useState<"apply" | "more" | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const requestSequence = useRef(0);
const events = auditResult?.events ?? data.audit.events;
const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor;
const activeFilters = auditResult?.activeFilters ?? {};
const providerNames = useMemo(() => data.providers.map((provider) => provider.name).sort(), [data.providers]);
const identityHandles = useMemo(() => data.identities.map((identity) => identity.handle).sort(), [data.identities]);
const eventNames = useMemo(() => Array.from(new Set(events.map((event) => event.eventName))).sort(), [events]);
const description = data.account.isAdmin
? "Recent administrative and credential events."
: "Recent account, identity, vault, and credential events for this principal.";
function updateFilter(name: keyof Pick<AuditFilters, "event" | "provider" | "identity" | "from" | "to">, value: string) {
setFilters((current) => ({ ...current, [name]: value }));
}
function filtersForRequest(cursor?: string | null): AuditFilters {
return normalizeAuditFilters({
event: filters.event,
provider: filters.provider,
identity: filters.identity,
from: localDateTimeToIso(filters.from || ""),
to: localDateTimeToIso(filters.to || ""),
cursor,
limit: 50,
});
}
function nextRequestId(): number {
requestSequence.current += 1;
return requestSequence.current;
@@ -1502,64 +1459,15 @@ export function AuditView({ data }: { data: DashboardData }) {
return requestSequence.current === requestId;
}
async function applyFilters(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const requestFilters = filtersForRequest();
const requestId = nextRequestId();
setLoadingAction("apply");
setErrorMessage("");
try {
const result = await fetchAuditEvents(requestFilters);
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters: requestFilters,
events: result.events,
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
}
}
}
async function clearFilters() {
const emptyFilters = { event: "", provider: "", identity: "", from: "", to: "" };
const requestId = nextRequestId();
setFilters(emptyFilters);
setLoadingAction("apply");
setErrorMessage("");
try {
const result = await fetchAuditEvents({ limit: 50 });
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters: {},
events: result.events,
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
}
}
}
async function loadMore() {
if (!nextCursor) return;
const requestId = nextRequestId();
setLoadingAction("more");
setLoadingMore(true);
setErrorMessage("");
try {
const result = await fetchAuditEvents({ ...activeFilters, cursor: nextCursor, limit: 50 });
const result = await fetchAuditEvents({ cursor: nextCursor, limit: 50 });
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters,
events: [...events, ...result.events],
nextCursor: result.nextCursor,
});
@@ -1568,7 +1476,7 @@ export function AuditView({ data }: { data: DashboardData }) {
setErrorMessage(error instanceof Error ? error.message : "Failed to load more audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
setLoadingMore(false);
}
}
}
@@ -1576,80 +1484,7 @@ export function AuditView({ data }: { data: DashboardData }) {
return (
<div className="grid gap-5">
<SectionHeader description={description} title="Audit Log" />
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>{data.audit.scope === "global" ? "Global audit scope" : "Principal audit scope"}</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4 md:grid-cols-2 xl:grid-cols-5" onSubmit={(event) => void applyFilters(event)}>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Event</span>
<Input
list="audit-event-options"
onChange={(event) => updateFilter("event", event.target.value)}
value={filters.event || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Provider</span>
<Input
list="audit-provider-options"
onChange={(event) => updateFilter("provider", event.target.value)}
value={filters.provider || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Identity</span>
<Input
list="audit-identity-options"
onChange={(event) => updateFilter("identity", event.target.value)}
value={filters.identity || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">From</span>
<Input
onChange={(event) => updateFilter("from", event.target.value)}
type="datetime-local"
value={filters.from || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">To</span>
<Input
onChange={(event) => updateFilter("to", event.target.value)}
type="datetime-local"
value={filters.to || ""}
/>
</label>
<datalist id="audit-event-options">
{eventNames.map((event) => (
<option key={event} value={event} />
))}
</datalist>
<datalist id="audit-provider-options">
{providerNames.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
<datalist id="audit-identity-options">
{identityHandles.map((identity) => (
<option key={identity} value={identity} />
))}
</datalist>
<div className="flex items-end gap-2 md:col-span-2 xl:col-span-5">
<Button disabled={loadingAction !== null} type="submit">
Apply filters
</Button>
<Button disabled={loadingAction !== null} onClick={() => void clearFilters()} type="button" variant="outline">
Clear
</Button>
</div>
</form>
{errorMessage ? <p className="mt-4 text-sm text-destructive">{errorMessage}</p> : null}
</CardContent>
</Card>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
{events.length ? (
@@ -1694,8 +1529,8 @@ export function AuditView({ data }: { data: DashboardData }) {
</Card>
{nextCursor ? (
<div className="flex justify-center">
<Button disabled={loadingAction !== null} onClick={() => void loadMore()} type="button" variant="outline">
{loadingAction === "more" ? "Loading..." : "Load more"}
<Button disabled={loadingMore} onClick={() => void loadMore()} type="button" variant="outline">
{loadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
+6 -16
View File
@@ -51,12 +51,7 @@ export type AuditRow = {
metadata: Record<string, unknown>;
};
export type AuditFilters = {
event?: string;
provider?: string;
identity?: string;
from?: string;
to?: string;
export type AuditEventsQuery = {
cursor?: string | null;
limit?: number;
};
@@ -517,20 +512,15 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] {
});
}
function auditQueryString(filters: AuditFilters = {}): string {
function auditQueryString(query: AuditEventsQuery = {}): string {
const params = new URLSearchParams();
params.set("limit", String(filters.limit ?? 50));
if (filters.event) params.set("event", filters.event);
if (filters.provider) params.set("provider", filters.provider);
if (filters.identity) params.set("identity", filters.identity);
if (filters.from) params.set("from", filters.from);
if (filters.to) params.set("to", filters.to);
if (filters.cursor) params.set("cursor", filters.cursor);
params.set("limit", String(query.limit ?? 50));
if (query.cursor) params.set("cursor", query.cursor);
return params.toString();
}
export async function fetchAuditEvents(filters: AuditFilters = {}): Promise<AuditEventsData> {
const data = await requestJson<AuditResponse>(`/api/audit/events?${auditQueryString(filters)}`);
export async function fetchAuditEvents(query: AuditEventsQuery = {}): Promise<AuditEventsData> {
const data = await requestJson<AuditResponse>(`/api/audit/events?${auditQueryString(query)}`);
const events = buildAuditRows(data.entries);
return {
scope: data.scope ?? "principal",