Skip to content

Commit 0d3e54d

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
fix: require billing permission to allocate concurrency and apply allocations atomically
Reallocating purchased concurrency across environments now requires billing permissions, matching purchases, and allocations are applied atomically so simultaneous changes can no longer exceed the purchased amount. Mono-RevId: 0ff535b7126ef285de9d815196545022ae2236b5
1 parent fade75b commit 0d3e54d

7 files changed

Lines changed: 1137 additions & 67 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Allocating extra concurrency to environments now requires billing permissions, the same as purchasing it. Allocation changes are also applied atomically, so simultaneous edits can no longer exceed your purchased concurrency.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency-limits/route.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { rbac } from "~/services/rbac.server";
6666
import { requireUserId } from "~/services/session.server";
6767
import { cn } from "~/utils/cn";
6868
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
69-
import { isPaidAddOnPurchase } from "~/utils/paidAddOnPermissions";
69+
import { requiresManageBilling } from "~/utils/paidAddOnPermissions";
7070
import { concurrencyLimitsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
7171
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
7272
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
@@ -170,15 +170,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
170170
return json(submission.reply());
171171
}
172172

173-
if (isPaidAddOnPurchase(submission.value.action)) {
173+
if (requiresManageBilling(submission.value.action)) {
174174
const auth = await rbac.authenticateSession(request, {
175175
userId,
176176
organizationId: project.organizationId,
177177
});
178178
if (!auth.ok || !auth.ability.can("manage", { type: "billing" })) {
179+
const permissionError = ["You don't have permission to manage billing."];
179180
return json(
180181
submission.reply({
181-
fieldErrors: { amount: ["You don't have permission to manage billing."] },
182+
fieldErrors:
183+
submission.value.action === "allocate"
184+
? { environments: permissionError }
185+
: { amount: permissionError },
182186
}),
183187
{ status: 403 }
184188
);
@@ -479,7 +483,12 @@ function Upgradable({
479483
variant="primary/small"
480484
type="submit"
481485
form="allocate"
482-
disabled={unallocated < 0 || isLoading}
486+
disabled={!canManageBilling || unallocated < 0 || isLoading}
487+
tooltip={
488+
canManageBilling
489+
? undefined
490+
: "You don't have permission to manage billing"
491+
}
483492
LeadingIcon={isLoading ? SpinnerWhite : undefined}
484493
>
485494
Save
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
export function isPaidAddOnPurchase(action: string): boolean {
22
return action === "purchase";
33
}
4+
5+
/**
6+
* Allocating purchased concurrency consumes the org-wide unallocated pool and changes live
7+
* environment limits, so it is gated by the same manage-billing permission as a purchase.
8+
*/
9+
export function requiresManageBilling(action: string): boolean {
10+
return isPaidAddOnPurchase(action) || action === "allocate";
11+
}

‎apps/webapp/app/utils/securityBoundaries.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import { billingMessageFromKey, billingMessages } from "./billingMessages";
3-
import { isPaidAddOnPurchase } from "./paidAddOnPermissions";
3+
import { isPaidAddOnPurchase, requiresManageBilling } from "./paidAddOnPermissions";
44

55
describe("billing messages", () => {
66
it("resolves known message keys and rejects arbitrary copy", () => {
@@ -19,4 +19,10 @@ describe("paid add-on permissions", () => {
1919
expect(isPaidAddOnPurchase("quota-increase")).toBe(false);
2020
expect(isPaidAddOnPurchase("allocate")).toBe(false);
2121
});
22+
23+
it("requires manage billing for purchases and allocations but not quota requests", () => {
24+
expect(requiresManageBilling("purchase")).toBe(true);
25+
expect(requiresManageBilling("allocate")).toBe(true);
26+
expect(requiresManageBilling("quota-increase")).toBe(false);
27+
});
2228
});

0 commit comments

Comments
 (0)