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
5 changes: 5 additions & 0 deletions cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ GHOST_SESSION_KEY="Z2hvc3QtZGV2LW9ubHkta2V5LW5vdC1hLXNlY3JldCE="
# Default per-step wall-clock budget in ms (a step may override it). Default 30s.
GHOST_STEP_TIMEOUT_MS="30000"

# Wall-clock budget for an entire run in ms. Default 30 minutes. When exceeded
# the worker stops renewing the run lease and raises an INCIDENT so a stuck
# browser cannot wedge the pod forever.
# GHOST_RUN_TIMEOUT_MS="1800000"

# 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
4 changes: 2 additions & 2 deletions cloud/apps/web/src/app/api/agent/recordings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { ingestTrace, MAX_TRACE_BYTES } from "@/lib/recording-ingest";
* human reviews the steps in the editor and publishes through
* `POST /api/workflows`, which revalidates them.
*
* `resolveAgentPrincipal` accepts a session too, and enforces the second
* factor on that path — see `lib/agent-auth.ts`.
* `resolveAgentPrincipal` requires a bearer credential (session cookies are
* refused) — see `lib/agent-auth.ts`.
*
* Body is JSON rather than multipart: the extension builds the trace in
* memory and has no file to attach.
Expand Down
2 changes: 1 addition & 1 deletion cloud/apps/web/src/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { resolveAgentPrincipal } from "@/lib/agent-auth";

/**
* Agent surface catalog.
* GET /api/agent — tool list + human-approval contract (session or Ghost credential).
* GET /api/agent — tool list + human-approval contract (Ghost credential).
*/
export async function GET(req: Request) {
const authz = await resolveAgentPrincipal(req);
Expand Down
37 changes: 36 additions & 1 deletion cloud/apps/web/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
import type { NextRequest } from "next/server";
import { handlers } from "@/auth";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";

export const { GET, POST } = handlers;
/**
* Auth.js route handlers, wrapped with a fixed-window rate limit.
*
* MFA verify / invite accept / audit verify are already throttled. Sign-in was
* the remaining open door from the audit: an unthrottled `/api/auth/*` lets an
* attacker grind credentials, magic-link tokens, and OAuth callbacks without
* bound.
*
* POST (credentials / magic-link / callback) is the attack surface and is
* keyed tightly. GET (session / csrf / providers) is hit by ordinary
* `useSession` traffic, so it gets a looser budget that still bounds a
* scripted sweep of token endpoints.
*
* Fail-closed via `rateLimit` itself: a Redis outage refuses the request
* rather than letting auth through unmetered.
*/

async function limited(
req: NextRequest,
handle: (req: NextRequest) => Promise<Response>,
{ limit, windowSeconds, key }: { limit: number; windowSeconds: number; key: string },
): Promise<Response> {
const result = await rateLimit(`${key}:${clientKey(req)}`, { limit, windowSeconds });
if (!result.ok) return tooManyRequests(result);
return handle(req);
}

export async function GET(req: NextRequest): Promise<Response> {
return limited(req, handlers.GET, { limit: 120, windowSeconds: 60, key: "auth:get" });
}

export async function POST(req: NextRequest): Promise<Response> {
return limited(req, handlers.POST, { limit: 20, windowSeconds: 60, key: "auth:post" });
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createAgentToken } from "@ghost/core/agent-credentials";
import { prisma } from "@ghost/core/db";

/**
* Agent credential hardening: optional expiry at creation, and org-admin
* inventory/revocation.
* Agent credential hardening: optional expiry at creation, org-admin
* inventory/revocation, and admin-only minting.
*
* Revocation and expiry *enforcement* already existed (`resolveAgentPrincipal`
* in `@/lib/agent-auth` already rejected a revoked or expired credential) —
* what was missing was any way to actually set an expiry, and any way for an
* org admin to see or revoke a credential that isn't their own. `Role` has
* been stored on `Membership` since Phase 0 but nothing read it until now.
* rejects a revoked or expired credential). Minting is now OWNER/ADMIN only —
* a MEMBER must not expand the agent attack surface by creating keys that can
* start runs.
*
* Requires DATABASE_URL; skips cleanly without one.
*/
Expand Down Expand Up @@ -46,7 +46,8 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
});

beforeEach(() => {
session.current = { user: { id: member, orgId } };
// Minting is admin-only; most create tests run as OWNER.
session.current = { user: { id: owner, orgId } };
});

