Skip to content

Commit bc13af5

Browse files
committed
fix(webapp): enforce write:prompts / update:prompts on prompt detail route + UI
Migrate the prompt detail action to dashboardAction and check the right permission per intent: promote -> update:prompts, create/edit/remove/ reactivate override -> write:prompts. Surface canPromote / canWritePrompts display flags from the loader (via the injected ability) and gate the Promote, Reactivate, Create override, Edit, and Remove buttons. Tenancy queries unchanged; permissive in OSS, enforced under the enterprise plugin.
1 parent a9b961b commit bc13af5

1 file changed

Lines changed: 140 additions & 84 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx

Lines changed: 140 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import * as Ariakit from "@ariakit/react";
22
import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
33
import { DialogClose } from "@radix-ui/react-dialog";
44
import { type MetaFunction, useFetcher } from "@remix-run/react";
5-
import {
6-
type ActionFunctionArgs,
7-
json,
8-
type LoaderFunctionArgs,
9-
redirect,
10-
} from "@remix-run/server-runtime";
5+
import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
116

127
import { AnimatePresence, motion } from "framer-motion";
138
import { ClipboardCheckIcon, ClipboardIcon, GitBranchPlusIcon } from "lucide-react";
@@ -22,6 +17,7 @@ import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
2217
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
2318
import { Badge } from "~/components/primitives/Badge";
2419
import { Button, LinkButton } from "~/components/primitives/Buttons";
20+
import { PermissionButton } from "~/components/primitives/PermissionButton";
2521
import { DateTime } from "~/components/primitives/DateTime";
2622
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
2723
import { Header3 } from "~/components/primitives/Headers";
@@ -60,7 +56,7 @@ import { Spinner } from "~/components/primitives/Spinner";
6056
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
6157
import { TextArea } from "~/components/primitives/TextArea";
6258
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
63-
import { prisma } from "~/db.server";
59+
import { $replica, prisma } from "~/db.server";
6460
import { useEnvironment } from "~/hooks/useEnvironment";
6561
import { useInterval } from "~/hooks/useInterval";
6662
import { useOrganization } from "~/hooks/useOrganizations";
@@ -73,6 +69,9 @@ import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$pr
7369
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
7470
import { getResizableSnapshot } from "~/services/resizablePanel.server";
7571
import { requireUserId } from "~/services/session.server";
72+
import { rbac } from "~/services/rbac.server";
73+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
74+
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
7675
import { PromptService } from "~/v3/services/promptService.server";
7776

7877
import { z } from "zod";
@@ -122,85 +121,107 @@ const ActionSchema = z.discriminatedUnion("intent", [
122121
}),
123122
]);
124123

125-
export async function action({ request, params }: ActionFunctionArgs) {
126-
const userId = await requireUserId(request);
127-
const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema.parse(params);
124+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
125+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
126+
return org?.id ?? null;
127+
}
128128

129-
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
130-
if (!project) return json({ error: "Project not found" }, { status: 404 });
129+
export const action = dashboardAction(
130+
{
131+
params: ParamSchema,
132+
context: async (params) => {
133+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
134+
return organizationId ? { organizationId } : {};
135+
},
136+
},
137+
async ({ request, params, user, ability }) => {
138+
const { organizationSlug, projectParam, envParam, promptSlug } = params;
139+
140+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
141+
if (!project) return json({ error: "Project not found" }, { status: 404 });
142+
143+
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
144+
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
145+
146+
const formData = Object.fromEntries(await request.formData());
147+
const parsed = ActionSchema.safeParse(formData);
148+
if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 });
149+
150+
const prompt = await prisma.prompt.findUnique({
151+
where: {
152+
projectId_runtimeEnvironmentId_slug: {
153+
projectId: project.id,
154+
runtimeEnvironmentId: environment.id,
155+
slug: promptSlug,
156+
},
157+
},
158+
});
131159

132-
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
133-
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
160+
if (!prompt) return json({ error: "Prompt not found" }, { status: 404 });
134161

135-
const formData = Object.fromEntries(await request.formData());
136-
const parsed = ActionSchema.safeParse(formData);
137-
if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 });
162+
const data = parsed.data;
138163

139-
const prompt = await prisma.prompt.findUnique({
140-
where: {
141-
projectId_runtimeEnvironmentId_slug: {
142-
projectId: project.id,
143-
runtimeEnvironmentId: environment.id,
144-
slug: promptSlug,
145-
},
146-
},
147-
});
164+
// Promoting a version to production is `update:prompts`; creating or
165+
// editing override versions is `write:prompts`. Check the right one per
166+
// intent — a single authorization block can't express both.
167+
const requiredAction = data.intent === "promote" ? "update" : "write";
168+
if (!ability.can(requiredAction, { type: "prompts" })) {
169+
return json({ error: "Unauthorized" }, { status: 403 });
170+
}
148171

