Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ GHOST_STEP_TIMEOUT_MS="30000"
# browser cannot wedge the pod forever.
# GHOST_RUN_TIMEOUT_MS="1800000"

# Cap for `GET /api/audit/verify?mode=full`. Over this many events the route
# returns 413 and asks the caller to use `mode=head` instead. Default 50000.
# GHOST_AUDIT_VERIFY_MAX_EVENTS="50000"

# How many runs one worker process executes at once. Each holds a browser, so
# this is memory-bound. Default 2.
WORKER_CONCURRENCY="2"
Expand Down
58 changes: 23 additions & 35 deletions cloud/apps/web/src/app/(app)/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { Card, CardBody } from "@/components/ui/card";
import { verifyAuditChain } from "@ghost/core/audit";
import { auditPayloadFromRow } from "@ghost/core/audit-log";

export const dynamic = "force-dynamic";

/**
* The organization's tamper-evident ledger, and whether it still verifies.
* The organization's tamper-evident ledger, and whether its expected head
* still matches.
*
* The hash chain existed from Phase 1 but nothing ever checked it — a log
* nobody can verify is just a log. This page answers the question a customer's
* auditor actually asks: has anything in this history been altered since it was
* written?
* Full mid-chain re-hashing of unbounded history is an API concern
* (`GET /api/audit/verify?mode=full`) and is capped. This page used to load
* the entire chain on every render (P1-1) — the integrity badge now uses the
* cheap expected-head check, which still catches suffix deletion.
*/
export default async function AuditPage() {
const session = await auth();
const orgId = session?.user.orgId;

// Verification must walk the chain from its real first event. Verifying a
// truncated window would report every healthy chain past the page size as
// "broken at #0", because the oldest row in that window has a non-null
// `prevHash` while the walk expects the first link's predecessor to be null —
// a false alarm in the one feature whose entire job is trustworthy
// verification.
const [events, allForChain] = orgId
const [events, orgRow, count, tail] = orgId
? await Promise.all([
prisma.auditEvent.findMany({ where: { orgId }, orderBy: { seq: "desc" }, take: 200 }),
prisma.auditEvent.findMany({
prisma.organization.findUniqueOrThrow({
where: { id: orgId },
select: { auditChainHead: true },
}),
prisma.auditEvent.count({ where: { orgId } }),
prisma.auditEvent.findFirst({
where: { orgId },
orderBy: { seq: "asc" },
select: { action: true, entityType: true, entityId: true, metadata: true, prevHash: true, hash: true },
orderBy: { seq: "desc" },
select: { hash: true },
}),
])
: [[], []];
: [[], null, 0, null];

const chain = verifyAuditChain(
allForChain.map((e) => ({
prevHash: e.prevHash,
hash: e.hash,
payload: auditPayloadFromRow(e),
})),
);
const headMatches = (tail?.hash ?? null) === (orgRow?.auditChainHead ?? null);

return (
<div className="mx-auto max-w-4xl space-y-6">
Expand All @@ -57,23 +49,19 @@ export default async function AuditPage() {
<div className="text-sm">
<span className="font-medium">Chain integrity</span>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{allForChain.length === 0
{count === 0
? "No events recorded yet."
: `Full chain verified — all ${allForChain.length} events${
allForChain.length > events.length ? `, showing the most recent ${events.length}` : ""
}.`}
: `Expected head check across ${count} events${
count > events.length ? `, showing the most recent ${events.length}` : ""
}. Full mid-chain verify: GET /api/audit/verify?mode=full.`}
</p>
</div>
<span
className={`text-sm font-medium ${
chain.intact ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"
headMatches ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"
}`}
>
{allForChain.length === 0
? "—"
: chain.intact
? "Intact"
: `Broken at #${(chain.firstBreakIndex ?? 0) + 1}`}
{count === 0 ? "—" : headMatches ? "Intact" : "Head mismatch"}
</span>
</CardBody>
</Card>
Expand Down
149 changes: 112 additions & 37 deletions cloud/apps/web/src/app/api/audit/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Comment on lines +47 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the expected and actual audit heads atomically

When an audit event is appended between this organization-head read and the later tail query, the statements observe different snapshots: orgRow.auditChainHead can be the old head while tail.hash is the newly committed head, causing an intact chain to be returned as intact: 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 👍 / 👎.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the organization anchor in head-mode run verification

When the terminal run timeline calls /api/audit/verify?runId=... without an explicit mode, this assignment returns verifyRunHead, which omits the previous anchored and anchorMatches checks. The UI therefore labels the journal “intact” solely because its tail matches the mutable Run.journalHead and silently suppresses the anchor status; rewriting both the journal and that column now passes even when the independently recorded organization-ledger anchor disagrees. Retain the anchor comparison in head mode or have this integrity UI request full verification.

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" },
Expand All @@ -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,
Expand All @@ -81,16 +125,24 @@ export async function GET(req: Request) {
where: { runId },
orderBy: { seq: "asc" },
});
Comment on lines 125 to 127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the run-journal cap before loading events

When runId refers to a journal larger than GHOST_AUDIT_VERIFY_MAX_EVENTS, this query materializes the entire journal before checking runEvents.length and returning 413. A run can have far more journal events than the organization audit-event count used by the earlier guard, so an authenticated full-verify request can still consume the web tier's memory and CPU—the failure this cap is meant to prevent. Count first or query at most cap + 1 rows before attempting verification.

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
Expand All @@ -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,
Expand All @@ -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";

/**
Expand All @@ -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) {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require current membership before allowing rejection

When an administrator removes a user from the organization, that user's JWT can remain valid for up to 12 hours, but loadActor runs only for approve. The removed user can therefore still submit decision: "reject" for a known pending gate, marking a forward run FAILED or rejecting compensation even though they are no longer the “member” this policy intends to authorize. Load and require a current actor for both decisions, then apply the OWNER/ADMIN capability check only to approval.

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
Expand Down
14 changes: 12 additions & 2 deletions cloud/apps/web/src/app/api/runs/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { NextResponse } from "next/server";
import { canStartRun } from "@ghost/core/roles";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { loadActor } from "@/lib/members";
import { startRun } from "@/lib/start-run";

/** Trigger a run of a workflow's latest version. */
export async function POST(req: Request) {
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;
// Re-read role from the DB; JWT says nothing about a demotion since sign-in.
// Today every Role may start a run; the check is here so a future VIEWER
// cannot slip through when canStartRun tightens.
const actor = await loadActor(orgId, userId);
if (!actor || !canStartRun(actor.role)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}

const body = (await req.json().catch(() => ({}))) as { workflowId?: string };
if (!body.workflowId) {
Expand All @@ -28,7 +38,7 @@ export async function POST(req: Request) {
const started = await startRun({
orgId,
workflowVersionId: version.id,
triggeredById: session.user.id,
triggeredById: userId,
});
if (!started.ok) {
// 503, not 500: the request was fine and retrying is the right move once
Expand Down
Loading
Loading