-
Notifications
You must be signed in to change notification settings - Fork 0
fix(cloud): RBAC matrix, sensitive-fill hygiene, audit verify cap #425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,36 +8,92 @@ import { auditPayloadFromRow, runEventPayloadFromRow } from "@ghost/core/audit-l | |
| /** | ||
| * Verify the organization's audit chain, and optionally one run's journal. | ||
| * | ||
| * `verifyAuditChain` has existed and been unit-tested since Phase 1 but was | ||
| * never reachable from anywhere — an audit log nobody can check is a log, not | ||
| * a proof. This is the endpoint that lets a customer (or their auditor) ask the | ||
| * question directly. | ||
| * Two modes (query `mode=`): | ||
| * | ||
| * A broken chain is reported, not hidden. If an org's history was forked by the | ||
| * old non-transactional appender, this will say so, which is the right outcome: | ||
| * an audit log that quietly conceals a break is worse than one that admits it. | ||
| * - `head` (default for the audit page): cheap — count + last hash vs | ||
| * `Organization.auditChainHead`. Detects suffix deletion / head drift. | ||
| * Does **not** re-hash every event; mid-chain payload edits need `full`. | ||
| * - `full`: walks every event through `verifyAuditChain`. Caped by | ||
| * `GHOST_AUDIT_VERIFY_MAX_EVENTS` (default 50_000) so an unbounded | ||
| * tenant history cannot take the web tier down (P1-1). Over the cap → 413. | ||
| * | ||
| * A broken chain is reported, not hidden. | ||
| */ | ||
|
|
||
| const DEFAULT_MAX_EVENTS = 50_000; | ||
|
|
||
| function maxEvents(): number { | ||
| const fromEnv = Number(process.env.GHOST_AUDIT_VERIFY_MAX_EVENTS); | ||
| return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_MAX_EVENTS; | ||
| } | ||
|
|
||
| export async function GET(req: Request) { | ||
| const session = await auth(); | ||
| if (!session?.user?.orgId) { | ||
| return NextResponse.json({ error: "unauthorized" }, { status: 401 }); | ||
| } | ||
| const orgId = session.user.orgId; | ||
| const runId = new URL(req.url).searchParams.get("runId"); | ||
|
|
||
| // Verification is inherently whole-chain — a hash chain cannot be checked in | ||
| // pages — so this is the most expensive read in the product and it grows | ||
| // with the tenant's entire history. Rate limited per organization because a | ||
| // loop over it is a one-request-each denial of service against the web tier, | ||
| // and because it is a deliberate audit action rather than something a UI | ||
| // polls. Six a minute is generous for a human and useless as an attack. | ||
| // | ||
| // This bounds the blast radius; it does not fix the underlying cost. The | ||
| // unbounded findMany below is P1-1 in docs/ARCHITECTURE_DECISIONS.md and | ||
| // needs incremental verification with a checkpoint, not a limiter. | ||
| const url = new URL(req.url); | ||
| const runId = url.searchParams.get("runId"); | ||
| const mode = url.searchParams.get("mode") === "full" ? "full" : "head"; | ||
|
|
||
| // Verification of a large chain is the most expensive read in the product. | ||
| // Rate limited per organization so a loop over it is not a one-request-each | ||
| // denial of service. Six a minute is generous for a human and useless as an | ||
| // attack. | ||
| const limited = await rateLimit(`audit-verify:${orgId}`, { limit: 6, windowSeconds: 60 }); | ||
| if (!limited.ok) return tooManyRequests(limited); | ||
|
|
||
| const orgRow = await prisma.organization.findUniqueOrThrow({ | ||
| where: { id: orgId }, | ||
| select: { auditChainHead: true }, | ||
| }); | ||
|
|
||
| const count = await prisma.auditEvent.count({ where: { orgId } }); | ||
| const tail = await prisma.auditEvent.findFirst({ | ||
| where: { orgId }, | ||
| orderBy: { seq: "desc" }, | ||
| select: { hash: true, seq: true }, | ||
| }); | ||
| const orgHead = tail?.hash ?? null; | ||
| const orgExpectedHeadMatches = orgHead === orgRow.auditChainHead; | ||
|
|
||
| if (mode === "head") { | ||
| const body: Record<string, unknown> = { | ||
| mode: "head", | ||
| org: { | ||
| // Head mode cannot claim mid-chain integrity — only that the expected | ||
| // tail matches what is stored on Organization. | ||
| intact: orgExpectedHeadMatches, | ||
| count, | ||
| headHash: orgHead, | ||
| expectedHeadHash: orgRow.auditChainHead, | ||
| expectedHeadMatches: orgExpectedHeadMatches, | ||
| fullVerifyAvailable: count <= maxEvents(), | ||
| }, | ||
| }; | ||
| if (runId) { | ||
| const runHead = await verifyRunHead(orgId, runId); | ||
| if (!runHead) return NextResponse.json({ error: "not found" }, { status: 404 }); | ||
| body.run = runHead; | ||
|
Comment on lines
+75
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the terminal run timeline calls AGENTS.md reference: AGENTS.md:L19-L25 Useful? React with 👍 / 👎. |
||
| } | ||
| return NextResponse.json(body); | ||
| } | ||
|
|
||
| // Full verify — capped. | ||
| const cap = maxEvents(); | ||
| if (count > cap) { | ||
| return NextResponse.json( | ||
| { | ||
| error: "audit chain too large for a single full verify", | ||
| count, | ||
| maxEvents: cap, | ||
| hint: "use mode=head for the expected-tail check, or raise GHOST_AUDIT_VERIFY_MAX_EVENTS", | ||
| }, | ||
| { status: 413 }, | ||
| ); | ||
| } | ||
|
|
||
| const events = await prisma.auditEvent.findMany({ | ||
| where: { orgId }, | ||
| orderBy: { seq: "asc" }, | ||
|
|
@@ -46,20 +102,8 @@ export async function GET(req: Request) { | |
| events.map((e) => ({ prevHash: e.prevHash, hash: e.hash, payload: auditPayloadFromRow(e) })), | ||
| ); | ||
|
|
||
| // Internal hash links alone cannot detect deletion of a valid suffix — the | ||
| // surviving prefix still verifies as intact, just shorter. auditChainHead is | ||
| // the expected tail persisted on Organization outside AuditEvent itself (see | ||
| // appendAuditEvent), so a suffix deleted without also rewriting that column | ||
| // is caught here instead of silently accepted as "the chain that happens to | ||
| // exist now". | ||
| const orgRow = await prisma.organization.findUniqueOrThrow({ | ||
| where: { id: orgId }, | ||
| select: { auditChainHead: true }, | ||
| }); | ||
| const orgHead = events.at(-1)?.hash ?? null; | ||
| const orgExpectedHeadMatches = orgHead === orgRow.auditChainHead; | ||
|
|
||
| const body: Record<string, unknown> = { | ||
| mode: "full", | ||
| org: { | ||
| intact: chain.intact && orgExpectedHeadMatches, | ||
| firstBreakIndex: chain.firstBreakIndex, | ||
|
|
@@ -81,16 +125,24 @@ export async function GET(req: Request) { | |
| where: { runId }, | ||
| orderBy: { seq: "asc" }, | ||
| }); | ||
|
Comment on lines
125
to
127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| const chain = verifyAuditChain( | ||
| if (runEvents.length > cap) { | ||
| return NextResponse.json( | ||
| { | ||
| error: "run journal too large for a single full verify", | ||
| count: runEvents.length, | ||
| maxEvents: cap, | ||
| }, | ||
| { status: 413 }, | ||
| ); | ||
| } | ||
| const runChain = verifyAuditChain( | ||
| runEvents.map((e) => ({ | ||
| prevHash: e.prevHash, | ||
| hash: e.hash, | ||
| payload: runEventPayloadFromRow(e), | ||
| })), | ||
| ); | ||
|
|
||
| // The org chain records each finished run's journal head. If the two | ||
| // disagree, the journal was altered after the run sealed it. | ||
| const head = runEvents.at(-1)?.hash ?? null; | ||
| const expectedHeadMatches = head === owned.journalHead; | ||
| const anchors = events | ||
|
|
@@ -99,8 +151,8 @@ export async function GET(req: Request) { | |
| .filter((h): h is string => typeof h === "string"); | ||
|
|
||
| body.run = { | ||
| intact: chain.intact && expectedHeadMatches, | ||
| firstBreakIndex: chain.firstBreakIndex, | ||
| intact: runChain.intact && expectedHeadMatches, | ||
| firstBreakIndex: runChain.firstBreakIndex, | ||
| count: runEvents.length, | ||
| headHash: head, | ||
| expectedHeadHash: owned.journalHead, | ||
|
|
@@ -112,3 +164,26 @@ export async function GET(req: Request) { | |
|
|
||
| return NextResponse.json(body); | ||
| } | ||
|
|
||
| async function verifyRunHead(orgId: string, runId: string) { | ||
| const owned = await prisma.run.findFirst({ | ||
| where: { id: runId, orgId }, | ||
| select: { id: true, journalHead: true }, | ||
| }); | ||
| if (!owned) return null; | ||
| const count = await prisma.runEvent.count({ where: { runId } }); | ||
| const tail = await prisma.runEvent.findFirst({ | ||
| where: { runId }, | ||
| orderBy: { seq: "desc" }, | ||
| select: { hash: true }, | ||
| }); | ||
| const head = tail?.hash ?? null; | ||
| const expectedHeadMatches = head === owned.journalHead; | ||
| return { | ||
| intact: expectedHeadMatches, | ||
| count, | ||
| headHash: head, | ||
| expectedHeadHash: owned.journalHead, | ||
| expectedHeadMatches, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { auth } from "@/auth"; | ||
| import { prisma } from "@/lib/db"; | ||
| import { loadActor } from "@/lib/members"; | ||
| import { enqueueCompensateRun, enqueueRunWorkflow } from "@/lib/queue"; | ||
| import { appendAuditEvent, appendRunEvent } from "@ghost/core/audit-log"; | ||
| import { canApproveRun } from "@ghost/core/roles"; | ||
| import { RUN_EVENT_TYPES } from "@ghost/core/run-events"; | ||
|
|
||
| /** | ||
|
|
@@ -21,11 +23,11 @@ export async function POST( | |
| { params }: { params: Promise<{ id: string; stepIndex: string }> }, | ||
| ) { | ||
| const session = await auth(); | ||
| if (!session?.user?.orgId) { | ||
| if (!session?.user?.orgId || !session.user.id) { | ||
| return NextResponse.json({ error: "unauthorized" }, { status: 401 }); | ||
| } | ||
| const orgId = session.user.orgId; | ||
| const userId = session.user.id ?? null; | ||
| const userId = session.user.id; | ||
| const { id, stepIndex } = await params; | ||
| const index = Number(stepIndex); | ||
| if (!Number.isInteger(index) || index < 0) { | ||
|
|
@@ -46,6 +48,14 @@ export async function POST( | |
| if (!run) return NextResponse.json({ error: "not found" }, { status: 404 }); | ||
|
|
||
| const approve = body.decision === "approve"; | ||
| // Approving authorizes a sensitive mutation — OWNER/ADMIN only. Rejecting | ||
| // stops the action and stays open to every member (see canApproveRun). | ||
| if (approve) { | ||
| const actor = await loadActor(orgId, userId); | ||
|
Comment on lines
+53
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an administrator removes a user from the organization, that user's JWT can remain valid for up to 12 hours, but AGENTS.md reference: AGENTS.md:L101-L112 Useful? React with 👍 / 👎. |
||
| if (!actor || !canApproveRun(actor.role)) { | ||
| return NextResponse.json({ error: "forbidden" }, { status: 403 }); | ||
| } | ||
| } | ||
| const now = new Date(); | ||
|
|
||
| // Which direction of the run is waiting. A gate opened while reversing the | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an audit event is appended between this organization-head read and the later tail query, the statements observe different snapshots:
orgRow.auditChainHeadcan be the old head whiletail.hashis the newly committed head, causing an intact chain to be returned asintact: false. The audit page has the same race because its head and tail queries run separately. Read both values in a repeatable-read transaction or otherwise from one database snapshot so ordinary concurrent activity cannot produce a false tampering alarm.AGENTS.md reference: AGENTS.md:L19-L25
Useful? React with 👍 / 👎.