149-
if (!prompt) return json({ error: "Prompt not found" }, { status: 404 });
172+
const service = new PromptService();
150173

151-
const data = parsed.data;
152-
const service = new PromptService();
174+
if (data.intent === "promote") {
175+
await service.promoteVersion(prompt.id, data.versionId);
176+
return json({ ok: true });
177+
}
153178

154-
if (data.intent === "promote") {
155-
await service.promoteVersion(prompt.id, data.versionId);
156-
return json({ ok: true });
157-
}
179+
const url = new URL(request.url);
158180

159-
const url = new URL(request.url);
181+
if (data.intent === "saveVersion") {
182+
const result = await service.createOverride(prompt.id, {
183+
textContent: data.textContent ?? "",
184+
model: data.model,
185+
commitMessage: data.commitMessage,
186+
source: "dashboard",
187+
createdBy: user.id,
188+
});
189+
url.searchParams.set("version", String(result.version));
190+
return redirect(url.pathname + url.search);
191+
}
160192

161-
if (data.intent === "saveVersion") {
162-
const result = await service.createOverride(prompt.id, {
163-
textContent: data.textContent ?? "",
164-
model: data.model,
165-
commitMessage: data.commitMessage,
166-
source: "dashboard",
167-
createdBy: userId,
168-
});
169-
url.searchParams.set("version", String(result.version));
170-
return redirect(url.pathname + url.search);
171-
}
193+
if (data.intent === "updateOverride") {
194+
await service.updateOverride(prompt.id, {
195+
textContent: data.textContent,
196+
model: data.model,
197+
commitMessage: data.commitMessage,
198+
});
199+
return json({ ok: true });
200+
}
172201

173-
if (data.intent === "updateOverride") {
174-
await service.updateOverride(prompt.id, {
175-
textContent: data.textContent,
176-
model: data.model,
177-
commitMessage: data.commitMessage,
178-
});
179-
return json({ ok: true });
180-
}
202+
if (data.intent === "removeOverride") {
203+
await service.removeOverride(prompt.id);
204+
// Navigate back to current version
205+
const currentVersion = await prisma.promptVersion.findFirst({
206+
where: { promptId: prompt.id, labels: { has: "current" } },
207+
select: { version: true },
208+
});
209+
if (currentVersion) {
210+
url.searchParams.set("version", String(currentVersion.version));
211+
} else {
212+
url.searchParams.delete("version");
213+
}
214+
return redirect(url.pathname + url.search);
215+
}
181216

182-
if (data.intent === "removeOverride") {
183-
await service.removeOverride(prompt.id);
184-
// Navigate back to current version
185-
const currentVersion = await prisma.promptVersion.findFirst({
186-
where: { promptId: prompt.id, labels: { has: "current" } },
187-
select: { version: true },
188-
});
189-
if (currentVersion) {
190-
url.searchParams.set("version", String(currentVersion.version));
191-
} else {
192-
url.searchParams.delete("version");
217+
if (data.intent === "reactivateOverride") {
218+
await service.reactivateOverride(prompt.id, data.versionId);
219+
return json({ ok: true });
193220
}
194-
return redirect(url.pathname + url.search);
195-
}
196221

197-
if (data.intent === "reactivateOverride") {
198-
await service.reactivateOverride(prompt.id, data.versionId);
199-
return json({ ok: true });
222+
return json({ error: "Unknown intent" }, { status: 400 });
200223
}
201-
202-
return json({ error: "Unknown intent" }, { status: 400 });
203-
}
224+
);
204225

205226
// ─── Loader ──────────────────────────────────────────────
206227

@@ -242,7 +263,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
242263
const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs);
243264
const endTime = toTime ? new Date(toTime) : new Date();
244265

245-
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
266+
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
267+
project.organizationId,
268+
"standard"
269+
);
246270
const presenter = new PromptPresenter(clickhouse);
247271
let generations: Awaited<ReturnType<typeof presenter.listGenerations>>["generations"] = [];
248272
let generationsPagination: { next?: string } = {};
@@ -301,6 +325,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
301325
const possibleOperations = opsErr ? [] : opsRows.map((r) => r.val);
302326
const possibleProviders = provsErr ? [] : provsRows.map((r) => r.val);
303327