async function create(body: Record<string, unknown>) {
Expand All @@ -72,6 +73,21 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
return GET();
}

/** Seed a credential owned by `userId` without going through the HTTP mint gate. */
async function seedCredential(userId: string, name: string) {
const generated = createAgentToken();
return prisma.agentCredential.create({
data: {
orgId,
userId,
name,
tokenHash: generated.tokenHash,
tokenHint: generated.tokenHint,
},
select: { id: true },
});
}

it("creates a credential with no expiry by default", async () => {
const res = await create({ name: "no-expiry" });
expect(res.status).toBe(201);
Expand All @@ -85,7 +101,6 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
expect(res.status).toBe(201);
const body = (await res.json()) as { credential: { expiresAt: string } };
const expiresAt = new Date(body.credential.expiresAt).getTime();
// ~30 days out, generous window for test execution time.
expect(expiresAt).toBeGreaterThan(before + 29 * 86_400_000);
expect(expiresAt).toBeLessThan(before + 31 * 86_400_000);
});
Expand All @@ -100,23 +115,25 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
},
);

it("refuses minting to a non-admin MEMBER", async () => {
session.current = { user: { id: member, orgId } };
const res = await create({ name: "member-mint" });
expect(res.status).toBe(403);
const created = await prisma.agentCredential.findFirst({ where: { orgId, name: "member-mint" } });
expect(created).toBeNull();
});

