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
3 changes: 3 additions & 0 deletions apps/api/src/routes/v2/audit-log.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", (
const page = yield* paginateOffsetQuery(query, ({ limit, offset }) =>
audit
.list(tenant.orgId, {
...(query.kind !== undefined && query.kind !== "all"
? { actionKind: query.kind }
: undefined),
...(query.actor_type !== undefined ? { actorType: query.actor_type } : undefined),
...identity,
...(affectedUser !== undefined ? { affectedUserId: affectedUser } : undefined),
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/settings/audit-log-filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Option } from "effect"
import { describe, expect, it } from "vitest"

import {
auditLogSearchFrom,
hasAuditLogFilters,
parseAuditLogSearch,
parseAuditLookup,
pickAuditLogSearch,
resolveAuditDateRange,
} from "./audit-log-filters"
Expand Down Expand Up @@ -111,3 +113,58 @@ describe("resolveAuditDateRange", () => {
expect(resolveAuditDateRange({ kind: "all" }, now)).toEqual({ since: undefined, until: undefined })
})
})

describe("the resource, action, request and kind filters", () => {
it("keeps known values and round-trips them through the URL", () => {
const search = {
kind: "access",
resource_type: "dashboard",
action: "dashboard.updated",
resource: "dash_YofPTrK9782DWwcnXhpcCw",
affected: "user_2fj3K9dLqWm8xYbT",
request: "8f2c1a9d4b7e3f60",
}
const filters = parseAuditLogSearch(search)
expect(filters).toMatchObject({
kind: "access",
resourceType: "dashboard",
action: "dashboard.updated",
resourceId: "dash_YofPTrK9782DWwcnXhpcCw",
affectedUserId: "user_2fj3K9dLqWm8xYbT",
requestId: "8f2c1a9d4b7e3f60",
})
expect(auditLogSearchFrom(filters)).toMatchObject(search)
})

it("drops unknown resource types and actions, and leaves the default kind out of the URL", () => {
const filters = parseAuditLogSearch({
kind: "changes",
resource_type: "spaceship",
action: "dashboard.launched",
request: "not-a-ray",
})
expect(filters.kind).toBeUndefined()
expect(filters.resourceType).toBeUndefined()
expect(filters.action).toBeUndefined()
expect(filters.requestId).toBeUndefined()
expect(auditLogSearchFrom(filters).kind).toBeUndefined()
})
})

describe("parseAuditLookup", () => {
it("reads each id shape as its filter", () => {
expect(Option.getOrNull(parseAuditLookup("user_2fj3K9dLqWm8xYbT"))).toEqual({
key: "actorId",
value: "user_2fj3K9dLqWm8xYbT",
})
expect(Option.getOrNull(parseAuditLookup("alrt_YofPTrK9782DWwcnXhpcCw"))).toEqual({
key: "resourceId",
value: "alrt_YofPTrK9782DWwcnXhpcCw",
})
expect(Option.getOrNull(parseAuditLookup("api_key.revoked"))).toEqual({
key: "action",
value: "api_key.revoked",
})
expect(Option.isNone(parseAuditLookup("dashboards"))).toBe(true)
})
})
84 changes: 82 additions & 2 deletions apps/web/src/components/settings/audit-log-filters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auditActionLabel, auditResourceLabel } from "@maple/domain/audit-actions"
import type { AuditActorType, AuditOutcome } from "@maple/domain/http"
import { Array as Arr, Option } from "effect"
import { Array as Arr, Option, String as Str } from "effect"

