diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts index 5bfc00cf92..cbaa3bcee4 100644 --- a/apps/api/src/routes/v2/audit-log.http.ts +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -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), diff --git a/apps/web/src/components/settings/audit-log-filters.test.ts b/apps/web/src/components/settings/audit-log-filters.test.ts index 9426cf5b19..3cc86da397 100644 --- a/apps/web/src/components/settings/audit-log-filters.test.ts +++ b/apps/web/src/components/settings/audit-log-filters.test.ts @@ -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" @@ -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) + }) +}) diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts index 0cebb89c53..a35656a7ff 100644 --- a/apps/web/src/components/settings/audit-log-filters.ts +++ b/apps/web/src/components/settings/audit-log-filters.ts @@ -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 @@ -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. */ @@ -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 = [ @@ -32,6 +45,10 @@ export type AuditRangePreset = (typeof AUDIT_RANGE_PRESETS)[number]["value"] const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] const OUTCOMES: ReadonlyArray = ["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 = ["changes", "access", "all"] + /** The window: a rolling preset, a fixed `since`/`until` pair, or everything. */ export type AuditDateRange = | { readonly kind: "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)) @@ -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 `-` 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 @@ -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 { + 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 @@ -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, } } @@ -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 ) } diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts new file mode 100644 index 0000000000..688198668c --- /dev/null +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -0,0 +1,251 @@ +import { encodePublicId, type PublicIdPrefix, PublicIdPrefixes, V2AuditLogEntry } from "@maple/domain/http/v2" +import { Array as Arr, Schema, String as Str } from "effect" + +// Audit log entries for `/lab/audit-log`: a morning of changes, reads, tool calls and refusals +// across users, an API key, an agent and Maple itself. + +export const AUDIT_NOW = Date.UTC(2026, 10, 9, 15, 30, 0) + +export const AUDIT_MEMBERS = [ + { value: "user_2fj3K9dLqWm8xYbT", label: "Ada Lovelace" }, + { value: "user_7HqLm2NpRt4VwXyZ", label: "Grace Hopper" }, + { value: "user_9KdTq3RsVw5XyZaB", label: "Linus Torvalds" }, +] as const + +const ADA = AUDIT_MEMBERS[0].value +const GRACE = AUDIT_MEMBERS[1].value +const LINUS = AUDIT_MEMBERS[2].value + +const decode = Schema.decodeUnknownSync(V2AuditLogEntry) + +const uuid = (n: number) => `3f1b7c02-9a44-4d1e-8b2f-${Str.padStart(12, "0")(String(n))}` + +type Wire = Readonly<{ + n: number + minutesAgo: number + action: string + actor: "ada" | "grace" | "linus" | "key" | "agent" | "system" + source: "dashboard" | "api" | "mcp" | "chat_platform" | "system" + resource?: { type: string; prefix?: PublicIdPrefix } + denied?: string + affected?: string + changes?: { + fields: ReadonlyArray + before: Record + after: Record + } + metadata?: Record + ip?: string + country?: string +}> + +const ACTORS = { + ada: { actor_type: "user", actor_id: ADA, actor_name: "Ada Lovelace" }, + grace: { actor_type: "user", actor_id: GRACE, actor_name: "Grace Hopper" }, + linus: { actor_type: "user", actor_id: LINUS, actor_name: null }, + key: { actor_type: "api_key", actor_id: "key_aXwpxqBkqtYwtBtmsGbR41", actor_name: "CI deploy key" }, + agent: { actor_type: "agent", actor_id: "actor_4CzLmR1pTxWvYbNhQd82Kf", actor_name: "Claude Code" }, + system: { actor_type: "system", actor_id: null, actor_name: null }, +} as const + +const entry = (wire: Wire): V2AuditLogEntry => { + const occurred = new Date(AUDIT_NOW - wire.minutesAgo * 60_000).toISOString() + return decode({ + id: encodePublicId(PublicIdPrefixes.auditLogEntry, uuid(wire.n)), + object: "audit_log_entry", + action: wire.action, + outcome: wire.denied === undefined ? "allowed" : "denied", + denial_reason: wire.denied ?? null, + ...ACTORS[wire.actor], + actor_avatar_url: null, + affected_user: wire.affected ?? null, + source: wire.source, + resource_type: wire.resource?.type ?? null, + resource_id: + wire.resource?.prefix === undefined + ? null + : encodePublicId(wire.resource.prefix, uuid(wire.n + 100)), + changes: wire.changes ?? null, + metadata: wire.metadata ?? null, + request_id: wire.source === "system" ? null : `8f2c1a9d4b7e${Str.padStart(4, "0")(String(wire.n))}`, + origin_ip: wire.source === "system" ? null : (wire.ip ?? "203.0.113.7"), + origin_country: wire.source === "system" ? null : (wire.country ?? "DE"), + occurred_at: occurred, + recorded_at: occurred, + }) +} + +export const AUDIT_ENTRIES: ReadonlyArray = Arr.map( + [ + { + n: 1, + minutesAgo: 3, + action: "telemetry.sql_executed", + actor: "agent", + source: "mcp", + denied: "Only SELECT statements may run", + metadata: { + sql: "DELETE FROM traces WHERE ServiceName = 'checkout'", + context: "mcp.run_sql", + start_time: "2026-11-09 14:00:00", + end_time: "2026-11-09 15:00:00", + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 2, + minutesAgo: 6, + action: "mcp_tool.called", + actor: "agent", + source: "mcp", + metadata: { + tool: "search_traces", + surface: "mcp", + is_error: false, + params: '{"service":"checkout","status":"Error","limit":20}', + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 3, + minutesAgo: 9, + action: "dashboard.updated", + actor: "ada", + source: "dashboard", + resource: { type: "dashboard", prefix: PublicIdPrefixes.dashboard }, + changes: { + fields: ["name", "tags"], + before: { name: "Checkout", tags: ["prod"] }, + after: { name: "Checkout overview", tags: ["prod", "payments"] }, + }, + }, + { + n: 4, + minutesAgo: 14, + action: "telemetry.read", + actor: "ada", + source: "dashboard", + metadata: { + endpoint: "queryEngine.execute", + method: "POST", + path: "/api/query-engine/execute", + status: 200, + }, + }, + { + n: 5, + minutesAgo: 31, + action: "api_key.created", + actor: "grace", + source: "dashboard", + resource: { type: "api_key", prefix: PublicIdPrefixes.apiKey }, + metadata: { name: "CI deploy key", scopes: ["telemetry:write", "dashboards:read"] }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 6, + minutesAgo: 47, + action: "api.request", + actor: "key", + source: "api", + denied: "Missing scope: alerts:write", + metadata: { method: "POST", path: "/v2/alert_rules" }, + ip: "203.0.113.90", + country: "NL", + }, + { + n: 7, + minutesAgo: 62, + action: "member.role_changed", + actor: "grace", + source: "dashboard", + affected: LINUS, + changes: { fields: ["role"], before: { role: "org:member" }, after: { role: "org:admin" } }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 8, + minutesAgo: 95, + action: "alert_rule.created", + actor: "linus", + source: "dashboard", + resource: { type: "alert_rule", prefix: PublicIdPrefixes.alertRule }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 9, + minutesAgo: 140, + action: "session_replay.read", + actor: "grace", + source: "dashboard", + metadata: { + endpoint: "sessionReplays.retrieve", + method: "GET", + path: "/v2/session_replays/rep_1", + status: 200, + }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 10, + minutesAgo: 60 * 20, + action: "error_issue.regression", + actor: "system", + source: "system", + resource: { type: "error_issue", prefix: PublicIdPrefixes.errorIssue }, + }, + { + n: 11, + minutesAgo: 60 * 22, + action: "dashboard_share.created", + actor: "linus", + source: "dashboard", + resource: { type: "dashboard_share", prefix: PublicIdPrefixes.dashboardShare }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 12, + minutesAgo: 60 * 26, + action: "source.rolled", + actor: "ada", + source: "dashboard", + resource: { type: "source", prefix: PublicIdPrefixes.source }, + metadata: { platform: "opentelemetry" }, + }, + { + n: 13, + minutesAgo: 60 * 50, + action: "ingest_key.rolled", + actor: "ada", + source: "dashboard", + metadata: { key: "private" }, + }, + { + n: 14, + minutesAgo: 60 * 75, + action: "mcp_tool.called", + actor: "agent", + source: "chat_platform", + metadata: { + tool: "list_errors", + surface: "chat", + is_error: true, + params: '{"service":"payments"}', + connector: "slack", + workspace_id: "T024BE7LD", + external_user_id: "U012AB3CD", + display_name: "Grace Hopper", + }, + ip: "198.51.100.200", + country: "US", + }, + ] satisfies ReadonlyArray, + entry, +) diff --git a/apps/web/src/lab/audit-log-lab.ts b/apps/web/src/lab/audit-log-lab.ts new file mode 100644 index 0000000000..5a70c0c638 --- /dev/null +++ b/apps/web/src/lab/audit-log-lab.ts @@ -0,0 +1,181 @@ +import type { V2AuditLogEntry } from "@maple/domain/http/v2" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Effect, Option, Record as Rec, Result, Schema, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as AuditModel from "../page/settings/audit-log/model" +import * as AuditUpdate from "../page/settings/audit-log/update" +import * as AuditView from "../page/settings/audit-log/view" +import type { Resources } from "../platform/resources" +import { SettingsSearch } from "../route/search" +import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "./audit-log-fixture" +import { keepLocal } from "./local-commands" + +// `/lab/audit-log`: the audit log tab over fixtures. Filters, the kind switch and the lookup all +// work: a navigation re-reads the fixtures through the page's own request. `?state=` picks a start. + +export const LabState = Schema.Literals([ + "changes", + "access", + "all", + "filtered", + "expanded", + "lookup-unknown", + "empty", + "error", + "loading", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "changes", + ), +})) + +type Search = typeof SettingsSearch.Type + +export const Model = Schema.Struct({ state: LabState, search: SettingsSearch, audit: AuditModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotAuditMessage: { message: AuditModel.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type AuditCommand = Command.Command + +const TIME_ZONE = "Europe/Berlin" +const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] +const isAccess = (action: string) => Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The fixtures the API would answer `request` with. */ +const matching = (request: AuditModel.PageRequest): ReadonlyArray => + Arr.filter( + AUDIT_ENTRIES, + (entry) => + (request.kind !== "changes" || !isAccess(entry.action) || entry.outcome === "denied") && + (request.kind !== "access" || isAccess(entry.action)) && + (request.actorType === undefined || entry.actor_type === request.actorType) && + (request.actorId === undefined || entry.actor_id === request.actorId) && + (request.outcome === undefined || entry.outcome === request.outcome) && + (request.resourceType === undefined || entry.resource_type === request.resourceType) && + (request.action === undefined || entry.action === request.action) && + (request.resourceId === undefined || entry.resource_id === request.resourceId) && + (request.affectedUserId === undefined || entry.affected_user === request.affectedUserId) && + (request.requestId === undefined || entry.request_id === request.requestId), + ) + +const searchFor = (state: LabState): Search => { + switch (state) { + case "access": + return { tab: "audit-log", kind: "access" } + case "all": + return { tab: "audit-log", kind: "all" } + case "filtered": + return { tab: "audit-log", kind: "all", actor: AUDIT_MEMBERS[1].value, resource_type: "api_key" } + default: + return { tab: "audit-log" } + } +} + +const decodeSearch = Schema.decodeUnknownOption(SettingsSearch) + +/** The search a page navigation lands on, read off its URL. */ +const searchOfUrl = (url: string): Search => + Option.getOrElse( + decodeSearch(Rec.fromEntries([...new URL(url, "https://lab.local").searchParams.entries()])), + (): Search => ({ tab: "audit-log" }), + ) + +/** Answers a page read the step asked for from the fixtures, as the API would. */ +const answer = (state: LabState, model: AuditModel.Model, commands: ReadonlyArray) => { + if (state === "loading" || !Arr.some(commands, (command) => command.name === "FetchAuditLogPage")) + return model + const result = + state === "error" + ? Result.fail(toLoadFailure("The warehouse did not answer in time")) + : Result.succeed({ + data: state === "empty" ? [] : matching(model.request), + has_more: false, + next_cursor: null, + }) + return AuditUpdate.update( + model, + AuditModel.Message.CompletedFetchPage({ request: model.request, resolvedSince: null, result }), + {}, + ).model +} + +/** The kit's own commands and the custom range's clock read; every API read is answered above. */ +const local = (commands: ReadonlyArray | undefined) => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === "ResolveAuditCustomToday"), +] + +const lift = (commands: ReadonlyArray) => + Command.mapMessages(commands, (message) => Message.GotAuditMessage({ message })) + +export const init = (flags: Flags): Return => { + const search = searchFor(flags.state) + const entered = AuditUpdate.init(search) + const withMembers = AuditUpdate.update( + answer(flags.state, entered.model, entered.commands ?? []), + AuditModel.Message.CompletedFetchMembers({ members: AUDIT_MEMBERS }), + search, + ).model + const extra: ReadonlyArray = + flags.state === "expanded" + ? [AuditModel.Message.ToggledEntry({ id: AUDIT_ENTRIES[2]?.id ?? "" })] + : flags.state === "lookup-unknown" + ? [ + AuditModel.Message.ChangedLookup({ value: "checkout" }), + AuditModel.Message.SubmittedLookup(), + ] + : [] + const audit = Arr.reduce( + extra, + withMembers, + (model, message) => AuditUpdate.update(model, message, search).model, + ) + return { model: { state: flags.state, search, audit } } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotAuditMessage: ({ message: child }) => { + const step = AuditUpdate.update(model.audit, child, model.search) + const out = step.outMessage + if (out?._tag !== "RequestedNavigation") + return { + model: { ...model, audit: answer(model.state, step.model, step.commands ?? []) }, + commands: lift(local(step.commands)), + } + const search = searchOfUrl(out.url) + const moved = AuditUpdate.searchChanged(step.model, search) + return { + model: { ...model, search, audit: answer(model.state, moved.model, moved.commands ?? []) }, + commands: lift([...local(step.commands), ...local(moved.commands)]), + } + }, + }) + +export const breadcrumbs = [{ label: "Lab", href: "/lab" }, { label: "Audit log" }] as const + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("mx-auto w-full max-w-5xl p-6")], + [ + h.submodel({ + slotId: "lab-audit-log", + model: model.audit, + view: AuditView.view, + viewInputs: { search: model.search, nowMs: AUDIT_NOW, timeZone: TIME_ZONE, apiKeys: [] }, + toParentMessage: (message: AuditModel.Message) => Message.GotAuditMessage({ message }), + }), + ], + ) diff --git a/apps/web/src/lab/entries/audit-log.ts b/apps/web/src/lab/entries/audit-log.ts new file mode 100644 index 0000000000..2b2f77344a --- /dev/null +++ b/apps/web/src/lab/entries/audit-log.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../audit-log-lab" + +// `/lab/audit-log`: the settings audit log tab over fixtures (`?state=`). + +export const lab = defineShellLab("Audit log", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/audit-log", + breadcrumbs, +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index fdfb884287..8c32f300e5 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -69,6 +69,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/audit-log", + title: "Audit log", + description: + "The settings audit log over fixtures: changes, data access, filters, an expanded entry, an unknown lookup, empty, failing and loading (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/slos", title: "SLOs", diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 1c27425258..84512e9897 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -2,6 +2,7 @@ import { Result, Array as Arr } from "effect" import { expect as expectView, given, role, scene, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../contract" +import * as CustomRange from "../../kit/custom-range" import { Message } from "./model" import { init, revisit, searchChanged, update } from "./update" import { view } from "./view" @@ -22,7 +23,11 @@ describe("audit log tab", () => { const moved = searchChanged(entered, { outcome: "denied" }).model const stale = update( moved, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + Message.CompletedFetchPage({ + request: init({}).model.request, + resolvedSince: null, + result: Result.succeed(page), + }), { outcome: "denied" }, ) expect(stale.model.view).toBeNull() @@ -88,9 +93,14 @@ describe("audit log tab", () => { }) test("Load more pins the ceiling to the newest entry and asks for the next cursor", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model expect(update(loaded, Message.ClickedLoadMore(), {}).commands?.[0]).toMatchObject({ @@ -99,9 +109,14 @@ describe("audit log tab", () => { }) test("a revisit reads the first page quietly and holds Load more until it lands", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model const back = revisit(loaded, {}) @@ -113,4 +128,108 @@ describe("audit log tab", () => { test("a revisit while the same first page is in flight does not read it twice", () => { expect(revisit(init({}).model, {}).commands ?? []).toEqual([]) }) + + test("the list opens on changes, and the kind switch writes access or all to the URL", () => { + const entered = init({ tab: "audit-log" }) + expect(entered.commands?.[0]).toMatchObject({ args: { request: { kind: "changes" } } }) + expect(init({ kind: "all" }).model.request.kind).toBeUndefined() + const step = update(entered.model, Message.ClickedKind({ value: "access" }), { tab: "audit-log" }) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&kind=access", + isReplace: true, + }), + ) + const back = update(entered.model, Message.ClickedKind({ value: "changes" }), { + tab: "audit-log", + kind: "access", + }) + expect(back.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ url: "/settings?tab=audit-log", isReplace: true }), + ) + }) + + test("a pasted id becomes the filter it stands for; anything else says what the lookup takes", () => { + const search = { tab: "audit-log" } as const + const typed = update( + init(search).model, + Message.ChangedLookup({ value: " key_aXwpxqBkqtYwtBtmsGbR41 " }), + search, + ) + expect(update(typed.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41", + isReplace: true, + }), + ) + const ray = update( + init(search).model, + Message.ChangedLookup({ value: "8f2c1a9d4b7e3f60-FRA" }), + search, + ) + expect(update(ray.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&request=8f2c1a9d4b7e3f60-FRA", + isReplace: true, + }), + ) + const junk = update(init(search).model, Message.ChangedLookup({ value: "hello world" }), search) + const refused = update(junk.model, Message.SubmittedLookup(), search) + expect(refused.outMessage).toBeUndefined() + scene( + { + update: (model, message) => update(model, message, search), + view: withViewInputs(view, { search, nowMs: 0, timeZone: "UTC", apiKeys: [] })(), + }, + given(refused.model), + expectView(role("status")).toHaveText(/Not something the log filters by/), + ) + }) + + test("removing the resource type drops the action that belongs to it", () => { + const search = { + tab: "audit-log", + resource_type: "dashboard", + action: "dashboard.updated", + outcome: "denied", + } as const + expect( + update(init(search).model, Message.RemovedFilter({ key: "resourceType" }), search).outMessage, + ).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&outcome=denied", + isReplace: true, + }), + ) + }) + + test("an applied custom range lands in the URL as UTC instants", () => { + const search = { tab: "audit-log" } as const + const opened = update( + init(search).model, + Message.ResolvedCustomToday({ + timeZone: "UTC", + today: { year: 2026, month: 11, day: 9 }, + lastDay: { year: 2026, month: 11, day: 9 }, + }), + search, + ).model + const start: ReturnType = { model: opened } + const picked = Arr.reduce( + [ + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 2 } }), + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 4 } }), + CustomRange.Message.ClickedApply(), + ], + start, + (step, message) => update(step.model, Message.GotCustomRangeMessage({ message }), search), + ) + expect(picked.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&since=2026-11-02T00%3A00%3A00.000Z&until=2026-11-04T23%3A59%3A00.000Z", + isReplace: true, + }), + ) + expect(picked.model.customRange).toBeNull() + }) }) diff --git a/apps/web/src/page/settings/audit-log/model.ts b/apps/web/src/page/settings/audit-log/model.ts index 17662b1bfd..83d3a89d37 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -6,6 +6,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as CopyButton from "@maple/views/ui/copy-button" import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" +import * as CustomRange from "../../kit/custom-range" // `AuditLogSection`. Filters live in the `/settings` search; the page passes them in and a change comes back // as a navigation. @@ -16,6 +17,13 @@ export const PageRequest = Schema.Struct({ actorType: Schema.optional(AuditActorType), actorId: Schema.optional(Schema.String), outcome: Schema.optional(AuditOutcome), + /** `changes` or `access`; absent lists both. */ + kind: Schema.optional(Schema.Literals(["changes", "access"])), + resourceType: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resourceId: Schema.optional(Schema.String), + affectedUserId: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), since: Schema.optional(Schema.String), until: Schema.optional(Schema.String), /** A rolling preset: part of the key, resolved to `since` when the page is fetched. */ @@ -60,7 +68,16 @@ export const Model = Schema.Struct({ expanded: Schema.Array(Schema.String), copies: Schema.Record(Schema.String, CopyButton.Model), personSelect: Select.Model, + resourceTypeSelect: Select.Model, + actionSelect: Select.Model, + affectedSelect: Select.Model, dateRange: Popover.Model, + /** The date popover's custom tab, open once the clock gave today. */ + customRange: Schema.NullOr(CustomRange.Model), + /** The lookup field's draft: a pasted id or action, applied on Enter. */ + lookup: Schema.String, + /** The last Enter matched no id shape or action. */ + isLookupUnknown: Schema.Boolean, }) export type Model = typeof Model.Type @@ -83,5 +100,21 @@ export const Message = defineMessageUnion({ GotDateRangeMessage: { message: Popover.Message }, ClickedRangePreset: { value: Schema.String }, ClickedClearFilters: {}, + ClickedKind: { value: Schema.String }, + GotResourceTypeSelectMessage: { message: Select.Message }, + GotActionSelectMessage: { message: Select.Message }, + GotAffectedSelectMessage: { message: Select.Message }, + ChangedLookup: { value: Schema.String }, + SubmittedLookup: {}, + ClearedLookup: {}, + /** A filter chip's x: the key of the filter it removes. */ + RemovedFilter: { key: Schema.String }, + ClickedCustomRange: { timeZone: Schema.String }, + ResolvedCustomToday: { + timeZone: Schema.String, + today: CustomRange.CalendarDate, + lastDay: CustomRange.CalendarDate, + }, + GotCustomRangeMessage: { message: CustomRange.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 05dd342dfe..9748a3fde0 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -6,10 +6,14 @@ import { AUDIT_RANGE_PRESETS, type AuditDateRange, type AuditLogFilters, + auditLogKindOf, auditLogSearchFrom, parseAuditLogSearch, + parseAuditLookup, } from "../../../components/settings/audit-log-filters" -import { Clock, Effect, Option, Result, Schema, Array as Arr } from "effect" +import { formatWarehouseDateTime, parseWarehouseDateTime } from "@maple/query-engine/datetime" +import * as CustomRange from "../../kit/custom-range" +import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" @@ -28,6 +32,8 @@ type Return = PageReturn type SettingsSearch = typeof SettingsSearchSchema.Type export const ANYONE = "anyone" +/** The "no filter" value of the resource type, action and affected user selects. */ +export const ANY = "any" const asActorType = Schema.decodeUnknownOption(AuditActorType) const asOutcome = Schema.decodeUnknownOption(AuditOutcome) @@ -35,7 +41,14 @@ const asOutcome = Schema.decodeUnknownOption(AuditOutcome) /** The filter input `AuditLogSection` derives from the URL; a rolling preset resolves in the Command. */ export const baseRequestOf = (filters: AuditLogFilters): PageRequest => { const range = filters.range + const kind = auditLogKindOf(filters) return { + ...(kind !== "all" && { kind }), + ...(filters.resourceType !== undefined && { resourceType: filters.resourceType }), + ...(filters.action !== undefined && { action: filters.action }), + ...(filters.resourceId !== undefined && { resourceId: filters.resourceId }), + ...(filters.affectedUserId !== undefined && { affectedUserId: filters.affectedUserId }), + ...(filters.requestId !== undefined && { requestId: filters.requestId }), ...(filters.actorType !== undefined && { actorType: filters.actorType }), ...(filters.actorId !== undefined && { actorId: filters.actorId }), ...(filters.outcome !== undefined && { outcome: filters.outcome }), @@ -53,14 +66,23 @@ const presetMsOf = (range: AuditDateRange): number | null => ) : null -const sameRequest = (a: PageRequest, b: PageRequest) => - a.cursor === b.cursor && - a.actorType === b.actorType && - a.actorId === b.actorId && - a.outcome === b.outcome && - a.since === b.since && - a.until === b.until && - a.preset === b.preset +const REQUEST_KEYS = [ + "cursor", + "kind", + "actorType", + "actorId", + "outcome", + "resourceType", + "action", + "resourceId", + "affectedUserId", + "requestId", + "since", + "until", + "preset", +] as const satisfies ReadonlyArray + +const sameRequest = (a: PageRequest, b: PageRequest) => Arr.every(REQUEST_KEYS, (key) => a[key] === b[key]) /** `auditLogPageAtom`: one page, newest first; a preset window resolves against the clock now. */ const FetchAuditPage = Command.define("FetchAuditLogPage", { @@ -87,6 +109,12 @@ const fetchPage = (request: PageRequest, presetSince: string | null) => query: { limit: AUDIT_LOG_PAGE_LIMIT, ...(request.cursor !== undefined && { cursor: request.cursor }), + kind: request.kind ?? "all", + ...(request.resourceType !== undefined && { resource_type: request.resourceType }), + ...(request.action !== undefined && { action: request.action }), + ...(request.resourceId !== undefined && { resource_id: request.resourceId }), + ...(request.affectedUserId !== undefined && { affected_user: request.affectedUserId }), + ...(request.requestId !== undefined && { request_id: request.requestId }), ...(request.actorType !== undefined && { actor_type: request.actorType }), ...(request.actorId !== undefined && { actor_id: request.actorId }), ...(request.outcome !== undefined && { outcome: request.outcome }), @@ -155,7 +183,13 @@ export const init = (search: SettingsSearch): Return => { expanded: [], copies: {}, personSelect: Select.init("audit-person", { value: ANYONE, selectedIndex: 0 }), + resourceTypeSelect: Select.init("audit-resource-type", { value: ANY, selectedIndex: 0 }), + actionSelect: Select.init("audit-action", { value: ANY, selectedIndex: 0 }), + affectedSelect: Select.init("audit-affected", { value: ANY, selectedIndex: 0 }), dateRange: Popover.init("audit-date-range", { align: "start" }), + customRange: null, + lookup: "", + isLookupUnknown: false, }, baseRequestOf(parseAuditLogSearch(search)), search, @@ -210,6 +244,68 @@ export const revisit = (model: Model, search: SettingsSearch): Return => { } } +/** "Custom range…" opens on the list's own window once the clock gave today. */ +const ResolveAuditCustomToday = Command.define("ResolveAuditCustomToday", { + args: { timeZone: Schema.String }, + messages: [Message.ResolvedCustomToday], + execute: ({ timeZone }) => + Effect.map(CustomRange.readToday(timeZone), (days) => + Message.ResolvedCustomToday({ timeZone, ...days }), + ), +}) + +const isoToWarehouse = (iso: string | undefined): string | null => + iso === undefined ? null : formatWarehouseDateTime(DateTime.toEpochMillis(DateTime.makeUnsafe(iso))) +const warehouseToIso = (value: string): string => new Date(parseWarehouseDateTime(value)).toISOString() + +type SelectField = "personSelect" | "resourceTypeSelect" | "actionSelect" | "affectedSelect" + +/** One select's step; `onChange` turns a picked value into the filters to navigate to. */ +const selectStep = ( + model: Model, + search: SettingsSearch, + field: SelectField, + child: Select.Message, + wrap: (message: Select.Message) => Message, + onChange: (value: string) => AuditLogFilters, +): Return => { + const step = Select.update(model[field], child) + const lifted: Return = { + model: { ...model, [field]: step.model }, + commands: Command.mapMessages(step.commands ?? [], wrap), + } + if (step.outMessage === undefined) return lifted + return { + ...lifted, + outMessage: navigateFilters(lifted.model, search, onChange(step.outMessage.value)).outMessage, + } +} + +const orAny = (value: string) => (value === ANY ? undefined : value) + +/** The filters without one of them: what a chip's x navigates to. */ +const withoutFilter = (filters: AuditLogFilters, key: string): AuditLogFilters => { + switch (key) { + case "range": + return { ...filters, range: { kind: "all" } } + case "resourceType": + // An action belongs to its resource type; it goes with it. + return { ...filters, resourceType: undefined, action: undefined } + case "actorId": + case "actorType": + case "outcome": + case "action": + case "resourceId": + case "affectedUserId": + case "requestId": + return { ...filters, [key]: undefined } + default: + return filters + } +} + +const liftCustom = (model: Model, customRange: CustomRange.Model | null): Model => ({ ...model, customRange }) + export const update = (model: Model, message: Message, search: SettingsSearch): Return => { const filters = parseAuditLogSearch(search) return Message.match(message, { @@ -273,25 +369,109 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), ClickedOutcome: ({ value }) => navigateFilters(model, search, { ...filters, outcome: Option.getOrUndefined(asOutcome(value)) }), - GotPersonSelectMessage: ({ message: child }) => { - const step = Select.update(model.personSelect, child) - const lifted: Return = { - model: { ...model, personSelect: step.model }, - commands: Command.mapMessages(step.commands ?? [], (inner) => - Message.GotPersonSelectMessage({ message: inner }), - ), - } - if (step.outMessage === undefined) return lifted - const actorId = step.outMessage.value === ANYONE ? undefined : step.outMessage.value + GotPersonSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "personSelect", + child, + (inner) => Message.GotPersonSelectMessage({ message: inner }), + (value) => ({ ...filters, actorId: value === ANYONE ? undefined : value }), + ), + GotResourceTypeSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "resourceTypeSelect", + child, + (inner) => Message.GotResourceTypeSelectMessage({ message: inner }), + (value) => ({ ...filters, resourceType: orAny(value), action: undefined }), + ), + GotActionSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "actionSelect", + child, + (inner) => Message.GotActionSelectMessage({ message: inner }), + (value) => ({ ...filters, action: orAny(value) }), + ), + GotAffectedSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "affectedSelect", + child, + (inner) => Message.GotAffectedSelectMessage({ message: inner }), + (value) => ({ ...filters, affectedUserId: orAny(value) }), + ), + ClickedKind: ({ value }) => + navigateFilters(model, search, { + ...filters, + kind: value === "access" || value === "all" ? value : undefined, + }), + ChangedLookup: ({ value }) => ({ model: { ...model, lookup: value, isLookupUnknown: false } }), + ClearedLookup: () => ({ model: { ...model, lookup: "", isLookupUnknown: false } }), + SubmittedLookup: () => + Option.match(parseAuditLookup(model.lookup), { + onNone: () => ({ + model: { ...model, isLookupUnknown: Str.isNonEmpty(Str.trim(model.lookup)) }, + }), + onSome: (lookup) => + navigateFilters({ ...model, lookup: "", isLookupUnknown: false }, search, { + ...filters, + [lookup.key]: lookup.value, + // A looked-up action names its resource type; a stale one would hide it. + ...(lookup.key === "action" && { resourceType: undefined }), + }), + }), + RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), + ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), + ResolvedCustomToday: ({ timeZone, today, lastDay }) => { + const range = filters.range return { - ...lifted, - outMessage: navigateFilters(lifted.model, search, { ...filters, actorId }).outMessage, + model: liftCustom( + model, + CustomRange.init({ + startTime: range.kind === "custom" ? isoToWarehouse(range.since) : null, + endTime: range.kind === "custom" ? isoToWarehouse(range.until) : null, + timeZone, + today, + lastDay, + }), + ), } }, + GotCustomRangeMessage: ({ message: child }) => { + if (model.customRange === null) return { model } + const step = CustomRange.update(model.customRange, child) + const out = step.outMessage + if (out === undefined) return { model: liftCustom(model, step.model) } + return CustomRange.OutMessage.match(out, { + CancelledRange: () => ({ model: liftCustom(model, null) }), + AppliedRange: ({ startTime, endTime }) => { + const closed = Popover.close(model.dateRange) + return { + ...navigateFilters({ ...liftCustom(model, null), dateRange: closed.model }, search, { + ...filters, + range: { + kind: "custom", + since: warehouseToIso(startTime), + until: warehouseToIso(endTime), + }, + }), + commands: Command.mapMessages(closed.commands ?? [], (inner) => + Message.GotDateRangeMessage({ message: inner }), + ), + } + }, + }) + }, GotDateRangeMessage: ({ message: child }) => { const step = Popover.update(model.dateRange, child) return { - model: { ...model, dateRange: step.model }, + // Closing the popover drops a half-picked custom range. + model: { ...model, dateRange: step.model, ...(!step.model.isOpen && { customRange: null }) }, commands: Command.mapMessages(step.commands ?? [], (inner) => Message.GotDateRangeMessage({ message: inner }), ), @@ -313,6 +493,7 @@ export const update = (model: Model, message: Message, search: SettingsSearch): ), } }, - ClickedClearFilters: () => navigateFilters(model, search, { range: { kind: "all" } }), + ClickedClearFilters: () => + navigateFilters(model, search, { range: { kind: "all" }, kind: filters.kind }), }) } diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 95d5e464fc..27d6b39798 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -1,4 +1,4 @@ -import { auditActionLabel } from "@maple/domain/audit-actions" +import { auditActionLabel, AuditResourceLabels, AuditResources } from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes, type V2ApiKey, type V2AuditLogEntry } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" @@ -8,9 +8,15 @@ import { cn } from "@maple/ui/lib/utils" import { AUDIT_RANGE_PRESETS, type AuditDateRange, + type AuditLogFilters, + auditLogKindOf, hasAuditLogFilters, parseAuditLogSearch, } from "../../../components/settings/audit-log-filters" +import { compactSearchInput } from "@maple/views/ui/compact-search-input" +import { removableChip } from "@maple/views/ui/removable-chip" +import type { Message as SelectMessage } from "@maple/views/ui/select" +import * as CustomRangeView from "../../kit/custom-range-view" import { auditResourceDisplay } from "../../../components/settings/audit-log-resource" import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" import { Submodel } from "foldkit" @@ -35,11 +41,11 @@ import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { settingsSection } from "../kit/section" import { Message, type Model } from "./model" -import { ANYONE } from "./update" +import { ANY, ANYONE } from "./update" import { auditDetail, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" -import { Array as Arr, String as Str, Option } from "effect" +import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder @@ -58,6 +64,11 @@ const ACTOR_FILTERS = [ { value: "agent", label: "Agents" }, { value: "system", label: "System" }, ] as const +const KIND_OPTIONS = [ + { value: "changes", label: "Changes" }, + { value: "access", label: "Data access" }, + { value: "all", label: "All" }, +] as const const OUTCOME_FILTERS = [ { value: "all", label: "All" }, { value: "allowed", label: "Allowed" }, @@ -122,6 +133,9 @@ const rangeLabel = (range: AuditDateRange, timeZone: string): string => { // TOOLBAR +const RANGE_OPTION_CLASS = + "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken" + const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): Html => { const label = rangeLabel(range, timeZone) const activeKey = range.kind === "all" ? "all" : range.kind === "preset" ? range.preset : null @@ -129,13 +143,18 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): { key: "all", label: "All time" }, ...Arr.map(AUDIT_RANGE_PRESETS, (preset) => ({ key: preset.value, label: preset.label })), ] + const optionClass = (isActive: boolean) => + cn( + RANGE_OPTION_CLASS, + isActive ? typographyClass({ tone: "foreground", weight: "medium" }) : "text-foreground-soft", + ) return h.submodel({ slotId: "audit-date-range", model: model.dateRange, view: Popover.view, toParentMessage: (message) => Message.GotDateRangeMessage({ message }), viewInputs: { - className: "w-48 p-0", + className: model.customRange === null ? "w-48 p-0" : "w-auto p-0", toView: ({ trigger, withTrigger }) => h.div( [h.Class("contents")], @@ -153,45 +172,109 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): ), ), ), - // TODO(settings-audit): "Custom range…" opens `CustomRangePicker`, not ported (shared with the - // time-range picker's custom tab); it is drawn without its picker. toContent: () => [ - vstack(h, { className: "py-1.5" }, [ - ...Arr.map(options, (option) => - pressable( - h, - { - onClick: Message.ClickedRangePreset({ value: option.key }), - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken", - option.key === activeKey - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-foreground-soft", + model.customRange !== null + ? h.submodel({ + slotId: "audit-custom-range", + model: model.customRange, + view: CustomRangeView.view, + viewInputs: {}, + toParentMessage: (message) => Message.GotCustomRangeMessage({ message }), + }) + : vstack(h, { className: "py-1.5" }, [ + ...Arr.map(options, (option) => + pressable( + h, + { + onClick: Message.ClickedRangePreset({ value: option.key }), + className: optionClass(option.key === activeKey), + attributes: [h.Key(option.key)], + }, + [option.label], ), - attributes: [h.Key(option.key)], - }, - [option.label], - ), - ), - separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), - pressable( - h, - { - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - range.kind === "custom" - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-muted-foreground", ), - }, - ["Custom range…"], - ), - ]), + separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), + pressable( + h, + { + onClick: Message.ClickedCustomRange({ timeZone }), + className: optionClass(range.kind === "custom"), + }, + ["Custom range…"], + ), + ]), ], }, }) } +/** A filter select with an "any" first item and the picked label shown in the trigger. */ +const filterSelect = ( + h: H, + options: Readonly<{ + slotId: string + model: Model["resourceTypeSelect"] + value: string | undefined + anyLabel: string + ariaLabel: string + items: ReadonlyArray + toMessage: (message: SelectMessage) => Message + }>, +): Html => { + const label = + options.value === undefined + ? options.anyLabel + : Option.match( + Arr.findFirst(options.items, (item) => item.value === options.value), + { onNone: () => options.value ?? options.anyLabel, onSome: (item) => item.label }, + ) + return h.submodel({ + slotId: options.slotId, + model: { ...options.model, value: options.value ?? ANY }, + view: SelectView.view, + toParentMessage: options.toMessage, + viewInputs: { + entries: [ + { _tag: "Item", item: { value: ANY, label: options.anyLabel } }, + ...Arr.map(options.items, (item): SelectView.SelectEntry => ({ _tag: "Item", item })), + ], + ariaLabel: options.ariaLabel, + size: "sm", + className: "w-auto min-w-36 max-w-64", + renderValue: () => [ + typography( + h, + { truncate: true, tone: options.value === undefined ? "muted" : "foreground" }, + [label], + ), + ], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +const byLabel = Order.mapInput(Order.String, (item: SelectView.SelectOption) => item.label) + +const RESOURCE_TYPE_ITEMS: ReadonlyArray = Arr.sort( + Arr.map(Rec.toEntries(AuditResourceLabels), ([value, label]) => ({ value, label })), + byLabel, +) + +const actionItems = (resourceType: string): ReadonlyArray => + Option.match( + Arr.findFirst(Rec.toEntries(AuditResources), ([key]) => key === resourceType), + { + onNone: () => [], + onSome: ([, resource]) => { + const verbs: ReadonlyArray = resource.verbs + return Arr.map(verbs, (verb) => { + const action = `${resourceType}.${verb}` + return { value: action, label: auditActionLabel(action) ?? action } + }) + }, + }, + ) + const personFilter = ( h: H, model: Model, @@ -437,14 +520,75 @@ const listBody = (h: H, model: Model, inputs: ViewInputs, isFiltered: boolean): ) } +/** Filters that have no control of their own, shown as removable chips (a looked-up id, an action). */ +const filterChips = (h: H, model: Model, filters: AuditLogFilters): ReadonlyArray => { + const memberName = (userId: string) => + Option.match( + Arr.findFirst(model.members, (member) => member.value === userId), + { onNone: () => userId, onSome: (member) => member.label }, + ) + const chips: ReadonlyArray<{ key: string; label: string } | null> = [ + filters.resourceId === undefined + ? null + : { key: "resourceId", label: `Resource ${filters.resourceId}` }, + filters.requestId === undefined ? null : { key: "requestId", label: `Request ${filters.requestId}` }, + // With a resource type picked, the action has its own select. + filters.action === undefined || filters.resourceType !== undefined + ? null + : { key: "action", label: auditActionLabel(filters.action) ?? filters.action }, + filters.affectedUserId === undefined || model.members.length > 0 + ? null + : { key: "affectedUserId", label: `Affected ${memberName(filters.affectedUserId)}` }, + ] + return Arr.flatMap(chips, (chip) => + chip === null + ? [] + : [ + removableChip(h, { + label: chip.label, + onRemove: Message.RemovedFilter({ key: chip.key }), + attributes: [h.Key(chip.key)], + }), + ], + ) +} + +const lookupField = (h: H, model: Model): ReadonlyArray => [ + compactSearchInput(h, { + value: model.lookup, + placeholder: "Paste an ID or action, then Enter", + ariaLabel: "Look up an ID or action", + onInput: (value) => Message.ChangedLookup({ value }), + onEnter: Message.SubmittedLookup(), + onClear: Message.ClearedLookup(), + clearLabel: "Clear lookup", + className: "h-8 w-full @lg:w-72", + }), + ...(model.isLookupUnknown + ? [ + typography(h, { size: "2xs", tone: "muted", attributes: [h.Role("status")] }, [ + "Not something the log filters by. Paste a user, API key, agent, resource or request ID, or an action like dashboard.updated.", + ]), + ] + : []), +] + +const KIND_DESCRIPTIONS = { + changes: "Changes and refused attempts. Data reads are under Data access.", + access: "Reads of telemetry and replays, MCP tool calls, SQL statements and API requests.", + all: "Every entry, changes and data access together.", +} as const + export const description = "Every change, refused attempt and data read from the dashboard, API, MCP and chat. Select an entry for its full record." export const view = Submodel.defineView((model, inputs, h) => { const filters = parseAuditLogSearch(inputs.search) + const kind = auditLogKindOf(filters) const isFailed = model.failure !== null const isWaiting = model.isWaiting && !model.isQuiet const view = model.view + const chips = filterChips(h, model, filters) return settingsSection( h, { @@ -454,10 +598,20 @@ export const view = Submodel.defineView((model, inpu framed: false, }, [ - vstack(h, { gap: "2" }, [ - hstack(h, { gap: "2", wrap: true }, [ - dateRange(h, model, filters.range, inputs.timeZone), - personFilter(h, model, filters.actorId, inputs.apiKeys), + vstack(h, { gap: "3", className: "@container" }, [ + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true, align: "center" }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Show", + value: kind, + onChange: (value) => Message.ClickedKind({ value }), + options: KIND_OPTIONS, + }), + typography(h, { size: "xs", tone: "muted", className: "hidden @2xl:block" }, [ + KIND_DESCRIPTIONS[kind], + ]), + ]), // The labelled Reload every page header uses, beside the range it re-reads. refreshButton(h, { label: "Reload", @@ -465,22 +619,67 @@ export const view = Submodel.defineView((model, inpu onRefresh: Message.ClickedRefresh(), }), ]), - hstack(h, { gap: "3", wrap: true }, [ - segmentedSelect(h, { - size: "sm", - ariaLabel: "Actor type", - value: filters.actorType ?? "all", - onChange: (value) => Message.ClickedActorType({ value }), - options: ACTOR_FILTERS, - }), - segmentedSelect(h, { - size: "sm", - ariaLabel: "Outcome", - value: filters.outcome ?? "all", - onChange: (value) => Message.ClickedOutcome({ value }), - options: OUTCOME_FILTERS, + hstack(h, { gap: "2", wrap: true, align: "center" }, [ + dateRange(h, model, filters.range, inputs.timeZone), + personFilter(h, model, filters.actorId, inputs.apiKeys), + filterSelect(h, { + slotId: "audit-resource-type", + model: model.resourceTypeSelect, + value: filters.resourceType, + anyLabel: "Any resource", + ariaLabel: "Resource type", + items: RESOURCE_TYPE_ITEMS, + toMessage: (message) => Message.GotResourceTypeSelectMessage({ message }), }), + ...(filters.resourceType === undefined + ? [] + : [ + filterSelect(h, { + slotId: "audit-action", + model: model.actionSelect, + value: filters.action, + anyLabel: "Any action", + ariaLabel: "Action", + items: actionItems(filters.resourceType), + toMessage: (message) => Message.GotActionSelectMessage({ message }), + }), + ]), + ...(model.members.length === 0 + ? [] + : [ + filterSelect(h, { + slotId: "audit-affected", + model: model.affectedSelect, + value: filters.affectedUserId, + anyLabel: "Any affected user", + ariaLabel: "Affected user", + items: model.members, + toMessage: (message) => Message.GotAffectedSelectMessage({ message }), + }), + ]), + ]), + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Actor type", + // Five options outgrow a phone; the group scrolls instead of the page. + className: "max-w-full overflow-x-auto", + value: filters.actorType ?? "all", + onChange: (value) => Message.ClickedActorType({ value }), + options: ACTOR_FILTERS, + }), + segmentedSelect(h, { + size: "sm", + ariaLabel: "Outcome", + value: filters.outcome ?? "all", + onChange: (value) => Message.ClickedOutcome({ value }), + options: OUTCOME_FILTERS, + }), + ]), + vstack(h, { gap: "1", className: "w-full @lg:w-auto" }, lookupField(h, model)), ]), + ...(chips.length === 0 ? [] : [hstack(h, { gap: "1.5", wrap: true }, chips)]), ]), panel( h, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index c676941169..2c63c17aef 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -318,6 +318,13 @@ export const SettingsSearch = Schema.Struct({ actor: Schema.optional(Schema.String), actor_type: Schema.optional(Schema.String), outcome: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + resource_type: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resource: Schema.optional(Schema.String), + affected: Schema.optional(Schema.String), + request: Schema.optional(Schema.String), + entry: Schema.optional(Schema.String), // Data sources deep links from `/integrations`: one source, or the connect form of a kind. source: Schema.optional(Schema.String), connect: Schema.optional(Schema.Literals(DATA_SOURCE_KINDS)), diff --git a/packages/backend/src/services/audit/AuditLogService.test.ts b/packages/backend/src/services/audit/AuditLogService.test.ts index 576d8727a0..0f9702014c 100644 --- a/packages/backend/src/services/audit/AuditLogService.test.ts +++ b/packages/backend/src/services/audit/AuditLogService.test.ts @@ -481,6 +481,65 @@ describe("AuditLogService.layerMemory", () => { }).pipe(Effect.provide(AuditLogService.layerMemory)), ) + it.effect("the changes kind keeps denied access; the access kind lists only access", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + resourceId: DASHBOARD_ID, + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.read", + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.sql_executed", + outcome: "denied", + denialReason: "write statement", + }) + const actions = (rows: ReadonlyArray<{ readonly action: string }>) => + Arr.map(rows, (row) => row.action) + expect(actions(yield* audit.list(ORG, { actionKind: "changes", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "dashboard.created", + ]) + expect(actions(yield* audit.list(ORG, { actionKind: "access", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "telemetry.read", + ]) + }).pipe(Effect.provide(AuditLogService.layerMemory)), + ) + + it.effect("compiles the changes kind to an exclusion that keeps denied entries", () => + Effect.gen(function* () { + const warehouse = recordingWarehouse() + yield* Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.list(ORG, { actionKind: "changes", limit: 5, offset: 0 }) + yield* audit.list(ORG, { actionKind: "access", limit: 5, offset: 0 }) + }).pipe( + Effect.provide( + Layer.effect(AuditLogService, AuditLogService.make).pipe(Layer.provide(warehouse.layer)), + ), + ) + const [changes, access] = warehouse.sql + expect(changes).toContain("NOT ((endsWith(audit_log.Action, '.read')") + expect(changes).toContain("OR audit_log.Outcome = 'denied')") + expect(access).toContain("endsWith(audit_log.Action, '.sql_executed')") + expect(access).not.toContain("NOT (endsWith(") + }), + ) + describe("recordHttpAudit attribution", () => { const tenant = new CurrentTenant.TenantSchema({ orgId: ORG, diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index 4b9fb69a0c..8afac5f199 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -93,6 +93,11 @@ export interface AuditLogListFilters { readonly untilMs?: number /** Leave out actions ending in any of these, before the limit; see {@link AUDIT_ACCESS_ACTION_SUFFIXES}. */ readonly excludeActionSuffixes?: ReadonlyArray + /** + * `changes`: no data access (see {@link AUDIT_ACCESS_ACTION_SUFFIXES}) unless it was denied. + * `access`: only data access. + */ + readonly actionKind?: "changes" | "access" readonly limit: number readonly offset: number } @@ -175,6 +180,29 @@ const neverFail = ), ) +const isAccessAction = (action: string) => + Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The suffix exclusion a listing applies: the caller's own, or the one `actionKind` implies. */ +const actionSuffixOpts = (filters: AuditLogListFilters) => { + if (filters.actionKind === "changes") + return { + excludeActionSuffixes: [ + ...(filters.excludeActionSuffixes ?? []), + ...AUDIT_ACCESS_ACTION_SUFFIXES, + ], + keepDeniedExcluded: true, + } + return { + ...(filters.excludeActionSuffixes !== undefined + ? { excludeActionSuffixes: filters.excludeActionSuffixes } + : undefined), + ...(filters.actionKind === "access" + ? { onlyActionSuffixes: AUDIT_ACCESS_ACTION_SUFFIXES } + : undefined), + } +} + /** Which optional filters bind, and the parameter values behind them. */ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { const since = filters.sinceMs === undefined ? undefined : DateTime.makeUnsafe(filters.sinceMs) @@ -193,9 +221,7 @@ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { requestId: filters.requestId !== undefined, since: since !== undefined, until: until !== undefined, - ...(filters.excludeActionSuffixes !== undefined - ? { excludeActionSuffixes: filters.excludeActionSuffixes } - : undefined), + ...actionSuffixOpts(filters), limit: filters.limit, offset: filters.offset, } @@ -376,7 +402,11 @@ export function makeMemoryAuditLog(): AuditLogServiceApi { (filters.untilMs === undefined || entry.occurredAt.getTime() <= filters.untilMs) && !Arr.some(filters.excludeActionSuffixes ?? [], (suffix) => Str.endsWith(suffix)(entry.action), - ), + ) && + (filters.actionKind !== "changes" || + !isAccessAction(entry.action) || + entry.outcome === "denied") && + (filters.actionKind !== "access" || isAccessAction(entry.action)), ) .sort((a, b) => { const byTime = b.occurredAt.getTime() - a.occurredAt.getTime() diff --git a/packages/domain/src/http/v2/audit-log.ts b/packages/domain/src/http/v2/audit-log.ts index 41d8d24449..3eec2afbbe 100644 --- a/packages/domain/src/http/v2/audit-log.ts +++ b/packages/domain/src/http/v2/audit-log.ts @@ -145,9 +145,22 @@ export const V2AuditLogEntry = Schema.Struct({ }) export type V2AuditLogEntry = Schema.Schema.Type +/** Which part of the log a listing covers: changes, data access (reads, tool calls, SQL), or both. */ +export const V2AuditLogKind = Schema.Literals(["all", "changes", "access"]).annotate({ + identifier: "AuditLogKind", + title: "Audit log kind", +}) +export type V2AuditLogKind = Schema.Schema.Type + /** Audit-log list query: standard pagination plus actor/action/resource/outcome/time filters. */ export const V2AuditLogQuery = Schema.Struct({ ...ListQuery.fields, + kind: Schema.optional( + V2AuditLogKind.annotate({ + description: + "`changes` leaves out data access (`*.read`, `*.called`, `*.sql_executed`, `*.request`) unless it was denied; `access` returns only data access; `all` (the default) returns both.", + }), + ), actor_type: Schema.optional( AuditActorType.annotate({ description: "Only return entries performed by this kind of actor.", diff --git a/packages/query-engine/src/ch/queries/audit-log.ts b/packages/query-engine/src/ch/queries/audit-log.ts index 69b305ade1..6321fadbeb 100644 --- a/packages/query-engine/src/ch/queries/audit-log.ts +++ b/packages/query-engine/src/ch/queries/audit-log.ts @@ -27,12 +27,19 @@ export interface AuditLogEntriesOpts { * limit. Fixed by the caller's code, never request input: they are inlined. */ readonly excludeActionSuffixes?: ReadonlyArray + /** Keep a denied entry even when its action is excluded: a refused read is still worth seeing. */ + readonly keepDeniedExcluded?: boolean + /** Only actions ending in one of these. Inlined, like `excludeActionSuffixes`. */ + readonly onlyActionSuffixes?: ReadonlyArray readonly limit: number readonly offset: number } const endsWith = defineCondFn<[CH.Expr, string]>("endsWith") +const endsWithAny = (action: CH.Expr, suffixes: ReadonlyArray) => + CH.or(...Arr.map(suffixes, (suffix) => endsWith(action, suffix))) + /** * One org's audit log, newest first, offset-paginated. Pinned to the managed * route: the table is written through `ingest` and does not exist in a BYO @@ -84,9 +91,12 @@ export function auditLogEntriesQuery(opts: AuditLogEntriesOpts) { CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), opts.excludeActionSuffixes !== undefined && opts.excludeActionSuffixes.length > 0 - ? CH.not( - CH.or(...Arr.map(opts.excludeActionSuffixes, (suffix) => endsWith($.Action, suffix))), - ) + ? opts.keepDeniedExcluded === true + ? CH.or(CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)), $.Outcome.eq("denied")) + : CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)) + : undefined, + opts.onlyActionSuffixes !== undefined && opts.onlyActionSuffixes.length > 0 + ? endsWithAny($.Action, opts.onlyActionSuffixes) : undefined, ]) .orderBy(["occurredAt", "desc"], ["id", "desc"]) diff --git a/packages/views/src/ui/compact-search-input.ts b/packages/views/src/ui/compact-search-input.ts index e9ffc05a02..c37021aaad 100644 --- a/packages/views/src/ui/compact-search-input.ts +++ b/packages/views/src/ui/compact-search-input.ts @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "./icon" @@ -11,6 +12,8 @@ export type CompactSearchInputOptions = Readonly<{ ariaLabel: string onInput: (value: string) => M onClear: M + /** Enter in the field: for a field that applies its value rather than filtering as you type. */ + onEnter?: M clearLabel?: string className?: string }> @@ -31,6 +34,15 @@ export const compactSearchInput = (h: HtmlBuilder, options: CompactSearchI h.Type("search"), h.Value(options.value), h.OnInput(options.onInput), + ...(options.onEnter === undefined + ? [] + : [ + h.OnKeyDownPreventDefault((key) => + key === "Enter" && options.onEnter !== undefined + ? Option.some(options.onEnter) + : Option.none(), + ), + ]), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), h.AriaLabel(options.ariaLabel), h.Class(