Files
template-app/apps/web/app/[org]/audit/page.tsx
T

106 lines
3.9 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The audit viewer (spec §9.4). It reads the platform's audit log for this deployment with the
// deployment token, which carries `audit:read`, and shows only the events of this organization:
// every write goes through one function that puts the organization's slug in the event's target,
// and `isOwnedBy` is that rule read backwards (src/server/audit.ts).
//
// Administrators only, which the layout beside this page asks of the tenancy resolver
// (src/server/guard.ts) before the response starts.
import { orgGet } from "@conusai-kit/tenancy"
import { ConusAIClient } from "@conusai-sdk/node-sdk"
import type { ReactElement } from "react"
import { webRuntime } from "../../../src/server/app"
import { isOwnedBy } from "../../../src/server/audit"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
interface AuditRow {
id: number
action: string
outcome: string
occurred_at: string
resource_type?: string | null
resource_id?: string | null
}
/** How many of this organization's events the viewer shows, and how far back it scans for them. */
const PAGE_LIMIT = 200
const MAX_PAGES = 25
/**
* The platform's log is deployment-wide and pages newest-first with no server-side filter, so the
* organization's rows are collected by walking pages until the viewer's own limit is filled or the
* log ends -- a filter over the first page alone would show nothing once 200 newer events of other
* organizations exist.
*/
async function organizationEvents(org: { slug: string; id: string }): Promise<AuditRow[]> {
const { env } = webRuntime()
const platform = new ConusAIClient({
baseUrl: env.deployment.CONUSAI_API_URL,
apiKey: env.deployment.CONUSAI_API_TOKEN,
})
const rows: AuditRow[] = []
let cursor: string | null | undefined
for (let page = 0; page < MAX_PAGES && rows.length < PAGE_LIMIT; page += 1) {
const { data } = await platform.readEvents({
query: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
throwOnError: true,
})
for (const row of (data?.items ?? []) as AuditRow[]) {
if (isOwnedBy(org, row.resource_id)) rows.push(row)
}
if (!data?.has_more || !data.next_cursor) break
cursor = data.next_cursor
}
return rows
}
export default async function Audit({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const { caller, i18n } = await pageContext()
// The layout already established membership; the id is what subscriber-written events carry.
const organization = await caller.call(orgGet, { org })
const { observe } = webRuntime()
const rows = await organizationEvents({ slug: org, id: organization.orgId }).catch((error: unknown) => {
// The platform being unreachable is not the viewer's failure to explain to a customer; it is
// ours to know about, so it is logged, and the page says there is nothing to show.
observe.log.warn(
`audit viewer: the platform's log could not be read: ${error instanceof Error ? error.name : "failed"}`,
)
return []
})
return (
<>
<h1>{i18n.message("app.audit")}</h1>
{rows.length === 0 ? (
<p>{i18n.message("app.auditEmpty")}</p>
) : (
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.when")}</th>
<th scope="col">{i18n.message("app.action")}</th>
<th scope="col">{i18n.message("app.outcome")}</th>
<th scope="col">{i18n.message("app.resource")}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{i18n.date(new Date(row.occurred_at))}</td>
<td>{row.action}</td>
<td>{row.outcome}</td>
<td>{row.resource_type ?? ""}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)
}