export interface AuditLogSearch {
readonly range?: string
Expand All @@ -8,6 +9,12 @@ export interface AuditLogSearch {
readonly actor?: string
readonly actor_type?: string
readonly outcome?: string
readonly kind?: string
readonly resource_type?: string
readonly action?: string
readonly resource?: string
readonly affected?: string
readonly request?: string
}

/** Every key present, unset ones `undefined`, so spreading it over the search clears them. */
Expand All @@ -18,6 +25,12 @@ export interface AuditLogSearchUpdate {
readonly actor: string | undefined
readonly actor_type: string | undefined
readonly outcome: string | undefined
readonly kind: string | undefined
readonly resource_type: string | undefined
readonly action: string | undefined
readonly resource: string | undefined
readonly affected: string | undefined
readonly request: string | undefined
}

export const AUDIT_RANGE_PRESETS = [
Expand All @@ -32,6 +45,10 @@ export type AuditRangePreset = (typeof AUDIT_RANGE_PRESETS)[number]["value"]
const ACTOR_TYPES: ReadonlyArray<AuditActorType> = ["user", "api_key", "agent", "system"]
const OUTCOMES: ReadonlyArray<AuditOutcome> = ["allowed", "denied"]

/** Which part of the log the list shows. `changes` is the default and stays out of the URL. */
export type AuditLogKind = "changes" | "access" | "all"
const KINDS: ReadonlyArray<AuditLogKind> = ["changes", "access", "all"]

/** The window: a rolling preset, a fixed `since`/`until` pair, or everything. */
export type AuditDateRange =
| { readonly kind: "all" }
Expand All @@ -43,8 +60,18 @@ export interface AuditLogFilters {
readonly actorId?: string | undefined
readonly actorType?: AuditActorType | undefined
readonly outcome?: AuditOutcome | undefined
readonly kind?: AuditLogKind | undefined
readonly resourceType?: string | undefined
readonly action?: string | undefined
/** A resource's public id (`dash_…`). */
readonly resourceId?: string | undefined
readonly affectedUserId?: string | undefined
readonly requestId?: string | undefined
}

/** The list's kind, `changes` when the URL names none. */
export const auditLogKindOf = (filters: AuditLogFilters): AuditLogKind => filters.kind ?? "changes"

// The API takes explicit-UTC ISO timestamps only; anything else would be a 400.
const isIsoTimestamp = (value: string | undefined): value is string =>
value !== undefined && /^\d{4}-\d{2}-\d{2}T.+Z$/.test(value) && !Number.isNaN(Date.parse(value))
Expand All @@ -53,6 +80,23 @@ const isIsoTimestamp = (value: string | undefined): value is string =>
const isActorId = (value: string | undefined): value is string =>
value !== undefined && /^(?:user|key|actor)_[A-Za-z0-9]+$/.test(value)

const isUserId = (value: string | undefined): value is string =>
value !== undefined && /^user_[A-Za-z0-9]+$/.test(value)

// Public resource ids: a lowercase prefix and a base58 body (`dash_…`, `alrt_…`).
const isPublicId = (value: string | undefined): value is string =>
value !== undefined && /^[a-z]+_[A-Za-z0-9]{6,}$/.test(value)

// Request ids are Cloudflare ray ids: hex, sometimes with a `-<colo>` suffix.
const isRequestId = (value: string | undefined): value is string =>
value !== undefined && /^[0-9a-f]{16}(?:-[A-Z]{3})?$/.test(value)

const isKnownAction = (value: string | undefined): value is string =>
value !== undefined && auditActionLabel(value) !== null

const isKnownResourceType = (value: string | undefined): value is string =>
value !== undefined && auditResourceLabel(value) !== null

/** Narrow the raw search params to filters the API accepts, dropping anything malformed. */
export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters {
const since = isIsoTimestamp(search.since) ? search.since : undefined
Expand All @@ -72,9 +116,34 @@ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters {
actorId: isActorId(search.actor) ? search.actor : undefined,
actorType: Option.getOrUndefined(Arr.findFirst(ACTOR_TYPES, (type) => type === search.actor_type)),
outcome: Option.getOrUndefined(Arr.findFirst(OUTCOMES, (value) => value === search.outcome)),
kind: Option.getOrUndefined(
Arr.findFirst(KINDS, (value) => value !== "changes" && value === search.kind),
),
resourceType: isKnownResourceType(search.resource_type) ? search.resource_type : undefined,
action: isKnownAction(search.action) ? search.action : undefined,
resourceId: isPublicId(search.resource) ? search.resource : undefined,
affectedUserId: isUserId(search.affected) ? search.affected : undefined,
requestId: isRequestId(search.request) ? search.request : undefined,
}
}

/** The filter one pasted value stands for: an id of a known shape, or an action name. */
export type AuditLookup =
| { readonly key: "actorId"; readonly value: string }
| { readonly key: "resourceId"; readonly value: string }
| { readonly key: "requestId"; readonly value: string }
| { readonly key: "action"; readonly value: string }

/** Read a pasted id or action as the filter it stands for; none for anything else. */
export function parseAuditLookup(raw: string): Option.Option<AuditLookup> {
const value = Str.trim(raw)
if (isActorId(value)) return Option.some({ key: "actorId", value })
if (isRequestId(value)) return Option.some({ key: "requestId", value })
if (isKnownAction(value)) return Option.some({ key: "action", value })
if (isPublicId(value)) return Option.some({ key: "resourceId", value })
return Option.none()
}

/** Write filters back as search params; unset ones are `undefined` so the URL drops them. */
export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpdate {
const { range } = filters
Expand All @@ -85,6 +154,12 @@ export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpda
actor: filters.actorId,
actor_type: filters.actorType,
outcome: filters.outcome,
kind: filters.kind === "changes" ? undefined : filters.kind,
resource_type: filters.resourceType,
action: filters.action,
resource: filters.resourceId,
affected: filters.affectedUserId,
request: filters.requestId,
}
}

Expand Down Expand Up @@ -117,6 +192,11 @@ export function hasAuditLogFilters(filters: AuditLogFilters): boolean {
filters.range.kind !== "all" ||
filters.actorId !== undefined ||
filters.actorType !== undefined ||
filters.outcome !== undefined
filters.outcome !== undefined ||
filters.resourceType !== undefined ||
filters.action !== undefined ||
filters.resourceId !== undefined ||
filters.affectedUserId !== undefined ||
filters.requestId !== undefined
)
}
Loading
Loading