Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
08ead1d
feat(security): audit_trail_events table, list endpoint and Maple's o…
Makisuo Nov 11, 2026
ded2423
fix(security): best-effort trail copy on the direct write, sibling cr…
Makisuo Nov 12, 2026
3b562af
fix(security): fail an email filter when the member directory is down…
Makisuo Nov 12, 2026
918b68c
feat(security): the audit log page in the Security hub
Makisuo Nov 12, 2026
e26901d
feat(security): an outcome reason on audit trail events, and Maple en…
Makisuo Nov 12, 2026
09838bd
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
a098f13
fix(security): the audit trail endpoint's errors carry a public title…
Makisuo Nov 12, 2026
60525ab
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
0153b0c
fix(security): audit log page review fixes
Makisuo Nov 12, 2026
cc6dca8
test(web): a sample URL for the security audit log page
Makisuo Nov 12, 2026
001b331
fix(web): the audit log row toggle says when the event happened
Makisuo Nov 12, 2026
2c15b63
fix(web): name the audit log's filter selects for what they filter
Makisuo Nov 12, 2026
736658e
fix(security): a refused API request is a permission event with a target
Makisuo Nov 12, 2026
7c1ab46
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
e150a50
fix(security): audit log page fixes from testing on the preview
Makisuo Nov 12, 2026
d3a9f40
fix(web): an actor typed but not applied survives a change of another…
Makisuo Nov 12, 2026
9f6d7fc
fix(security): the audit log's event id link carries the entry's time…
Makisuo Nov 12, 2026
22849d0
feat(security): Vercel and GCP audit events go to the audit trail, no…
Makisuo Nov 12, 2026
5e11c11
fix(security): the audit trail accepts only the org's own drain, boun…
Makisuo Nov 12, 2026
10153a2
fix(security): a source lookup that fails asks the Vercel audit drain…
Makisuo Nov 12, 2026
7cd5701
feat(security): the audit detection vocabulary, its alert_rules colum…
Makisuo Nov 12, 2026
80d2c18
feat(security): audit log detections, alert rules that count audit ev…
Makisuo Nov 12, 2026
1bf6770
test(security): audit log detections, from the rule row to the routes
Makisuo Nov 12, 2026
c592466
fix(security): an audit incident links the audit log only for someone…
Makisuo Nov 12, 2026
ce4b6d6
fix(security): detections from testing on the preview
Makisuo Nov 12, 2026
8e22c94
chore(web): the kit index lists canReadAuditLog
Makisuo Nov 12, 2026
adbc8d9
fix(security): a detection's count floor moves only when it counts more
Makisuo Nov 12, 2026
521a1be
fix(security): an audit incident says who may open the log only once …
Makisuo Nov 12, 2026
d8f5e05
fix(security): an audit actor filter stays out of API spans
Makisuo Nov 12, 2026
6d4324d
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
73aa975
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 12, 2026
a98bced
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 12, 2026
14ee1c9
Merge remote-tracking branch 'origin/main' into audit-trail/table
Makisuo Nov 13, 2026
a59b9d6
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
8fef5b3
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
2d2bdc1
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
8bc7eba
chore(db): one detection migration on top of main's newest
Makisuo Nov 13, 2026
d32d7de
fix(audit-trail): cost sources have a category in the trail
Makisuo Nov 13, 2026
f5dceed
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
7816d03
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
10a38c1
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
e89330f
fix(audit-trail): a dropped trail copy from the queue is a warning, n…
Makisuo Nov 13, 2026
7b544b4
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
67090fa
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
5db0f72
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
bec8c40
fix(security): the audit log names the platform on a phone and in its…
Makisuo Nov 13, 2026
0dd401d
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
61c0c83
Merge remote-tracking branch 'origin/main' into audit-trail/table
Makisuo Nov 13, 2026
73fd519
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
04f6108
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
d60d7b5
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
d8dd5ff
Merge remote-tracking branch 'origin/main' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
839c56f
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 214 additions & 10 deletions apps/api/src/routes/internal/audit-trail.http.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"
import { afterEach, describe, expect, it } from "vitest"
import {
AlertMemberDirectoryUnavailableError,
ApiSchemaErrors,
ApiUnexpectedErrors,
AuditDetection,
AuditDetectionListResponse,
AuditTrailApiGroup,
type AuditTrailEntry,
AuditTrailListResponse,
Expand All @@ -11,10 +13,14 @@ import {
RoleName,
UserId,
} from "@maple/domain/http"
import { Context, Effect, Layer, Schema, Array as Arr, String as Str } from "effect"
import { Context, Effect, Layer, Order, Schema, Array as Arr, String as Str } from "effect"
import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2"
import { HttpRouter } from "effect/http"
import { HttpApi, HttpApiBuilder } from "effect/http-api"
import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary"
import { AuditLogService, makeMemoryAuditLog } from "@maple/backend/services/audit/AuditLogService"
import { AuditDetectionsService } from "@maple/backend/services/audit-trail/AuditDetectionsService"
import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/testing/test-pglite"
import {
type AuditTrailListFilters,
type AuditTrailPage,
Expand All @@ -34,6 +40,12 @@ const USER_ID = Schema.decodeSync(UserId)("user_trail_route")
const unused = Effect.die("not used by the audit trail route")
const asRole = Schema.decodeSync(RoleName)
const asPage = Schema.decodeUnknownSync(AuditTrailListResponse)
const asDetection = Schema.decodeUnknownSync(AuditDetection)
const asDetections = Schema.decodeUnknownSync(AuditDetectionListResponse)
const asFailure = Schema.decodeUnknownSync(Schema.Struct({ _tag: Schema.String }))

const trackedDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(trackedDbs))

const event = (overrides: Partial<AuditTrailEntry> = {}): AuditTrailEntry => ({
id: Str.repeat(32)("a"),
Expand All @@ -55,15 +67,28 @@ const event = (overrides: Partial<AuditTrailEntry> = {}): AuditTrailEntry => ({
...overrides,
})

/** The route over a trail that answers with `page` and records the filters it was asked for. */
const makeHarness = (roles: ReadonlyArray<string>, page: AuditTrailPage, directoryDown = false) => {
/**
* The route over a trail that answers with `page` and records the filters it was asked for,
* and the real detections service over an embedded database (`testDb`, shared to read back
* as another caller).
*/
const makeHarness = (
roles: ReadonlyArray<string>,
page: AuditTrailPage,
directoryDown = false,
testDb: TestDb = createTestDb(trackedDbs),
) => {
const asked: Array<AuditTrailListFilters> = []
let directoryReads = 0
const auditLog = makeMemoryAuditLog()
const services = Layer.mergeAll(
Layer.succeed(AuditTrailService, {
write: () => unused,
list: (_orgId, filters) => Effect.sync(() => (asked.push(filters), page)),
count: () => unused,
}),
Layer.effect(AuditDetectionsService, AuditDetectionsService.make).pipe(Layer.provide(testDb.layer)),
Layer.succeed(AuditLogService, auditLog),
Layer.succeed(OrgMembersService, {
listMembers: () =>
directoryDown
Expand Down Expand Up @@ -105,17 +130,40 @@ const makeHarness = (roles: ReadonlyArray<string>, page: AuditTrailPage, directo
)
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })

const list = async (search = "") => {
const response = await handler(
const send = async (request: Request) => {
const response = await handler(request, Context.empty() as never)
return { status: response.status, body: await response.json() }
}

const list = (search = "") =>
send(
new Request(`http://maple.test/internal/audit-trail/events${search}`, {
headers: { authorization: "Bearer test-token" },
}),
Context.empty() as never,
)
return { status: response.status, body: await response.json() }
}

return { list, asked, directoryReads: () => directoryReads, dispose }
/** A call to a detections endpoint: `path` is what follows `/detections`. */
const detections = (method: string, path = "", body?: unknown) =>
send(
new Request(`http://maple.test/internal/audit-trail/detections${path}`, {
method,
headers: {
authorization: "Bearer test-token",
...(body === undefined ? undefined : { "content-type": "application/json" }),
},
...(body === undefined ? undefined : { body: JSON.stringify(body) }),
}),
)

/** What Maple's own audit log recorded: each entry's action, resource and changed fields. */
const audited = async () =>
Arr.map(await Effect.runPromise(auditLog.list(ORG_ID, { limit: 50, offset: 0 })), (entry) => ({
action: entry.action,
resourceId: entry.resourceId,
changedFields: entry.changedFields,
}))

return { list, detections, audited, asked, directoryReads: () => directoryReads, dispose }
}

const ADMIN = ["org:admin"]
Expand Down Expand Up @@ -287,3 +335,159 @@ describe("GET /internal/audit-trail/events", () => {
}
})
})

const EMPTY_PAGE: AuditTrailPage = { events: [], next: null }

const detectionBody = (overrides: Record<string, unknown> = {}) => ({
name: "Members added",
filters: { provider: "vercel", category: "membership" },
windowMinutes: 10,
threshold: 3,
severity: "critical",
...overrides,
})

const UNKNOWN_ID = "00000000-0000-4000-8000-000000000000"

describe("/internal/audit-trail/detections", () => {
// A detection says what the audit log holds, so it sits behind the audit log permission.
it("refuses an engineer on every endpoint and stores nothing", async () => {
const testDb = createTestDb(trackedDbs)
const admin = makeHarness(ADMIN, EMPTY_PAGE, false, testDb)
const engineer = makeHarness(["org:engineer"], EMPTY_PAGE, false, testDb)
try {
const existing = asDetection((await admin.detections("POST", "", detectionBody())).body)

const statuses = [
await engineer.detections("GET"),
await engineer.detections("POST", "", detectionBody({ name: "Theirs" })),
await engineer.detections("PATCH", `/${existing.id}`, { threshold: 9 }),
await engineer.detections("DELETE", `/${existing.id}`),
]

expect(Arr.map(statuses, (response) => response.status)).toEqual([403, 403, 403, 403])
expect(await engineer.audited()).toEqual([])
expect(asDetections((await admin.detections("GET")).body).detections).toEqual([existing])
} finally {
await admin.dispose()
await engineer.dispose()
}
})

it("lets an admin create, list, edit and delete a detection, and audits each change", async () => {
const harness = makeHarness(ADMIN, EMPTY_PAGE)
try {
const created = await harness.detections("POST", "", detectionBody({ name: " Members added " }))
expect(created.status).toBe(200)
const detection = asDetection(created.body)
expect(detection).toMatchObject({
name: "Members added",
enabled: true,
filters: { provider: "vercel", category: "membership" },
windowMinutes: 10,
threshold: 3,
severity: "critical",
destinationIds: [],
})
expect(asDetections((await harness.detections("GET")).body).detections).toEqual([detection])

const edited = await harness.detections("PATCH", `/${detection.id}`, {
threshold: 5,
enabled: false,
})
expect(edited.status).toBe(200)
expect(asDetection(edited.body)).toMatchObject({
id: detection.id,
name: "Members added",
threshold: 5,
enabled: false,
})

const deleted = await harness.detections("DELETE", `/${detection.id}`)
expect(deleted).toEqual({ status: 200, body: { id: detection.id } })
expect(asDetections((await harness.detections("GET")).body).detections).toEqual([])

// The audit log names a resource by its public id.
const resourceId = encodePublicId(PublicIdPrefixes.alertRule, detection.id)
expect(Arr.sortWith(await harness.audited(), (entry) => entry.action, Order.String)).toEqual([
{ action: "alert_rule.created", resourceId, changedFields: null },
{ action: "alert_rule.deleted", resourceId, changedFields: null },
{ action: "alert_rule.updated", resourceId, changedFields: ["enabled", "threshold"] },
])
} finally {
await harness.dispose()
}
})

it("answers 400 for a detection outside its bounds, before anything is stored", async () => {
const harness = makeHarness(ADMIN, EMPTY_PAGE)
try {
const responses = [
await harness.detections("POST", "", detectionBody({ name: " " })),
await harness.detections("POST", "", detectionBody({ name: Str.repeat(121)("x") })),
await harness.detections("POST", "", detectionBody({ windowMinutes: 4 })),
await harness.detections("POST", "", detectionBody({ windowMinutes: 1441 })),
await harness.detections("POST", "", detectionBody({ threshold: 0 })),
await harness.detections("POST", "", detectionBody({ threshold: 2.5 })),
await harness.detections("POST", "", detectionBody({ severity: "info" })),
await harness.detections("POST", "", detectionBody({ filters: { provider: "nowhere" } })),
await harness.detections("POST", "", detectionBody({ filters: { action: " " } })),
await harness.detections(
"POST",
"",
detectionBody({ destinationIds: Arr.makeBy(21, () => UNKNOWN_ID) }),
),
]

expect(Arr.dedupe(Arr.map(responses, (response) => response.status))).toEqual([400])
expect(asDetections((await harness.detections("GET")).body).detections).toEqual([])
} finally {
await harness.dispose()
}
})

// The unique index on org and name would otherwise answer a 500.
it("answers 400 for an unknown destination and for a name already taken", async () => {
const harness = makeHarness(ADMIN, EMPTY_PAGE)
try {
const first = asDetection((await harness.detections("POST", "", detectionBody())).body)
const other = asDetection(
(await harness.detections("POST", "", detectionBody({ name: "Tokens created" }))).body,
)

const responses = [
await harness.detections("POST", "", detectionBody({ name: "Members added" })),
await harness.detections("PATCH", `/${other.id}`, { name: first.name }),
await harness.detections(
"POST",
"",
detectionBody({ name: "Third", destinationIds: [UNKNOWN_ID] }),
),
]

expect(
Arr.map(responses, (response) => [response.status, asFailure(response.body)._tag]),
).toEqual([
[400, "@maple/http/errors/AuditDetectionInvalidError"],
[400, "@maple/http/errors/AuditDetectionInvalidError"],
[400, "@maple/http/errors/AuditDetectionInvalidError"],
])
expect(asDetections((await harness.detections("GET")).body).detections).toHaveLength(2)
} finally {
await harness.dispose()
}
})

it("answers 404 for a detection that does not exist", async () => {
const harness = makeHarness(ADMIN, EMPTY_PAGE)
try {
const edited = await harness.detections("PATCH", `/${UNKNOWN_ID}`, { threshold: 5 })
const deleted = await harness.detections("DELETE", `/${UNKNOWN_ID}`)

expect([edited.status, deleted.status]).toEqual([404, 404])
expect(await harness.audited()).toEqual([])
} finally {
await harness.dispose()
}
})
})
Loading
Loading