106 lines
3.9 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|