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
41 changes: 34 additions & 7 deletions packages/db/src/policies.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import type { PolicyId, RunId } from "@agentops/core";
import { createPolicyId } from "@agentops/core";
import type { Policy, PolicySeverity } from "@agentops/core";
import type { AgentOpsDb } from "./connection.js";
import { policies, policyResults } from "./schema.js";
import { policies, policyResults, runs } from "./schema.js";

interface ListPoliciesFilters {
type?: string;
Expand Down Expand Up @@ -224,6 +224,10 @@ export function deletePolicy(
export function getPolicyResultsForPolicy(
db: AgentOpsDb,
policyId: PolicyId,
// When set, only results whose run belongs to this user are returned.
// Policy results reference runs across every user, so member-facing
// callers must pass their own user id; omitting it is the admin view.
ownedByUserId?: string,
): Array<{
id: string;
runId: string;
Expand All @@ -233,11 +237,34 @@ export function getPolicyResultsForPolicy(
details: Record<string, unknown>;
evaluatedAt: string;
}> {
const rows = db
.select()
.from(policyResults)
.where(eq(policyResults.policyId, policyId as string))
.all() as DbPolicyResult[];
const resultColumns = {
id: policyResults.id,
runId: policyResults.runId,
policyId: policyResults.policyId,
passed: policyResults.passed,
message: policyResults.message,
details: policyResults.details,
evaluatedAt: policyResults.evaluatedAt,
};
const rows = (
ownedByUserId === undefined
? db
.select(resultColumns)
.from(policyResults)
.where(eq(policyResults.policyId, policyId as string))
.all()
: db
.select(resultColumns)
.from(policyResults)
.innerJoin(runs, eq(policyResults.runId, runs.id))
.where(
and(
eq(policyResults.policyId, policyId as string),
eq(runs.userId, ownedByUserId),
),
)
.all()
) as DbPolicyResult[];

return rows.map((row) => ({
id: row.id,
Expand Down
133 changes: 133 additions & 0 deletions packages/web/src/app/api/__tests__/auth-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import {
insertRun,
insertPolicy,
insertPolicyResult,
insertSession,
runMetrics,
type AgentOpsDb,
} from "@agentops/db";
import {
Expand Down Expand Up @@ -65,6 +67,10 @@ import { GET as activeSessionsRoute } from "@/app/api/sessions/active/route";
import { GET as usageLocalRoute } from "@/app/api/usage/local/route";
import { GET as usageByUserRoute } from "@/app/api/usage/by-user/route";
import { POST as searchMetaRoute } from "@/app/api/runs/search/route";
import { GET as runAgentsRoute } from "@/app/api/runs/[id]/agents/route";
import { GET as runMetricsRoute } from "@/app/api/runs/[id]/metrics/route";
import { GET as runPoliciesRoute } from "@/app/api/runs/[id]/policies/route";
import { GET as policyResultsRoute } from "@/app/api/policies/[id]/results/route";

let db: AgentOpsDb;
let admin: TestUser;
Expand Down Expand Up @@ -494,3 +500,130 @@ describe("POST /api/runs/search (filter options) scoping", () => {
expect(body.repos).toEqual(["owner/repo"]);
});
});

// ─── Run sub-resource + policy-results routes (July 2026 audit) ────────────────
// These four GET routes shipped without requireUser or ownership checks and
// leaked cross-tenant metrics, event timelines, and policy results.

describe("run sub-resource routes ownership", () => {
function seedMetrics(runId: string) {
db.insert(runMetrics)
.values({
id: `rm_${runId}`,
runId,
tokenUsage: { input: 100, output: 50, total: 150 },
wallTimeMs: 1000,
costCents: 500,
flakeRate: 0,
recordedAt: "2025-01-01T00:00:00.000Z",
})
.run();
}

const cases = [
{
name: "GET /api/runs/[id]/metrics",
call: (runId: string, token?: string) =>
runMetricsRoute(
token
? authedRequest(`http://localhost/api/runs/${runId}/metrics`, { method: "GET", token })
: anonRequest(`http://localhost/api/runs/${runId}/metrics`, { method: "GET" }),
withParams({ id: runId }),
),
},
{
name: "GET /api/runs/[id]/agents",
call: (runId: string, token?: string) =>
runAgentsRoute(
token
? authedRequest(`http://localhost/api/runs/${runId}/agents`, { method: "GET", token })
: anonRequest(`http://localhost/api/runs/${runId}/agents`, { method: "GET" }),
withParams({ id: runId }),
),
},
{
name: "GET /api/runs/[id]/policies",
call: (runId: string, token?: string) =>
runPoliciesRoute(
token
? authedRequest(`http://localhost/api/runs/${runId}/policies`, { method: "GET", token })
: anonRequest(`http://localhost/api/runs/${runId}/policies`, { method: "GET" }),
withParams({ id: runId }),
),
},
];

for (const { name, call } of cases) {
it(`${name}: 401 anon, 404 non-owner, 200 owner, 200 admin`, async () => {
const run = makeRun({ userId: owner.user.id });
seedMetrics(run.id as string);

expect((await call(run.id as string)).status).toBe(401);
// 404 (not 403) for the non-owner — no existence leak.
expect((await call(run.id as string, other.token)).status).toBe(404);
expect((await call(run.id as string, owner.token)).status).toBe(200);
expect((await call(run.id as string, admin.token)).status).toBe(200);
});

it(`${name}: null-owner (pre-auth) runs are admin-only`, async () => {
const run = makeRun({});
seedMetrics(run.id as string);
expect((await call(run.id as string, owner.token)).status).toBe(404);
expect((await call(run.id as string, admin.token)).status).toBe(200);
});
}
});

describe("GET /api/policies/[id]/results scoping", () => {
function seedResult(id: string, runId: string, policyId = "pol_test") {
insertPolicyResult(db, {
id,
runId,
policyId,
passed: true,
message: "ok",
details: {},
evaluatedAt: "2025-01-01T00:00:00.000Z",
});
}

it("401 without auth", async () => {
seedPolicy();
const res = await policyResultsRoute(
anonRequest("http://localhost/api/policies/pol_test/results", { method: "GET" }),
withParams({ id: "pol_test" }),
);
expect(res.status).toBe(401);
});

it("members only see results for their own runs; admins see all", async () => {
seedPolicy();
const ownerRun = makeRun({ userId: owner.user.id });
const otherRun = makeRun({ userId: other.user.id });
seedResult("pr_owner", ownerRun.id as string);
seedResult("pr_other", otherRun.id as string);

const memberRes = await policyResultsRoute(
authedRequest("http://localhost/api/policies/pol_test/results", {
method: "GET",
token: owner.token,
}),
withParams({ id: "pol_test" }),
);
expect(memberRes.status).toBe(200);
const memberBody = (await jsonOf(memberRes)) as Array<{ runId: string }>;
expect(memberBody.map((r) => r.runId)).toEqual([ownerRun.id as string]);

const adminRes = await policyResultsRoute(
authedRequest("http://localhost/api/policies/pol_test/results", {
method: "GET",
token: admin.token,
}),
withParams({ id: "pol_test" }),
);
const adminBody = (await jsonOf(adminRes)) as Array<{ runId: string }>;
expect(adminBody.map((r) => r.runId).sort()).toEqual(
[ownerRun.id as string, otherRun.id as string].sort(),
);
});
});
15 changes: 13 additions & 2 deletions packages/web/src/app/api/policies/[id]/results/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@ import { NextRequest, NextResponse } from "next/server";
import { getPolicyResultsForPolicy } from "@agentops/db";
import { createPolicyId } from "@agentops/core";
import { db } from "@/lib/db";
import { requireUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = await requireUser(request);
if (user instanceof NextResponse) return user;

try {
const { id } = await params;
const results = getPolicyResultsForPolicy(db(), createPolicyId(id));
// Policy results reference runs across every user. Members only see
// results for their own runs; admins see everything (matching the
// view-scoping rules used by the runs/sessions lists).
const results = getPolicyResultsForPolicy(
db(),
createPolicyId(id),
user.role === "admin" ? undefined : user.id,
);
return NextResponse.json(results);
} catch (error) {
console.error("API error:", error);
Expand Down
20 changes: 16 additions & 4 deletions packages/web/src/app/api/runs/[id]/agents/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getEventsBySource } from "@agentops/db";
import { buildAgentTimeline } from "@agentops/core";
import { getEventsBySource, getRun } from "@agentops/db";
import { buildAgentTimeline, createRunId } from "@agentops/core";
import { db } from "@/lib/db";
import { requireUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = await requireUser(request);
if (user instanceof NextResponse) return user;

try {
const { id } = await params;
const events = getEventsBySource(db(), id, 500);
const d = db();
// Same ownership rules as GET /api/runs/[id]: members only see their
// own runs; pre-auth runs (userId == null) are admin-only. 404 (not
// 403) on non-owner so run IDs can't be enumerated.
const run = getRun(d, createRunId(id));
if (!run || (user.role !== "admin" && run.userId !== user.id)) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
const events = getEventsBySource(d, id, 500);
const timeline = buildAgentTimeline(events);
return NextResponse.json({ timeline });
} catch (error) {
Expand Down
19 changes: 16 additions & 3 deletions packages/web/src/app/api/runs/[id]/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getRunMetrics } from "@agentops/db";
import { getRunMetrics, getRun } from "@agentops/db";
import { createRunId } from "@agentops/core";
import { db } from "@/lib/db";
import { requireUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = await requireUser(request);
if (user instanceof NextResponse) return user;

try {
const { id } = await params;
const metrics = getRunMetrics(db(), createRunId(id));
const d = db();
const runId = createRunId(id);
// Same ownership rules as GET /api/runs/[id]: members only see their
// own runs; pre-auth runs (userId == null) are admin-only. 404 (not
// 403) on non-owner so run IDs can't be enumerated.
const run = getRun(d, runId);
if (!run || (user.role !== "admin" && run.userId !== user.id)) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
const metrics = getRunMetrics(d, runId);
if (!metrics) {
return NextResponse.json({ error: "Metrics not found" }, { status: 404 });
}
Expand Down
19 changes: 16 additions & 3 deletions packages/web/src/app/api/runs/[id]/policies/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getPolicyResults } from "@agentops/db";
import { getPolicyResults, getRun } from "@agentops/db";
import { createRunId } from "@agentops/core";
import { db } from "@/lib/db";
import { requireUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = await requireUser(request);
if (user instanceof NextResponse) return user;

try {
const { id } = await params;
const results = getPolicyResults(db(), createRunId(id));
const d = db();
const runId = createRunId(id);
// Same ownership rules as GET /api/runs/[id]: members only see their
// own runs; pre-auth runs (userId == null) are admin-only. 404 (not
// 403) on non-owner so run IDs can't be enumerated.
const run = getRun(d, runId);
if (!run || (user.role !== "admin" && run.userId !== user.id)) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
const results = getPolicyResults(d, runId);
return NextResponse.json(results);
} catch (error) {
console.error("API error:", error);
Expand Down
Loading