@pithy-sh/audit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +17 -0
- package/package.json +50 -0
- package/pithy.manifest.json +19 -0
- package/src/actions.ts +59 -0
- package/src/capability.ts +135 -0
- package/src/cli/emitFromCLI.ts +66 -0
- package/src/cli/resolveActor.ts +126 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/data/auditEvent.ts +116 -0
- package/src/data/tables.ts +25 -0
- package/src/error/errors.ts +66 -0
- package/src/http/guards.ts +94 -0
- package/src/http/responses.ts +101 -0
- package/src/http/routes.ts +206 -0
- package/src/http/schemas.ts +111 -0
- package/src/http/views.ts +84 -0
- package/src/index.ts +31 -0
- package/src/migrations/0001_init.ts +117 -0
- package/src/query.ts +197 -0
- package/src/recorder.ts +155 -0
- package/src/seeds/example.ts +189 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AuditAction,
|
|
6
|
+
AuditActorType,
|
|
7
|
+
AuditMetadata,
|
|
8
|
+
AuditOutcome,
|
|
9
|
+
AuditSeverity,
|
|
10
|
+
} from "@pithy-sh/core/src/audit/auditEvent";
|
|
11
|
+
import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `pithy_audit_events` table — one row per recorded security-relevant action. One Zod object is
|
|
16
|
+
* the whole table definition: `z.output` is the app shape (a `Date` for `occurredAt`, a decoded
|
|
17
|
+
* object for `metadata`), `z.input` is the SQLite row shape (ms-epoch numbers, JSON strings), and
|
|
18
|
+
* every JS↔SQLite conversion goes through a codec. The taxonomy enums and the `action` validator are
|
|
19
|
+
* core's (`@pithy-sh/core/src/audit/auditEvent`) — the seam every emitter writes to; this table is
|
|
20
|
+
* how `@pithy-sh/audit` persists it.
|
|
21
|
+
*
|
|
22
|
+
* The `pithy_audit_` prefix (CamelCasePlugin snake-cases `pithyAuditEvents` → `pithy_audit_events`)
|
|
23
|
+
* keeps the table from clashing with an adopter's own (principle 1). `id` is an autoincrement
|
|
24
|
+
* surrogate giving natural event ordering; it is internal and never exposed over HTTP.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The `metadata` column codec — the JSON ↔ object conversion, Zod-validated against {@link AuditMetadata}
|
|
28
|
+
* on both sides. Exported so the recorder encodes the column through the same codec it is read back
|
|
29
|
+
* with, rather than hand-rolling `JSON.stringify` (CLAUDE.md §Data layer).
|
|
30
|
+
*/
|
|
31
|
+
export const AuditMetadataColumn = sqliteJson(AuditMetadata);
|
|
32
|
+
|
|
33
|
+
export const AuditEventRow = z
|
|
34
|
+
.object({
|
|
35
|
+
id: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.describe(
|
|
39
|
+
"Autoincrement surrogate primary key. Monotonic, so it gives natural event ordering; internal, never exposed.",
|
|
40
|
+
),
|
|
41
|
+
eventId: z
|
|
42
|
+
.string()
|
|
43
|
+
.describe(
|
|
44
|
+
"Client-generated unique idempotency key (a UUID), set by the recorder before the insert and held stable across `withD1Retry` attempts. The unique index turns a retry after a post-commit transport hiccup into a no-op (the guard fires) instead of a duplicate row.",
|
|
45
|
+
),
|
|
46
|
+
occurredAt: SQLiteDate.describe(
|
|
47
|
+
"When the action occurred. Ms-epoch in SQLite, a `Date` in app code; indexed for time-range queries.",
|
|
48
|
+
),
|
|
49
|
+
action: AuditAction.describe(
|
|
50
|
+
"What happened, as a namespaced `domain/reason` action code; indexed for per-action queries.",
|
|
51
|
+
),
|
|
52
|
+
outcome: AuditOutcome.describe("Whether the action succeeded, failed, or was denied."),
|
|
53
|
+
severity: AuditSeverity.describe(
|
|
54
|
+
"How serious the event is, orthogonal to outcome; drives dashboard filtering and alerting.",
|
|
55
|
+
),
|
|
56
|
+
actorType: AuditActorType.describe("The kind of principal that acted."),
|
|
57
|
+
actorId: z
|
|
58
|
+
.string()
|
|
59
|
+
.nullable()
|
|
60
|
+
.describe(
|
|
61
|
+
"The acting principal's stable id (user id, service/token name); null for `system`/`anonymous`. Indexed.",
|
|
62
|
+
),
|
|
63
|
+
sessionId: z
|
|
64
|
+
.string()
|
|
65
|
+
.nullable()
|
|
66
|
+
.describe("The session this action belongs to, tying a chain of actions together; null when none."),
|
|
67
|
+
resourceType: z
|
|
68
|
+
.string()
|
|
69
|
+
.nullable()
|
|
70
|
+
.describe("The type of resource the action targeted (e.g. `user`, `secret`); null when not resource-scoped."),
|
|
71
|
+
resourceId: z
|
|
72
|
+
.string()
|
|
73
|
+
.nullable()
|
|
74
|
+
.describe("The id of the resource the action targeted; null when not resource-scoped."),
|
|
75
|
+
ip: z.string().nullable().describe("The client IP the request came from, for correlation; null when unknown."),
|
|
76
|
+
userAgent: z.string().nullable().describe("The client user-agent string, for correlation; null when unknown."),
|
|
77
|
+
requestId: z
|
|
78
|
+
.string()
|
|
79
|
+
.nullable()
|
|
80
|
+
.describe("The request correlation id, tying this event to one request/trace; null when unknown."),
|
|
81
|
+
metadata: AuditMetadataColumn.nullable().describe(
|
|
82
|
+
"Capability-specific structured detail as a JSON column, Zod-validated on write and read; null when there is none.",
|
|
83
|
+
),
|
|
84
|
+
project: z
|
|
85
|
+
.string()
|
|
86
|
+
.nullable()
|
|
87
|
+
.describe(
|
|
88
|
+
"The project this event was recorded in, stamped by the recorder from the Worker's `PROJECT` var — never by the emitter, so it cannot be forged or omitted. Null on rows written before this column existed, and on a Worker carrying no `PROJECT` var.",
|
|
89
|
+
),
|
|
90
|
+
environment: z
|
|
91
|
+
.string()
|
|
92
|
+
.nullable()
|
|
93
|
+
.describe(
|
|
94
|
+
"The environment the recording Worker serves (`dev` | `staging` | `prod`), stamped by the recorder from the `ENVIRONMENT` var. Null on rows written before this column existed. Recorded on the row rather than inferred from the database, because an exported or aggregated trail no longer knows which database it came from.",
|
|
95
|
+
),
|
|
96
|
+
worker: z
|
|
97
|
+
.string()
|
|
98
|
+
.nullable()
|
|
99
|
+
.describe(
|
|
100
|
+
"The `apps/<name>` directory name of the Worker that recorded the event, stamped from the `WORKER` var; null for a CLI-originated action, which came from no Worker. Two Workers in one project share a database when they declare the same binding, so this is the only column that separates their events.",
|
|
101
|
+
),
|
|
102
|
+
version: z
|
|
103
|
+
.string()
|
|
104
|
+
.nullable()
|
|
105
|
+
.describe(
|
|
106
|
+
'The Cloudflare version id of the build that recorded the event, stamped by the recorder from the `CF_VERSION_METADATA` binding. Null for a CLI-originated action, for a Worker that does not declare the binding, and on rows written before this column existed. It is what turns "this was revoked" into "this was revoked, by this subject, against this exact build".',
|
|
107
|
+
),
|
|
108
|
+
tenant: z
|
|
109
|
+
.string()
|
|
110
|
+
.nullable()
|
|
111
|
+
.describe(
|
|
112
|
+
"Whose action it was — the id of the tenant it was taken *for*, indexed with `occurredAt` for the (tenant, time) read this column exists to serve. **Supplied by the emitter, not stamped by the recorder** — the opposite of the four columns above it, and deliberately: `project`, `environment` and `worker` are properties of the writer, constant across every row a multi-tenant Worker writes, and no Worker var can know which customer an action belonged to. So this one is exactly as trustworthy as the call site that sets it, and it cannot be defaulted or verified here. Null means *not tenant-scoped* — a single-tenant app, a CLI-originated action, a fleet-wide operator action — and on rows written before this column existed. Never derived from a membership table afterwards: the tenant of an action is a fact at the time of the action, membership is a fact now.",
|
|
113
|
+
),
|
|
114
|
+
})
|
|
115
|
+
.describe("One audit event in `pithy_audit_events` — the durable, queryable record of a security-relevant action.");
|
|
116
|
+
export type AuditEventRow = z.output<typeof AuditEventRow>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import { AuditEventRow } from "./auditEvent";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The audit capability's table map: camelCase keys (CamelCasePlugin emits the snake_case
|
|
11
|
+
* `pithy_audit_` SQL). One source of truth, shared by the capability wiring (`capability.ts`), the
|
|
12
|
+
* recorder, and the query helper so all three type against the same schema.
|
|
13
|
+
*/
|
|
14
|
+
export const auditTables = {
|
|
15
|
+
pithyAuditEvents: AuditEventRow,
|
|
16
|
+
};
|
|
17
|
+
export type AuditTables = typeof auditTables;
|
|
18
|
+
|
|
19
|
+
/** The typed Kysely view over the audit table — what the recorder and query helper run against. */
|
|
20
|
+
export type AuditDatabase = Kysely<DatabaseSchema<AuditTables>>;
|
|
21
|
+
|
|
22
|
+
/** Build the audit Kysely over a D1 binding (or a REST `D1Database` from `@pithy-sh/cloudflare`). */
|
|
23
|
+
export function auditDatabase(d1: D1Database): AuditDatabase {
|
|
24
|
+
return createDatabase(d1, auditTables);
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Audit recorder throw sugar. The `audit/*` codes live in core's closed `KitErrorPayload` union
|
|
9
|
+
* (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
|
|
10
|
+
* package-local vehicles that set one of those members — the same pattern as core's `NotFoundError`
|
|
11
|
+
* and `@pithy-sh/cloudflare`'s error classes. Runtime code here builds one of these, never a plain
|
|
12
|
+
* `new Error`.
|
|
13
|
+
*
|
|
14
|
+
* Both are recorded **non-fatally**: the recorder constructs one to represent a failure it logs, but
|
|
15
|
+
* never throws it into the audited action. `detail` carries throw-site context for logs; it is never
|
|
16
|
+
* serialized to a client (the HTTP codec strips it).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
20
|
+
interface AuditErrorArgs {
|
|
21
|
+
/** Override the public, safe-to-expose message. */
|
|
22
|
+
message?: string;
|
|
23
|
+
/** A remediation hint (CLI action line). */
|
|
24
|
+
action?: string;
|
|
25
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
26
|
+
detail?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
29
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
30
|
+
*/
|
|
31
|
+
params?: MessageParams;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An audit event failed validation against the `AuditEvent` schema (bad action, outcome, or actor). */
|
|
35
|
+
export class AuditInvalidEventError extends PithyError {
|
|
36
|
+
constructor(args: AuditErrorArgs = {}, options?: { cause?: unknown }) {
|
|
37
|
+
super(
|
|
38
|
+
{
|
|
39
|
+
code: "audit/invalid_event",
|
|
40
|
+
status: 400,
|
|
41
|
+
message: args.message ?? "An audit event failed validation.",
|
|
42
|
+
action: args.action,
|
|
43
|
+
detail: args.detail,
|
|
44
|
+
params: args.params,
|
|
45
|
+
},
|
|
46
|
+
options,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Persisting an audit event to D1 failed after retries. Logged, never thrown to a client. */
|
|
52
|
+
export class AuditWriteFailedError extends PithyError {
|
|
53
|
+
constructor(args: AuditErrorArgs = {}, options?: { cause?: unknown }) {
|
|
54
|
+
super(
|
|
55
|
+
{
|
|
56
|
+
code: "audit/write_failed",
|
|
57
|
+
status: 500,
|
|
58
|
+
message: args.message ?? "Failed to persist an audit event.",
|
|
59
|
+
action: args.action,
|
|
60
|
+
detail: args.detail,
|
|
61
|
+
params: args.params,
|
|
62
|
+
},
|
|
63
|
+
options,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Audit's control-plane scopes, and the admin surface a manifest advertises.
|
|
9
|
+
*
|
|
10
|
+
* **Every route here is `control-plane`, every route here is a read, and there are no others.** The
|
|
11
|
+
* trail has no end-user surface — a user does not read the record of themselves being audited — so
|
|
12
|
+
* there is nothing for `requireAuth()` to gate, and putting it on these routes would deny every
|
|
13
|
+
* legitimate management call permanently: the seam deliberately leaves `c.var.auth` null for a
|
|
14
|
+
* control-plane caller, and no credential could ever satisfy it. The gate is imported from core
|
|
15
|
+
* rather than copied, because core is a hard dependency of every capability, so importing it cannot
|
|
16
|
+
* leave a deployment without one; with the seam uncomposed `requireControlPlane` raises
|
|
17
|
+
* `controlplane/not_connected` rather than passing.
|
|
18
|
+
*
|
|
19
|
+
* **Nothing here writes.** The trail is append-only by construction and stays that way: there is no
|
|
20
|
+
* delete route, no edit route, and no retention control on this surface. A management credential that
|
|
21
|
+
* could erase an audit row is a management credential that can erase the evidence of its own use.
|
|
22
|
+
*
|
|
23
|
+
* ## Two read scopes, because two reads disclose different things
|
|
24
|
+
*
|
|
25
|
+
* The tempting shape is one `audit:read`. It is wrong on the merits for the same reason a single
|
|
26
|
+
* `payments:admin` flag was: it makes the safe operation and the dangerous one the same grant.
|
|
27
|
+
*
|
|
28
|
+
* A **page of the trail** answers who did what, when, and whether it worked. That is the "recent
|
|
29
|
+
* activity" pane, it is what an operator looks at all day, and its projection carries no network
|
|
30
|
+
* identifier and no capability payload.
|
|
31
|
+
*
|
|
32
|
+
* **One event in full** additionally carries the client IP, the user-agent, and the capability's own
|
|
33
|
+
* `metadata` bag — which routinely holds the email address, the resource name, or the reason a
|
|
34
|
+
* capability recorded alongside its event. Bulk-harvesting those over a whole trail is a privacy
|
|
35
|
+
* incident, and requiring a second scope is what makes it a decision the adopter makes rather than
|
|
36
|
+
* one a listing credential comes with. Since the two are separate routes, a credential holding only
|
|
37
|
+
* the detail scope cannot enumerate the trail to find ids to read, and a credential holding only the
|
|
38
|
+
* list scope cannot resolve one. `scopeCovers` matches exactly — no prefix, no wildcard — so holding
|
|
39
|
+
* one confers nothing about the other.
|
|
40
|
+
*
|
|
41
|
+
* The names are constants rather than config: a configurable scope name is a way to misconfigure a
|
|
42
|
+
* default-denied gate into a differently-named one, and they are the join key with what
|
|
43
|
+
* `pithy dashboard connect` offers an adopter to grant.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Page the trail: who acted, what they did, when, against what, and whether it was allowed. The
|
|
48
|
+
* everyday read, and deliberately the one that carries no IP address and no capability metadata.
|
|
49
|
+
*/
|
|
50
|
+
export const AUDIT_TRAIL_READ_SCOPE: ControlPlaneScope = "audit:events:read";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read one event in full — the client IP, the user-agent, and the capability's `metadata` bag with it.
|
|
54
|
+
* The forensic read, and the more dangerous of the two: this is where the trail's personal data is.
|
|
55
|
+
*/
|
|
56
|
+
export const AUDIT_EVENT_DETAIL_READ_SCOPE: ControlPlaneScope = "audit:events:read_detail";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every control-plane scope audit defines — what `pithy dashboard connect` offers for this capability,
|
|
60
|
+
* and the list a manifest or a doc quotes rather than re-typing.
|
|
61
|
+
*/
|
|
62
|
+
export const AUDIT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
63
|
+
AUDIT_TRAIL_READ_SCOPE,
|
|
64
|
+
AUDIT_EVENT_DETAIL_READ_SCOPE,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Audit's management surface, as `GET /control-plane/manifest` reports it.
|
|
69
|
+
*
|
|
70
|
+
* Declared beside the scopes so the scope a route demands and the scope a manifest advertises are the
|
|
71
|
+
* same constant, read from one place. `basePath` is a parameter and never a default: an adopter who
|
|
72
|
+
* mounted audit at `/trail` must get a manifest naming `/trail/events`, or a client composing its
|
|
73
|
+
* calls from the manifest would 404 against exactly the adopters who customized anything.
|
|
74
|
+
*
|
|
75
|
+
* The summaries say what the operation is *for*. A client renders these beside a pane somebody is
|
|
76
|
+
* about to open over other people's activity.
|
|
77
|
+
*/
|
|
78
|
+
export function auditAdminRoutes(basePath: string): AdminRoute[] {
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
method: "GET",
|
|
82
|
+
path: `${basePath}/events`,
|
|
83
|
+
scope: AUDIT_TRAIL_READ_SCOPE,
|
|
84
|
+
summary:
|
|
85
|
+
"Page the trail, newest first, filtered by actor, action, resource, outcome, severity, origin, and time.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: `${basePath}/events/:eventId`,
|
|
90
|
+
scope: AUDIT_EVENT_DETAIL_READ_SCOPE,
|
|
91
|
+
summary: "Read one event in full, including the client IP, user-agent, and capability metadata.",
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import { AuditActorType, AuditMetadata, AuditOutcome, AuditSeverity } from "@pithy-sh/core/src/audit/auditEvent";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the audit routes return, as Zod objects a management client can validate against.
|
|
9
|
+
*
|
|
10
|
+
* `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves of the
|
|
11
|
+
* contract are runtime values for the same reason: a client reading a customer's Worker is crossing a
|
|
12
|
+
* trust boundary in *both* directions, and a TypeScript interface is erased before it can help — it
|
|
13
|
+
* cannot check a response, so every client that had only an interface hand-wrote a mirror of it, and
|
|
14
|
+
* the mirror drifted the first time a field landed here.
|
|
15
|
+
*
|
|
16
|
+
* **No codecs, and no transform anywhere in this file.** These schemas describe JSON on the wire, so
|
|
17
|
+
* parsing one must hand back exactly what went in — that is what lets `responses.test.ts` compare the
|
|
18
|
+
* parsed value with the projection's output and fail on a field either side forgot. A `SQLiteDate`
|
|
19
|
+
* here would decode an ISO string into a `Date` and quietly make that comparison meaningless.
|
|
20
|
+
*
|
|
21
|
+
* The projections that fill these live in `views.ts`, which documents *why* each field is in one view
|
|
22
|
+
* and not the other. This file is the shape; that file is the argument.
|
|
23
|
+
*
|
|
24
|
+
* **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
|
|
25
|
+
* version boundary — a management client validates a response with this schema against a customer's
|
|
26
|
+
* Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
|
|
27
|
+
* below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
|
|
28
|
+
* say*, which is a different fact from `null`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Where a page resumes, or the end of the list. */
|
|
32
|
+
const NextCursor = z
|
|
33
|
+
.string()
|
|
34
|
+
.nullable()
|
|
35
|
+
.describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One event as the listing shows it — everything but the network identifiers and the metadata bag.
|
|
39
|
+
*
|
|
40
|
+
* The type is derived from this object rather than declared beside it, so a field cannot exist in one
|
|
41
|
+
* and not the other.
|
|
42
|
+
*/
|
|
43
|
+
export const AuditEventView = z
|
|
44
|
+
.object({
|
|
45
|
+
eventId: z.string().describe("The event's stable public id (the recorder's UUID)."),
|
|
46
|
+
occurredAt: z.iso.datetime().describe("When it happened, ISO-8601."),
|
|
47
|
+
action: z.string().describe("The `domain/reason` action code."),
|
|
48
|
+
outcome: AuditOutcome.describe("Whether it succeeded, failed, or was denied."),
|
|
49
|
+
severity: AuditSeverity.describe("How serious it is, orthogonal to the outcome."),
|
|
50
|
+
actorType: AuditActorType.describe("The kind of principal that acted."),
|
|
51
|
+
actorId: z.string().nullable().describe("The acting principal's stable id, or null."),
|
|
52
|
+
sessionId: z.string().nullable().describe("The session the action belongs to — a row id, never a token — or null."),
|
|
53
|
+
resourceType: z.string().nullable().describe("The kind of thing acted on, or null."),
|
|
54
|
+
resourceId: z.string().nullable().describe("The thing acted on, or null."),
|
|
55
|
+
requestId: z.string().nullable().describe("The request correlation id, or null."),
|
|
56
|
+
project: z.string().nullable().describe("The project the recorder stamped, or null when it recorded none."),
|
|
57
|
+
environment: z.string().nullable().describe("The environment the recording Worker served, or null."),
|
|
58
|
+
worker: z.string().nullable().describe("The `apps/<name>` Worker that recorded it, or null for a CLI action."),
|
|
59
|
+
version: z.string().nullable().describe("The Cloudflare build id that recorded it, or null."),
|
|
60
|
+
tenant: z
|
|
61
|
+
.string()
|
|
62
|
+
.nullable()
|
|
63
|
+
.describe("The tenant the action was taken for, or null when it was not tenant-scoped."),
|
|
64
|
+
})
|
|
65
|
+
.describe("One audit event as the listing shows it. No client IP, no user-agent, no metadata bag.");
|
|
66
|
+
export type AuditEventView = z.output<typeof AuditEventView>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One event in full — the listing view plus the three fields the detail scope exists to separate.
|
|
70
|
+
*
|
|
71
|
+
* Re-described after `.extend()`: extending builds a new schema, and a description does not follow it.
|
|
72
|
+
*/
|
|
73
|
+
export const AuditEventDetailView = AuditEventView.extend({
|
|
74
|
+
ip: z
|
|
75
|
+
.string()
|
|
76
|
+
.nullable()
|
|
77
|
+
.describe("The client IP the request came from, or null. Personal data; behind its own scope."),
|
|
78
|
+
userAgent: z
|
|
79
|
+
.string()
|
|
80
|
+
.nullable()
|
|
81
|
+
.describe("The client user-agent, or null. A device fingerprint; behind its own scope."),
|
|
82
|
+
metadata: AuditMetadata.nullable().describe(
|
|
83
|
+
"The capability's own structured detail, or null. Arbitrary payload; behind its own scope.",
|
|
84
|
+
),
|
|
85
|
+
}).describe("One audit event in full — the listing view plus the client IP, user-agent, and metadata bag.");
|
|
86
|
+
export type AuditEventDetailView = z.output<typeof AuditEventDetailView>;
|
|
87
|
+
|
|
88
|
+
/** `GET {base}/events`. */
|
|
89
|
+
export const AuditEventsResponse = z
|
|
90
|
+
.object({
|
|
91
|
+
events: z.array(AuditEventView).describe("The page, newest first."),
|
|
92
|
+
nextCursor: NextCursor,
|
|
93
|
+
})
|
|
94
|
+
.describe("A filtered, resumable page of the audit trail.");
|
|
95
|
+
export type AuditEventsResponse = z.output<typeof AuditEventsResponse>;
|
|
96
|
+
|
|
97
|
+
/** `GET {base}/events/:eventId`. */
|
|
98
|
+
export const AuditEventResponse = z
|
|
99
|
+
.object({ event: AuditEventDetailView.describe("The event, with the fields the detail scope gates.") })
|
|
100
|
+
.describe("One audit event in full.");
|
|
101
|
+
export type AuditEventResponse = z.output<typeof AuditEventResponse>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
|
|
7
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
8
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
9
|
+
import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
11
|
+
import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
|
|
12
|
+
import type { Context, Hono } from "hono";
|
|
13
|
+
import { AuditTrailActions } from "../actions";
|
|
14
|
+
import type { AuditDatabase } from "../data/tables";
|
|
15
|
+
import { pageAuditEvents, readAuditEvent } from "../query";
|
|
16
|
+
import { AUDIT_EVENT_DETAIL_READ_SCOPE, AUDIT_TRAIL_READ_SCOPE } from "./guards";
|
|
17
|
+
import type { AuditEventResponse, AuditEventsResponse } from "./responses";
|
|
18
|
+
import { AuditEventIdParam, ListAuditEventsQuery } from "./schemas";
|
|
19
|
+
import { auditEventDetailView, auditEventView } from "./views";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The audit routes, their verification strategies, and what each accepts:
|
|
23
|
+
*
|
|
24
|
+
* GET /audit/events → a page of the trail (control-plane: audit:events:read) query: ListAuditEventsQuery
|
|
25
|
+
* GET /audit/events/:eventId → one event in full (control-plane: audit:events:read_detail) param: AuditEventIdParam
|
|
26
|
+
*
|
|
27
|
+
* **Both are reads, and there is nothing else.** The trail is append-only: no route here deletes,
|
|
28
|
+
* edits, or prunes, because a management credential that could erase an audit row could erase the
|
|
29
|
+
* evidence of its own use. Retention is a lifecycle concern for a Workflow, not a button on a
|
|
30
|
+
* dashboard.
|
|
31
|
+
*
|
|
32
|
+
* **Neither has an end-user surface.** A user does not call the record of themselves being audited,
|
|
33
|
+
* so `requireAuth()` appears nowhere in this file — and must not: the seam leaves `c.var.auth` null
|
|
34
|
+
* for a control-plane caller by design, so an auth gate would deny every legitimate management call
|
|
35
|
+
* permanently, with no credential able to fix it.
|
|
36
|
+
*
|
|
37
|
+
* **Validators sit after the gate on both lines.** A validator ahead of it turns a 403 into a 400 and
|
|
38
|
+
* tells an unverified caller which requests were well-formed — here that is a live oracle for which
|
|
39
|
+
* projects, Workers, and action codes this deployment records.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What every route this capability mounts declares: its path, its strategy, and the scope it checks.
|
|
44
|
+
*
|
|
45
|
+
* Exported so a test asserts against the declaration rather than against a middleware count. Counting
|
|
46
|
+
* `app.routes` entries proves that *something* runs before the handler — a bare `zValidator` satisfies
|
|
47
|
+
* it — and cannot prove *what*. This is a declaration, so it can drift from the router;
|
|
48
|
+
* `routeContract.test.ts` checks it against Hono in both directions, so a route added without an entry
|
|
49
|
+
* and an entry naming no route both fail.
|
|
50
|
+
*/
|
|
51
|
+
export interface AuditRouteDeclaration {
|
|
52
|
+
readonly method: "GET";
|
|
53
|
+
/** The path relative to the configured `basePath`, e.g. `/events`. */
|
|
54
|
+
readonly path: string;
|
|
55
|
+
readonly strategy: VerificationStrategy;
|
|
56
|
+
/** The control-plane scope this route checks. */
|
|
57
|
+
readonly scope: ControlPlaneScope;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every route, and how it is gated. */
|
|
61
|
+
export const AUDIT_ROUTES: readonly AuditRouteDeclaration[] = [
|
|
62
|
+
{ method: "GET", path: "/events", strategy: "control-plane", scope: AUDIT_TRAIL_READ_SCOPE },
|
|
63
|
+
{ method: "GET", path: "/events/:eventId", strategy: "control-plane", scope: AUDIT_EVENT_DETAIL_READ_SCOPE },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** How the audit sub-router is built. */
|
|
67
|
+
export interface AuditRoutesOptions {
|
|
68
|
+
/**
|
|
69
|
+
* The path the routes mount under — **required, with no fallback here**. The default lives in
|
|
70
|
+
* `AuditConfig`, and the capability passes its resolved value to both this and `auditAdminRoutes`,
|
|
71
|
+
* so the mounted path and the advertised path are one value read twice rather than two defaults
|
|
72
|
+
* that can drift apart.
|
|
73
|
+
*/
|
|
74
|
+
basePath: string;
|
|
75
|
+
/** The audit database for this request — resolved by the capability, which owns the registry key. */
|
|
76
|
+
database: (c: Context<PithyHonoEnv>) => AuditDatabase;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The verified management client behind a control-plane call.
|
|
81
|
+
*
|
|
82
|
+
* `requireControlPlane()` has run on every route in this file, so `c.var.controlPlane` is populated by
|
|
83
|
+
* the time a handler reads it. The throw is a programming-error guard, not a runtime path: reaching it
|
|
84
|
+
* would mean a route was mounted without its gate, which is the one mistake this file is arranged to
|
|
85
|
+
* make impossible.
|
|
86
|
+
*/
|
|
87
|
+
function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
|
|
88
|
+
const context = c.var.controlPlane;
|
|
89
|
+
if (!context) {
|
|
90
|
+
throw new InternalError({
|
|
91
|
+
message: "Audit could not identify the management caller.",
|
|
92
|
+
detail: "requireControlPlane() must run before an audit handler reads the caller.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return context;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The filter, as the audit trail should record that it was asked.
|
|
100
|
+
*
|
|
101
|
+
* Built here rather than inline in the `metadata:` literal for two reasons. It keeps dates as ISO
|
|
102
|
+
* strings — a `Date` would round-trip out of the JSON column as a string anyway, so writing one is a
|
|
103
|
+
* silent type change. And `metadata` may not carry top-level keys that name a column (`project`,
|
|
104
|
+
* `environment`, `worker`, `tenant`); nesting the filter under one key keeps the record of *what was
|
|
105
|
+
* asked for* distinct from the record of *where the reading happened* and *whose events these were*,
|
|
106
|
+
* which is what those columns mean. A `tenant` here is a filter the caller typed, not the tenant this
|
|
107
|
+
* read belonged to — collapsing the two would make the trail lie about its own reads.
|
|
108
|
+
*/
|
|
109
|
+
function askedFor(query: ListAuditEventsQuery): Record<string, unknown> {
|
|
110
|
+
const { from, to, cursor, ...rest } = query;
|
|
111
|
+
return {
|
|
112
|
+
...rest,
|
|
113
|
+
...(from ? { from: from.toISOString() } : {}),
|
|
114
|
+
...(to ? { to: to.toISOString() } : {}),
|
|
115
|
+
// The cursor's value is noise in a trail; whether the caller was paging is not.
|
|
116
|
+
resumed: cursor !== undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Register the audit sub-router. Returned as the capability's `routes` hook. */
|
|
121
|
+
export function registerAuditRoutes(options: AuditRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
122
|
+
const base = options.basePath;
|
|
123
|
+
const database = options.database;
|
|
124
|
+
|
|
125
|
+
return (app) => {
|
|
126
|
+
/**
|
|
127
|
+
* CONTROL-PLANE READ. A filtered, resumable page of the trail — keyset, never offset, because the
|
|
128
|
+
* table is appended to while it is being read and an offset page silently skips records.
|
|
129
|
+
*/
|
|
130
|
+
app.get(
|
|
131
|
+
`${base}/events`,
|
|
132
|
+
requireControlPlane(AUDIT_TRAIL_READ_SCOPE),
|
|
133
|
+
zValidator("query", ListAuditEventsQuery, validationHook),
|
|
134
|
+
async (c) => {
|
|
135
|
+
const operator = caller(c);
|
|
136
|
+
const query = c.req.valid("query");
|
|
137
|
+
const page = await pageAuditEvents(database(c), query);
|
|
138
|
+
// Audited *after* the read, so `returned` is the truth rather than an intention, and audited at
|
|
139
|
+
// all because reading the record of everyone else's actions is itself one. Core's guard already
|
|
140
|
+
// recorded that the call was allowed; what it cannot know is what was asked for and how much
|
|
141
|
+
// came back, which is the difference between "an operator opened the pane" and "something
|
|
142
|
+
// paged the entire trail".
|
|
143
|
+
await c.var.emit({
|
|
144
|
+
action: AuditTrailActions.trailRead,
|
|
145
|
+
outcome: "success",
|
|
146
|
+
actorType: "control-plane",
|
|
147
|
+
actorId: operator.subject,
|
|
148
|
+
resourceType: "audit_trail",
|
|
149
|
+
requestId: c.req.header("cf-ray"),
|
|
150
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
151
|
+
userAgent: c.req.header("user-agent"),
|
|
152
|
+
metadata: {
|
|
153
|
+
connectionId: operator.connectionId,
|
|
154
|
+
query: askedFor(query),
|
|
155
|
+
returned: page.events.length,
|
|
156
|
+
more: page.nextCursor !== null,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
// `satisfies`, not `.parse()`. The check belongs at compile time: parsing every response would
|
|
160
|
+
// spend a validation pass on data this Worker just built from its own rows, and it would turn a
|
|
161
|
+
// shape mistake into a 500 in production rather than a red build.
|
|
162
|
+
return c.json(
|
|
163
|
+
{ events: page.events.map(auditEventView), nextCursor: page.nextCursor } satisfies AuditEventsResponse,
|
|
164
|
+
200,
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* CONTROL-PLANE READ, the forensic one. Returns the client IP, the user-agent, and the capability's
|
|
171
|
+
* metadata bag, which is why it sits behind its own scope rather than the listing's.
|
|
172
|
+
*/
|
|
173
|
+
app.get(
|
|
174
|
+
`${base}/events/:eventId`,
|
|
175
|
+
requireControlPlane(AUDIT_EVENT_DETAIL_READ_SCOPE),
|
|
176
|
+
zValidator("param", AuditEventIdParam, validationHook),
|
|
177
|
+
async (c) => {
|
|
178
|
+
const operator = caller(c);
|
|
179
|
+
const { eventId } = c.req.valid("param");
|
|
180
|
+
const row = await readAuditEvent(database(c), eventId);
|
|
181
|
+
// Recorded whether or not the event existed, and with the same `resourceId` either way. A read
|
|
182
|
+
// that found nothing is still somebody asking after a specific event, and a miss that went
|
|
183
|
+
// unrecorded would make probing for ids the one action on this surface that leaves no trace.
|
|
184
|
+
await c.var.emit({
|
|
185
|
+
action: AuditTrailActions.eventRead,
|
|
186
|
+
outcome: row ? "success" : "failure",
|
|
187
|
+
actorType: "control-plane",
|
|
188
|
+
actorId: operator.subject,
|
|
189
|
+
resourceType: "audit_event",
|
|
190
|
+
resourceId: eventId,
|
|
191
|
+
requestId: c.req.header("cf-ray"),
|
|
192
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
193
|
+
userAgent: c.req.header("user-agent"),
|
|
194
|
+
metadata: { connectionId: operator.connectionId, found: row !== null },
|
|
195
|
+
});
|
|
196
|
+
if (!row) {
|
|
197
|
+
throw new NotFoundError({
|
|
198
|
+
message: "No audit event with that id.",
|
|
199
|
+
action: "Check the eventId against a page from GET /events.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return c.json({ event: auditEventDetailView(row) } satisfies AuditEventResponse, 200);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
}
|