Skip to content

Commit 9ba38d1

Browse files
committed
fix(webapp,run-engine): scope batch stream grants to the environment and guard batch completion
Grants are now keyed by environment as well as batch, and are only spent once the caller has been authenticated, so knowing a batch id is not enough to drain another environment grant. Batch completion no longer overwrites a terminal batch. An in-flight completion that read the batch before it was aborted could previously flip it back to completed and complete the waitpoint a second time with a success payload.
1 parent 5389588 commit 9ba38d1

7 files changed

Lines changed: 170 additions & 114 deletions

File tree

apps/webapp/app/runEngine/concerns/batchStreamGrants.server.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@ export class BatchStreamGrants {
3535
* Grant a newly created batch its phase 2 budget. Never throws: a batch that fails to get
3636
* a grant still works, it just falls back to the general rate limiter for streaming.
3737
*/
38-
async mint(batchId: string): Promise<void> {
38+
async mint(environmentId: string, batchId: string): Promise<void> {
3939
try {
40-
await this.redis.set(this.#key(batchId), this.options.attempts, "PX", this.options.ttlMs);
40+
await this.redis.set(
41+
this.#key(environmentId, batchId),
42+
this.options.attempts,
43+
"PX",
44+
this.options.ttlMs
45+
);
4146
} catch (error) {
4247
logger.warn("BatchStreamGrants: failed to mint grant", {
4348
batchId,
@@ -53,10 +58,12 @@ export class BatchStreamGrants {
5358
* unreachable, so the caller falls back to the general rate limiter rather than opening
5459
* an unbounded bypass.
5560
*/
56-
async spend(batchId: string): Promise<boolean> {
61+
async spend(environmentId: string, batchId: string): Promise<boolean> {
5762
try {
5863
// @ts-expect-error - Custom command defined via defineCommand
59-
const remaining = (await this.redis.spendBatchStreamGrant(this.#key(batchId))) as number;
64+
const remaining = (await this.redis.spendBatchStreamGrant(
65+
this.#key(environmentId, batchId)
66+
)) as number;
6067

6168
return remaining >= 0;
6269
} catch (error) {
@@ -73,8 +80,12 @@ export class BatchStreamGrants {
7380
await this.redis.quit();
7481
}
7582

76-
#key(batchId: string): string {
77-
return `${KEY_PREFIX}${batchId}`;
83+
/**
84+
* Scoped to the environment as well as the batch, so a caller authenticated against a
85+
* different environment can never spend this batch's grant even if they know its id.
86+
*/
87+
#key(environmentId: string, batchId: string): string {
88+
return `${KEY_PREFIX}${environmentId}:${batchId}`;
7889
}
7990

8091
#registerCommands(): void {

apps/webapp/app/runEngine/services/createBatch.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export class CreateBatchService extends WithRunEngine {
122122

123123
this.onBatchTaskRunCreated.post(batch);
124124

125-
await batchStreamGrants.mint(friendlyId);
125+
await batchStreamGrants.mint(environment.id, friendlyId);
126126

127127
// Block parent run if this is a batchTriggerAndWait
128128
if (body.parentRunId && body.resumeParentOnCompletion) {

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,22 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
8686
}
8787

8888
const batchFriendlyId = match[1];
89+
const authorizationValue = req.headers.authorization;
8990

90-
if (!batchFriendlyId) {
91+
if (!batchFriendlyId || !authorizationValue) {
9192
return false;
9293
}
9394

94-
return batchStreamGrants.spend(batchFriendlyId);
95+
const authenticated = await authenticateAuthorizationHeader(authorizationValue, {
96+
allowPublicKey: true,
97+
allowJWT: true,
98+
});
99+
100+
if (!authenticated || !authenticated.ok) {
101+
return false;
102+
}
103+
104+
return batchStreamGrants.spend(authenticated.environment.id, batchFriendlyId);
95105
},
96106
log: {
97107
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",

apps/webapp/test/authorizationRateLimitMiddleware.test.ts

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -415,81 +415,4 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware",
415415
expect(configOverrideCalls).toBe(3);
416416
});
417417
});
418-
419-
describe("bypass", () => {
420-
const exhaustedLimiter = {
421-
type: "tokenBucket",
422-
refillRate: 1,
423-
interval: "1m",
424-
maxTokens: 1,
425-
} as const;
426-
427-
redisTest("lets a bypassed request through an exhausted limit", async ({ redisOptions }) => {
428-
const rateLimitMiddleware = authorizationRateLimitMiddleware({
429-
redis: { ...redisOptions, tlsDisabled: true },
430-
keyPrefix: "test",
431-
defaultLimiter: exhaustedLimiter,
432-
pathMatchers: [/^\/api/],
433-
bypass: async (req) => req.path === "/api/granted",
434-
});
435-
436-
app.use(rateLimitMiddleware);
437-
app.get("/api/granted", (req, res) => res.status(200).json({ message: "Granted" }));
438-
app.get("/api/limited", (req, res) => res.status(200).json({ message: "Limited" }));
439-
440-
await request(app).get("/api/limited").set("Authorization", "Bearer test-token");
441-
const limited = await request(app)
442-
.get("/api/limited")
443-
.set("Authorization", "Bearer test-token");
444-
expect(limited.status).toBe(429);
445-
446-
const granted = await request(app)
447-
.get("/api/granted")
448-
.set("Authorization", "Bearer test-token");
449-
450-
expect(granted.status).toBe(200);
451-
expect(granted.body).toEqual({ message: "Granted" });
452-
});
453-
454-
redisTest("falls back to the limiter when the bypass declines", async ({ redisOptions }) => {
455-
const rateLimitMiddleware = authorizationRateLimitMiddleware({
456-
redis: { ...redisOptions, tlsDisabled: true },
457-
keyPrefix: "test",
458-
defaultLimiter: exhaustedLimiter,
459-
pathMatchers: [/^\/api/],
460-
bypass: async () => false,
461-
});
462-
463-
app.use(rateLimitMiddleware);
464-
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
465-
466-
await request(app).get("/api/test").set("Authorization", "Bearer declined");
467-
const response = await request(app).get("/api/test").set("Authorization", "Bearer declined");
468-
469-
expect(response.status).toBe(429);
470-
});
471-
472-
redisTest("does not let the bypass skip authentication", async ({ redisOptions }) => {
473-
let bypassCalled = false;
474-
475-
const rateLimitMiddleware = authorizationRateLimitMiddleware({
476-
redis: { ...redisOptions, tlsDisabled: true },
477-
keyPrefix: "test",
478-
defaultLimiter: exhaustedLimiter,
479-
pathMatchers: [/^\/api/],
480-
bypass: async () => {
481-
bypassCalled = true;
482-
return true;
483-
},
484-
});
485-
486-
app.use(rateLimitMiddleware);
487-
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
488-
489-
const response = await request(app).get("/api/test");
490-
491-
expect(response.status).toBe(401);
492-
expect(bypassCalled).toBe(false);
493-
});
494-
});
495418
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import { beforeEach, describe, expect, vi } from "vitest";
3+
4+
vi.setConfig({ testTimeout: 30_000 });
5+
6+
import type { Express } from "express";
7+
import express from "express";
8+
import request from "supertest";
9+
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
10+
11+
const exhaustedLimiter = {
12+
type: "tokenBucket",
13+
refillRate: 1,
14+
interval: "1m",
15+
maxTokens: 1,
16+
} as const;
17+
18+
describe("authorizationRateLimitMiddleware bypass", () => {
19+
let app: Express;
20+
21+
beforeEach(() => {
22+
app = express();
23+
});
24+
25+
redisTest("lets a bypassed request through an exhausted limit", async ({ redisOptions }) => {
26+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
27+
redis: { ...redisOptions, tlsDisabled: true },
28+
keyPrefix: "test-bypass-allowed",
29+
defaultLimiter: exhaustedLimiter,
30+
pathMatchers: [/^\/api/],
31+
bypass: async (req) => req.path === "/api/granted",
32+
});
33+
34+
app.use(rateLimitMiddleware);
35+
app.get("/api/granted", (req, res) => res.status(200).json({ message: "Granted" }));
36+
app.get("/api/limited", (req, res) => res.status(200).json({ message: "Limited" }));
37+
38+
await request(app).get("/api/limited").set("Authorization", "Bearer test-token");
39+
const limited = await request(app)
40+
.get("/api/limited")
41+
.set("Authorization", "Bearer test-token");
42+
expect(limited.status).toBe(429);
43+
44+
const granted = await request(app)
45+
.get("/api/granted")
46+
.set("Authorization", "Bearer test-token");
47+
48+
expect(granted.status).toBe(200);
49+
expect(granted.body).toEqual({ message: "Granted" });
50+
});
51+
52+
redisTest("falls back to the limiter when the bypass declines", async ({ redisOptions }) => {
53+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
54+
redis: { ...redisOptions, tlsDisabled: true },
55+
keyPrefix: "test-bypass-declined",
56+
defaultLimiter: exhaustedLimiter,
57+
pathMatchers: [/^\/api/],
58+
bypass: async () => false,
59+
});
60+
61+
app.use(rateLimitMiddleware);
62+
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
63+
64+
await request(app).get("/api/test").set("Authorization", "Bearer declined");
65+
const response = await request(app).get("/api/test").set("Authorization", "Bearer declined");
66+
67+
expect(response.status).toBe(429);
68+
});
69+
70+
redisTest("does not let the bypass skip authentication", async ({ redisOptions }) => {
71+
let bypassCalled = false;
72+
73+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
74+
redis: { ...redisOptions, tlsDisabled: true },
75+
keyPrefix: "test-bypass-unauthenticated",
76+
defaultLimiter: exhaustedLimiter,
77+
pathMatchers: [/^\/api/],
78+
bypass: async () => {
79+
bypassCalled = true;
80+
return true;
81+
},
82+
});
83+
84+
app.use(rateLimitMiddleware);
85+
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
86+
87+
const response = await request(app).get("/api/test");
88+
89+
expect(response.status).toBe(401);
90+
expect(bypassCalled).toBe(false);
91+
});
92+
});

apps/webapp/test/batchStreamGrants.test.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ vi.mock("../app/services/logger.server", () => ({
1414

1515
import { BatchStreamGrants } from "../app/runEngine/concerns/batchStreamGrants.server.js";
1616

17-
describe.skipIf(process.env.GITHUB_ACTIONS)("BatchStreamGrants", () => {
17+
describe("BatchStreamGrants", () => {
18+
const ENV = "env_1";
19+
1820
redisTest("spends exactly the granted number of attempts", async ({ redisOptions }) => {
1921
const grants = new BatchStreamGrants({
2022
redis: { ...redisOptions, tlsDisabled: true },
@@ -23,13 +25,13 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("BatchStreamGrants", () => {
2325
});
2426

2527
try {
26-
await grants.mint("batch_spend");
28+
await grants.mint(ENV, "batch_spend");
2729

28-
expect(await grants.spend("batch_spend")).toBe(true);
29-
expect(await grants.spend("batch_spend")).toBe(true);
30-
expect(await grants.spend("batch_spend")).toBe(true);
31-
expect(await grants.spend("batch_spend")).toBe(false);
32-
expect(await grants.spend("batch_spend")).toBe(false);
30+
expect(await grants.spend(ENV, "batch_spend")).toBe(true);
31+
expect(await grants.spend(ENV, "batch_spend")).toBe(true);
32+
expect(await grants.spend(ENV, "batch_spend")).toBe(true);
33+
expect(await grants.spend(ENV, "batch_spend")).toBe(false);
34+
expect(await grants.spend(ENV, "batch_spend")).toBe(false);
3335
} finally {
3436
await grants.quit();
3537
}
@@ -43,7 +45,7 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("BatchStreamGrants", () => {
4345
});
4446

4547
try {
46-
expect(await grants.spend("batch_never_minted")).toBe(false);
48+
expect(await grants.spend(ENV, "batch_never_minted")).toBe(false);
4749
} finally {
4850
await grants.quit();
4951
}
@@ -57,12 +59,12 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("BatchStreamGrants", () => {
5759
});
5860

5961
try {
60-
await grants.mint("batch_a");
61-
await grants.mint("batch_b");
62+
await grants.mint(ENV, "batch_a");
63+
await grants.mint(ENV, "batch_b");
6264

63-
expect(await grants.spend("batch_a")).toBe(true);
64-
expect(await grants.spend("batch_a")).toBe(false);
65-
expect(await grants.spend("batch_b")).toBe(true);
65+
expect(await grants.spend(ENV, "batch_a")).toBe(true);
66+
expect(await grants.spend(ENV, "batch_a")).toBe(false);
67+
expect(await grants.spend(ENV, "batch_b")).toBe(true);
6668
} finally {
6769
await grants.quit();
6870
}
@@ -71,22 +73,39 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("BatchStreamGrants", () => {
7173
redisTest("expires the grant so it cannot outlive the seal window", async ({ redisOptions }) => {
7274
const grants = new BatchStreamGrants({
7375
redis: { ...redisOptions, tlsDisabled: true },
74-
attempts: 5,
76+
attempts: 100_000,
7577
ttlMs: 150,
7678
});
7779

7880
try {
79-
await grants.mint("batch_expiring");
80-
expect(await grants.spend("batch_expiring")).toBe(true);
81+
await grants.mint(ENV, "batch_expiring");
82+
expect(await grants.spend(ENV, "batch_expiring")).toBe(true);
8183

8284
await vi.waitFor(
8385
async () => {
84-
expect(await grants.spend("batch_expiring")).toBe(false);
86+
expect(await grants.spend(ENV, "batch_expiring")).toBe(false);
8587
},
8688
{ timeout: 5_000, interval: 50 }
8789
);
8890
} finally {
8991
await grants.quit();
9092
}
9193
});
94+
95+
redisTest("another environment cannot spend this batch's grant", async ({ redisOptions }) => {
96+
const grants = new BatchStreamGrants({
97+
redis: { ...redisOptions, tlsDisabled: true },
98+
attempts: 1,
99+
ttlMs: 60_000,
100+
});
101+
102+
try {
103+
await grants.mint(ENV, "batch_scoped");
104+
105+
expect(await grants.spend("env_intruder", "batch_scoped")).toBe(false);
106+
expect(await grants.spend(ENV, "batch_scoped")).toBe(true);
107+
} finally {
108+
await grants.quit();
109+
}
110+
});
92111
});

internal-packages/run-engine/src/engine/systems/batchSystem.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export class BatchSystem {
109109
return;
110110
}
111111

112-
const enqueuedCount = batch.successfulRunCount ?? 0;
112+
const enqueuedCount = (batch.successfulRunCount ?? 0) + (batch.failedRunCount ?? 0);
113113
const error: TaskRunError = {
114114
type: "STRING_ERROR",
115115
raw:
@@ -190,21 +190,22 @@ export class BatchSystem {
190190

191191
if (runs.every((r) => isFinalRunStatus(r.status))) {
192192
this.$.logger.debug("#tryCompleteBatch: All runs are completed", { batchId });
193-
await this.$.runStore.updateBatchTaskRun(
193+
194+
const completed = await this.$.runStore.updateManyBatchTaskRun(
194195
{
195-
where: {
196-
id: batchId,
197-
},
198-
data: {
199-
status: "COMPLETED",
200-
},
201-
select: {
202-
id: true,
203-
},
196+
where: { id: batchId, status: { notIn: ["ABORTED", "COMPLETED"] } },
197+
data: { status: "COMPLETED" },
204198
},
205199
this.$.prisma
206200
);
207201

202+
if (completed.count === 0) {
203+
this.$.logger.debug("#tryCompleteBatch: batch already reached a terminal status", {
204+
batchId,
205+
});
206+
return;
207+
}
208+
208209
//get waitpoint (if there is one)
209210
const waitpoint = await this.$.runStore.findWaitpoint(
210211
{

0 commit comments

Comments
 (0)