328+
// Display flags for the promote / override controls — the action enforces
329+
// update:prompts and write:prompts independently. Permissive in OSS.
330+
const promptAuth = await rbac.authenticateSession(request, {
331+
userId,
332+
organizationId: project.organizationId,
333+
});
334+
const promptPermissions = promptAuth.ok
335+
? checkPermissions(promptAuth.ability, {
336+
canWritePrompts: { action: "write", resource: { type: "prompts" } },
337+
canPromote: { action: "update", resource: { type: "prompts" } },
338+
})
339+
: { canWritePrompts: true, canPromote: true };
340+
304341
return typedjson({
305342
resizable: {
306343
outer: resizableOuter,
@@ -353,6 +390,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
353390
possibleModels,
354391
possibleOperations,
355392
possibleProviders,
393+
...promptPermissions,
356394
});
357395
};
358396

@@ -437,6 +475,8 @@ export default function PromptDetailPage() {
437475
possibleModels,
438476
possibleOperations,
439477
possibleProviders,
478+
canWritePrompts,
479+
canPromote,
440480
} = useTypedLoaderData<typeof loader>();
441481
const organization = useOrganization();
442482
const project = useProject();
@@ -518,18 +558,22 @@ export default function PromptDetailPage() {
518558
</div>
519559
)}
520560
{selectedVersion && !isCurrent && selectedVersion.source === "code" && (
521-
<Button
561+
<PermissionButton
562+
hasPermission={canPromote}
563+
noPermissionTooltip="You don't have permission to promote prompt versions"
522564
variant="secondary/small"
523565
onClick={() => handlePromote(selectedVersion.id)}
524566
disabled={fetcher.state !== "idle"}
525567
>
526568
Promote to current
527-
</Button>
569+
</PermissionButton>
528570
)}
529571
{selectedVersion &&
530572
selectedVersion.source !== "code" &&
531573
!selectedVersion.labels.includes("override") && (
532-
<Button
574+
<PermissionButton
575+
hasPermission={canWritePrompts}
576+
noPermissionTooltip="You don't have permission to edit prompt overrides"
533577
variant="secondary/small"
534578
onClick={() =>
535579
fetcher.submit(
@@ -540,12 +584,17 @@ export default function PromptDetailPage() {
540584
disabled={fetcher.state !== "idle"}
541585
>
542586
Reactivate as override
543-
</Button>
587+
</PermissionButton>
544588
)}
545589
{!overrideVersion && (
546-
<Button variant="secondary/small" onClick={() => setOverrideDialogOpen(true)}>
590+
<PermissionButton
591+
hasPermission={canWritePrompts}
592+
noPermissionTooltip="You don't have permission to edit prompt overrides"
593+
variant="secondary/small"
594+
onClick={() => setOverrideDialogOpen(true)}
595+
>
547596
Create override
548-
</Button>
597+
</PermissionButton>
549598
)}
550599
</div>
551600
</PageAccessories>
@@ -565,21 +614,25 @@ export default function PromptDetailPage() {
565614
instead of the deployed prompt.
566615
</span>
567616
<div className="flex items-center gap-2 py-1.5">
568-
<Button
617+
<PermissionButton
618+
hasPermission={canWritePrompts}
619+
noPermissionTooltip="You don't have permission to edit prompt overrides"
569620
variant="tertiary/small"
570621
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
571622
onClick={() => setOverrideDialogOpen(true)}
572623
>
573624
Edit
574-
</Button>
575-
<Button
625+
</PermissionButton>
626+
<PermissionButton
627+
hasPermission={canWritePrompts}
628+
noPermissionTooltip="You don't have permission to edit prompt overrides"
576629
variant="tertiary/small"
577630
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
578631
onClick={() => fetcher.submit({ intent: "removeOverride" }, { method: "POST" })}
579632
disabled={fetcher.state !== "idle"}
580633
>
581634
Remove
582-
</Button>
635+
</PermissionButton>
583636
</div>
584637
</motion.div>
585638
)}
@@ -1502,7 +1555,10 @@ function GenerationsTab({
15021555
{gen.operation_id || gen.task_identifier}
15031556
</TableCell>
15041557
<TableCell
1505-
className={cn("tabular-nums", isSelected ? "text-text-bright" : "text-charcoal-400")}
1558+
className={cn(
1559+
"tabular-nums",
1560+
isSelected ? "text-text-bright" : "text-charcoal-400"
1561+
)}
15061562
>
15071563
v{gen.prompt_version}
15081564
</TableCell>

0 commit comments

Comments
 (0)