Skip to content

Commit 8e10526

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): accept a billing service secret on the billing admin routes
The admin API routes used by the billing service accept a dedicated service secret, set with `BILLING_SERVICE_SECRET`, in addition to admin personal access tokens. Mono-RevId: 957e550be1b6f85154b7a5720b1caa425c97957c
1 parent 88c5745 commit 8e10526

10 files changed

Lines changed: 158 additions & 14 deletions

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ const EnvironmentSchema = z
429429
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
430430
// Instance-level kill switch for the admin dashboard and user impersonation.
431431
ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true),
432+
BILLING_SERVICE_SECRET: z.string().min(32).optional(),
432433
REMIX_APP_PORT: z.string().optional(),
433434
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
434435
// Read directly from process.env in server.ts (before this schema loads); declared here for discoverability.

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.hit.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type BillingLimitHitWebhookBody,
77
} from "~/services/billingLimit.schemas";
88
import { logger } from "~/services/logger.server";
9-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
9+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
1010
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
1111
import {
1212
enqueueBillingLimitCancelInProgressRuns,
@@ -21,7 +21,7 @@ const ParamsSchema = z.object({
2121

2222
/** Billing platform webhook: org entered billing limit grace. Idempotent — returns 202. */
2323
export async function action({ request, params }: ActionFunctionArgs) {
24-
await requireAdminApiRequest(request);
24+
await requireBillingServiceOrAdminApiRequest(request);
2525

2626
if (request.method.toLowerCase() !== "post") {
2727
return json({ error: "Method not allowed" }, { status: 405 });

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.reject.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
55
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
66
import { BillingLimitConvergeEnvironmentsService } from "~/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server";
77
import { enqueueBillingLimitConverge } from "~/v3/billingLimitWorker.server";
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({
1212

1313
/** Billing platform webhook: org billing limit grace expired. Idempotent — returns 202. */
1414
export async function action({ request, params }: ActionFunctionArgs) {
15-
await requireAdminApiRequest(request);
15+
await requireBillingServiceOrAdminApiRequest(request);
1616

1717
if (request.method.toLowerCase() !== "post") {
1818
return json({ error: "Method not allowed" }, { status: 405 });

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.resolve.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
55
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
66
import { logger } from "~/services/logger.server";
77
import { enqueueBillingLimitResolve } from "~/v3/billingLimitWorker.server";
@@ -19,7 +19,7 @@ const BodySchema = z.object({
1919

2020
/** Billing platform webhook: org resolved billing limit to ok. Idempotent — returns 202. */
2121
export async function action({ request, params }: ActionFunctionArgs) {
22-
await requireAdminApiRequest(request);
22+
await requireBillingServiceOrAdminApiRequest(request);
2323

2424
if (request.method.toLowerCase() !== "post") {
2525
return json({ error: "Method not allowed" }, { status: 405 });

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
66
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
77
import { setOrgConcurrencyLimits } from "~/v3/services/orgPlanProvisioning.server";
88
import { enqueueOrgProvisioningJob } from "~/v3/services/orgProvisioningJobs.server";
@@ -19,7 +19,7 @@ const RequestBodySchema = z.object({
1919
});
2020

2121
export async function action({ request, params }: ActionFunctionArgs) {
22-
await requireAdminApiRequest(request);
22+
await requireBillingServiceOrAdminApiRequest(request);
2323

2424
const { organizationId } = ParamsSchema.parse(params);
2525

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
55
import { enqueueOrgProvisioningJob } from "~/v3/services/orgProvisioningJobs.server";
66

77
const ParamsSchema = z.object({
88
organizationId: z.string(),
99
});
1010

1111
export async function action({ request, params }: ActionFunctionArgs) {
12-
await requireAdminApiRequest(request);
12+
await requireBillingServiceOrAdminApiRequest(request);
1313

1414
const { organizationId } = ParamsSchema.parse(params);
1515

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
55
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
66
import { enqueueOrgProvisioningJob } from "~/v3/services/orgProvisioningJobs.server";
77

@@ -14,7 +14,7 @@ const BodySchema = z.object({
1414
});
1515

1616
export async function action({ request, params }: ActionFunctionArgs) {
17-
await requireAdminApiRequest(request);
17+
await requireBillingServiceOrAdminApiRequest(request);
1818

1919
const { organizationId } = ParamsSchema.parse(params);
2020
const body = BodySchema.safeParse(await request.json());

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.stream-basin.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
4-
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
55
import { isValidDuration } from "~/services/realtime/duration.server";
66
import {
77
deprovisionBasinForOrg,
@@ -21,7 +21,7 @@ const BodySchema = z.discriminatedUnion("action", [
2121
]);
2222

2323
export async function action({ request, params }: ActionFunctionArgs) {
24-
await requireAdminApiRequest(request);
24+
await requireBillingServiceOrAdminApiRequest(request);
2525

2626
const { organizationId } = ParamsSchema.parse(params);
2727

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createHash, timingSafeEqual } from "node:crypto";
2+
import { env } from "~/env.server";
3+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
4+
5+
/**
6+
* Guards the admin API routes the billing service calls. Accepts the billing service secret
7+
* (`BILLING_SERVICE_SECRET`) or, as on every other admin route, an admin's personal access token.
8+
*/
9+
export async function requireBillingServiceOrAdminApiRequest(request: Request): Promise<void> {
10+
if (isBillingServiceRequest(request, env.BILLING_SERVICE_SECRET)) {
11+
return;
12+
}
13+
14+
await requireAdminApiRequest(request);
15+
}
16+
17+
function isBillingServiceRequest(request: Request, secret: string | undefined): boolean {
18+
if (!secret) {
19+
return false;
20+
}
21+
22+
const authorization = request.headers.get("Authorization");
23+
if (!authorization?.startsWith("Bearer ")) {
24+
return false;
25+
}
26+
27+
return digestEquals(authorization.slice("Bearer ".length), secret);
28+
}
29+
30+
function digestEquals(a: string, b: string): boolean {
31+
return timingSafeEqual(
32+
createHash("sha256").update(a).digest(),
33+
createHash("sha256").update(b).digest()
34+
);
35+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { beforeEach, describe, expect, vi } from "vitest";
2+
3+
const SECRET = "billing-service-secret-0123456789abcdef";
4+
5+
const db = vi.hoisted(() => ({ client: null as any }));
6+
const env = vi.hoisted(() => ({
7+
SESSION_SECRET: "test-session-secret-for-billing-service-auth",
8+
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef",
9+
BILLING_SERVICE_SECRET: undefined as string | undefined,
10+
}));
11+
12+
vi.mock("~/db.server", () => ({
13+
get prisma() {
14+
return db.client;
15+
},
16+
get $replica() {
17+
return db.client;
18+
},
19+
}));
20+
vi.mock("~/env.server", () => ({ env }));
21+
vi.mock("~/services/logger.server", () => ({
22+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
23+
}));
24+
vi.mock("~/services/rbac.server", () => ({
25+
rbac: { isUsingPlugin: async () => false, setTokenRole: async () => ({ ok: true }) },
26+
}));
27+
28+
import { postgresTest } from "@internal/testcontainers";
29+
import { requireBillingServiceOrAdminApiRequest } from "~/services/billingServiceAuth.server";
30+
import {
31+
createPersonalAccessToken,
32+
requireAdminApiRequest,
33+
} from "~/services/personalAccessToken.server";
34+
35+
vi.setConfig({ testTimeout: 60_000 });
36+
37+
let userCounter = 0;
38+
39+
async function patFor(prisma: any, admin: boolean) {
40+
userCounter += 1;
41+
const user = await prisma.user.create({
42+
data: {
43+
email: `billing-service-auth-${userCounter}-${Date.now()}@example.com`,
44+
authenticationMethod: "MAGIC_LINK",
45+
admin,
46+
},
47+
});
48+
const pat = await createPersonalAccessToken({ name: "test", userId: user.id });
49+
return (pat as { token: string }).token;
50+
}
51+
52+
function bearer(token: string) {
53+
return new Request("http://localhost/admin/api/v1/orgs/org_1/runs/enable", {
54+
method: "POST",
55+
headers: { Authorization: `Bearer ${token}` },
56+
});
57+
}
58+
59+
async function statusOf(guard: Promise<unknown>) {
60+
try {
61+
await guard;
62+
return 200;
63+
} catch (error) {
64+
if (error instanceof Response) return error.status;
65+
throw error;
66+
}
67+
}
68+
69+
describe("billing service admin API auth", () => {
70+
beforeEach(() => {
71+
env.BILLING_SERVICE_SECRET = SECRET;
72+
});
73+
74+
/** Catches: billing's calls failing once the secret is configured. */
75+
postgresTest("billing routes accept the billing service secret", async ({ prisma }) => {
76+
db.client = prisma;
77+
expect(await statusOf(requireBillingServiceOrAdminApiRequest(bearer(SECRET)))).toBe(200);
78+
});
79+
80+
/** Catches: admins losing access to the billing routes, or the fallback accepting any user's PAT. */
81+
postgresTest("billing routes still take admin PATs and refuse others", async ({ prisma }) => {
82+
db.client = prisma;
83+
const adminPat = await patFor(prisma, true);
84+
const userPat = await patFor(prisma, false);
85+
86+
expect(await statusOf(requireBillingServiceOrAdminApiRequest(bearer(adminPat)))).toBe(200);
87+
expect(await statusOf(requireBillingServiceOrAdminApiRequest(bearer(userPat)))).toBe(403);
88+
});
89+
90+
/** Catches: a near-match of the secret being accepted. */
91+
postgresTest("billing routes refuse a wrong secret", async ({ prisma }) => {
92+
db.client = prisma;
93+
expect(await statusOf(requireBillingServiceOrAdminApiRequest(bearer(`${SECRET}x`)))).toBe(401);
94+
});
95+
96+
/** Catches: installs without billing accepting a bearer value because no secret is configured. */
97+
postgresTest("the secret does nothing when it isn't configured", async ({ prisma }) => {
98+
db.client = prisma;
99+
env.BILLING_SERVICE_SECRET = undefined;
100+
expect(await statusOf(requireBillingServiceOrAdminApiRequest(bearer(SECRET)))).toBe(401);
101+
});
102+
103+
/** Catches: the billing secret working as an admin credential on the other admin routes. */
104+
postgresTest("other admin routes refuse the secret", async ({ prisma }) => {
105+
db.client = prisma;
106+
expect(await statusOf(requireAdminApiRequest(bearer(SECRET)))).toBe(401);
107+
});
108+
});

0 commit comments

Comments
 (0)