it("lets a member revoke their own credential but not a colleague's", async () => {
session.current = { user: { id: owner, orgId } };
const ownerCred = (
(await (await create({ name: "owner-cred" })).json()) as { credential: { id: string } }
).credential;
const ownerCred = (await (await create({ name: "owner-cred" })).json() as {
credential: { id: string };
}).credential;

session.current = { user: { id: member, orgId } };
const memberCred = (
(await (await create({ name: "member-cred" })).json()) as { credential: { id: string } }
).credential;
const memberCred = await seedCredential(member, "member-cred");

// Member revoking their own credential succeeds.
session.current = { user: { id: member, orgId } };
const ownRevoke = await revoke(memberCred.id);
expect(ownRevoke.status).toBe(200);

// Member revoking the owner's credential does not — same 404 shape as a
// nonexistent id, so a non-admin cannot even confirm it exists.
const otherRevoke = await revoke(ownerCred.id);
expect(otherRevoke.status).toBe(404);
const stillActive = await prisma.agentCredential.findUniqueOrThrow({
Expand All @@ -126,10 +143,7 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
});

it("lets an OWNER revoke a member's credential", async () => {
session.current = { user: { id: member, orgId } };
const memberCred = (
(await (await create({ name: "revoke-me" })).json()) as { credential: { id: string } }
).credential;
const memberCred = await seedCredential(member, "revoke-me");

session.current = { user: { id: owner, orgId } };
const res = await revoke(memberCred.id);
Expand All @@ -145,8 +159,7 @@ describe.skipIf(!hasDb)("agent credential hardening (Postgres)", () => {
});

it("shows an OWNER every active credential in the org, across members", async () => {
session.current = { user: { id: member, orgId } };
await create({ name: "member-visible-to-owner" });
await seedCredential(member, "member-visible-to-owner");

session.current = { user: { id: owner, orgId } };
const res = await orgList();
Expand Down
15 changes: 15 additions & 0 deletions cloud/apps/web/src/app/api/settings/agent-credentials/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auth } from "@/auth";
import { createAgentToken } from "@ghost/core/agent-credentials";
import { isOrgAdmin } from "@ghost/core/roles";
import { prisma } from "@/lib/db";

export async function GET() {
Expand Down Expand Up @@ -34,6 +35,20 @@ export async function POST(req: Request) {
if (!session?.user.id || !session.user.orgId) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}

// Minting a credential that can start runs is an org-admin action. A MEMBER
// can still *use* a key an admin handed them (or revoke their own), but they
// cannot expand the attack surface by creating more.
const membership = await prisma.membership.findUnique({
where: {
userId_orgId: { userId: session.user.id, orgId: session.user.orgId },
},
select: { role: true },
});
if (!membership || !isOrgAdmin(membership.role)) {
return Response.json({ error: "forbidden" }, { status: 403 });
}

const body = (await req.json().catch(() => null)) as {
name?: unknown;
expiresInDays?: unknown;
Expand Down
95 changes: 32 additions & 63 deletions cloud/apps/web/src/lib/agent-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

/**
* The agent surface is excluded from the middleware matcher, so the
* second-factor gate that covers every other session-authenticated route has
* to be enforced inside `resolveAgentPrincipal`.
* The agent surface is excluded from the middleware matcher (bearer clients
* have no session), so it must refuse a browser session cookie entirely —
* not merely MFA-gate it. Otherwise a stolen session can `POST /api/agent/runs`
* while `/api/runs` is correctly challenged.
*
* Without it, a stolen session cookie for an MFA-enabled user could start a
* run through `POST /api/agent/runs` — against the customer's real systems —
* while the identical action through `POST /api/runs` was correctly
* challenged. That is the second factor covering everything except the
* endpoint that moves money.
*
* These tests pin the three cases that matter. They are hermetic: no database,
* no session, just the branch logic.
* These tests pin the refusal. They are hermetic: no database.
*/

const state = vi.hoisted(() => ({
session: null as null | {
user: { id?: string; orgId?: string; mfaEnabled?: boolean; sid?: string };
vi.mock("@/lib/db", () => ({
prisma: {
agentCredential: {
findUnique: async () => null,
update: async () => undefined,
},
membership: { findUnique: async () => null },
},
mfaCookie: undefined as string | undefined,
mfaVerifies: false,
}));

vi.mock("@/auth", () => ({ auth: async () => state.session }));

vi.mock("next/headers", () => ({
cookies: async () => ({ get: () => (state.mfaCookie ? { value: state.mfaCookie } : undefined) }),
}));

vi.mock("@/lib/mfa-cookie", () => ({
MFA_COOKIE_NAME: "ghost_mfa",
verifyMfaCookie: async () => state.mfaVerifies,
}));

vi.mock("@/lib/db", () => ({ prisma: {} }));

const { resolveAgentPrincipal } = await import("@/lib/agent-auth");

/** No Authorization header, so resolution falls to the session path. */
function sessionRequest() {
function bareRequest() {
return new Request("https://ghost.test/api/agent/runs", { method: "POST" });
}

beforeEach(() => {
state.session = { user: { id: "u1", orgId: "o1", mfaEnabled: false, sid: "s1" } };
state.mfaCookie = undefined;
state.mfaVerifies = false;
});

describe("resolveAgentPrincipal — session path", () => {
it("admits a user who has not enabled two-factor", async () => {
const result = await resolveAgentPrincipal(sessionRequest());
expect(result).toMatchObject({ ok: true, principal: { orgId: "o1", via: "session" } });
});

it("refuses an MFA-enabled user whose challenge has not been answered", async () => {
state.session = { user: { id: "u1", orgId: "o1", mfaEnabled: true, sid: "s1" } };
state.mfaVerifies = false;

const result = await resolveAgentPrincipal(sessionRequest());

// 403, matching what the middleware returns for an API path — a distinct
// status from 401, because the caller *is* authenticated and needs to do
// something specific about it.
expect(result).toMatchObject({ ok: false, status: 403 });
function bearerRequest(token: string) {
return new Request("https://ghost.test/api/agent/runs", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}

it("admits an MFA-enabled user who has answered the challenge", async () => {
state.session = { user: { id: "u1", orgId: "o1", mfaEnabled: true, sid: "s1" } };
state.mfaCookie = "signed-cookie";
state.mfaVerifies = true;
beforeEach(() => {
vi.clearAllMocks();
});

const result = await resolveAgentPrincipal(sessionRequest());
expect(result).toMatchObject({ ok: true, principal: { userId: "u1", via: "session" } });
describe("resolveAgentPrincipal — bearer only", () => {
it("refuses a caller with no Authorization header", async () => {
const result = await resolveAgentPrincipal(bareRequest());
expect(result).toMatchObject({
ok: false,
status: 401,
error: expect.stringMatching(/agent api key required/i),
});
});

it("still refuses an unauthenticated caller with 401", async () => {
state.session = null;
const result = await resolveAgentPrincipal(sessionRequest());
expect(result).toMatchObject({ ok: false, status: 401 });
it("refuses an invalid bearer token", async () => {
const result = await resolveAgentPrincipal(bearerRequest("not-a-real-key"));
expect(result).toMatchObject({ ok: false, status: 401, error: "invalid agent api key" });
});
});
Loading
Loading