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
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
import type React from "react";
import _ from "lodash";
import { CheckCircle, Server, Shield } from "lucide-react";
import { toast } from "sonner";

import { trpc } from "~/api/trpc";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
Expand All @@ -20,6 +23,7 @@ import { useWorkspace } from "~/components/WorkspaceProvider";

type DeploymentVersion = WorkspaceEngine["schemas"]["DeploymentVersion"];
type ReleaseTarget = WorkspaceEngine["schemas"]["ReleaseTargetWithState"];
type Environment = WorkspaceEngine["schemas"]["Environment"];

const getReleaseTargetKey = (rt: ReleaseTarget) => {
return `${rt.releaseTarget.resourceId}-${rt.releaseTarget.environmentId}-${rt.releaseTarget.deploymentId}`;
Expand Down Expand Up @@ -54,6 +58,63 @@ const PoliciesSection: React.FC<{ policies: string[] }> = ({ policies }) => {
);
};

const PendingActionsSection: React.FC<{
version: DeploymentVersion;
environment: Environment;
}> = ({ version, environment }) => {
const { workspace } = useWorkspace();
const approveMutation = trpc.deploymentVersions.approve.useMutation();
const decisionsQuery = trpc.decisions.environmentVersion.useQuery({
workspaceId: workspace.id,
environmentId: environment.id,
versionId: version.id,
});
const pendingActions = (decisionsQuery.data ?? []).flatMap(
(action) => action.ruleResults,
);
const approvalAction = pendingActions.find(
(action) => action.actionType === "approval",
);

if (approvalAction == null) return null;

const onClick = () =>
approveMutation
.mutateAsync({
workspaceId: workspace.id,
deploymentVersionId: version.id,
environmentId: environment.id,
status: "approved",
})
.then(() => toast.success("Approval record queued successfully"));

return (
<div className="space-y-1.5 rounded-md border p-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<div className="h-1.5 w-1.5 rounded-full bg-amber-500 p-1" />
Pending actions
</div>
<div className="space-y-1">
{pendingActions.map((action, idx) => (
<div key={idx} className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
{version.tag} - {action.message}
</div>
<Button
variant="secondary"
className="h-4 text-xs"
onClick={onClick}
disabled={approveMutation.isPending}
>
Approve
</Button>
</div>
))}
</div>
</div>
);
};

const ResourceItem: React.FC<{
releaseTarget: ReleaseTarget;
}> = ({ releaseTarget: rt }) => {
Expand Down Expand Up @@ -191,6 +252,10 @@ export const EnvironmentActionsPanel: React.FC<
<div className="space-y-4">
{/* Policies */}
{/* <PoliciesSection policies={environment.policies} /> */}
<PendingActionsSection
version={versions[0]}
environment={environment}
/>

{/* Resources grouped by version */}
<div className="space-y-2">
Expand Down
4 changes: 4 additions & 0 deletions packages/trpc/src/root.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { decisionsRouter } from "./routes/decisions.js";
import { deploymentVersionsRouter } from "./routes/deployment-versions.js";
import { deploymentsRouter } from "./routes/deployments.js";
import { environmentRouter } from "./routes/environments.js";
import { githubRouter } from "./routes/github.js";
Expand All @@ -19,6 +21,7 @@ export const appRouter = router({
resource: resourcesRouter,
workspace: workspaceRouter,
deployment: deploymentsRouter,
deploymentVersions: deploymentVersionsRouter,
system: systemsRouter,
environment: environmentRouter,
validate: validateRouter,
Expand All @@ -29,4 +32,5 @@ export const appRouter = router({
resourceProviders: resourceProvidersRouter,
jobAgents: jobAgentsRouter,
github: githubRouter,
decisions: decisionsRouter,
});
90 changes: 90 additions & 0 deletions packages/trpc/src/routes/decisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
import { z } from "zod";

import { getClientFor } from "@ctrlplane/workspace-engine-sdk";

import { protectedProcedure, router } from "../trpc.js";

const getOneReleaseTarget = async (
workspaceId: string,
environmentId: string,
deploymentId: string,
) => {
const response = await getClientFor(workspaceId).GET(
"/v1/workspaces/{workspaceId}/environments/{environmentId}/release-targets",
{ params: { path: { workspaceId, environmentId } } },
);
return (response.data?.items ?? []).find(
(target) => target.deployment.id === deploymentId,
);
Comment on lines +17 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Wrong property path; likely never matches any release target.

ReleaseTargetWithState elsewhere in the app exposes IDs under releaseTarget.{deploymentId, environmentId, resourceId}. Matching on target.deployment.id will fail.

Apply:

-  return (response.data?.items ?? []).find(
-    (target) => target.deployment.id === deploymentId,
-  );
+  return (response.data?.items ?? []).find(
+    (target) => target.releaseTarget.deploymentId === deploymentId,
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (response.data?.items ?? []).find(
(target) => target.deployment.id === deploymentId,
);
return (response.data?.items ?? []).find(
(target) => target.releaseTarget.deploymentId === deploymentId,
);
🤖 Prompt for AI Agents
In packages/trpc/src/routes/decisions.ts around lines 17 to 19, the code is
matching on target.deployment.id which is the wrong property path and therefore
never finds matches; change the predicate to use the ReleaseTargetWithState
shape by comparing target.releaseTarget.deploymentId === deploymentId (and
defensively handle undefined releaseTarget if necessary), i.e. access
releaseTarget.deploymentId instead of deployment.id so the find will match the
actual IDs exposed elsewhere in the app.

};

const getDeploymentVersion = async (
workspaceId: string,
deploymentVersionId: string,
) => {
const response = await getClientFor(workspaceId).GET(
"/v1/workspaces/{workspaceId}/deploymentversions/{deploymentVersionId}",
{ params: { path: { workspaceId, deploymentVersionId } } },
);
if (response.data == null) throw new Error("Deployment version not found");
return response.data;
};

const getPolicyResults = async (
workspaceId: string,
releaseTarget: WorkspaceEngine["schemas"]["ReleaseTargetWithState"],
version: WorkspaceEngine["schemas"]["DeploymentVersion"],
) => {
const decision = await getClientFor(workspaceId).POST(
"/v1/workspaces/{workspaceId}/release-targets/evaluate",
{
params: { path: { workspaceId } },
body: {
releaseTarget: {
deploymentId: releaseTarget.deployment.id,
environmentId: releaseTarget.environment.id,
resourceId: releaseTarget.resource.id,
},
version,
Comment on lines +44 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Evaluate payload uses wrong fields; sends undefined IDs.

Build the releaseTarget for evaluation from releaseTarget.releaseTarget.*, not .deployment/.environment.

-        releaseTarget: {
-          deploymentId: releaseTarget.deployment.id,
-          environmentId: releaseTarget.environment.id,
-          resourceId: releaseTarget.resource.id,
-        },
+        releaseTarget: {
+          deploymentId: releaseTarget.releaseTarget.deploymentId,
+          environmentId: releaseTarget.releaseTarget.environmentId,
+          resourceId: releaseTarget.releaseTarget.resourceId,
+        },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
releaseTarget: {
deploymentId: releaseTarget.deployment.id,
environmentId: releaseTarget.environment.id,
resourceId: releaseTarget.resource.id,
},
version,
releaseTarget: {
deploymentId: releaseTarget.releaseTarget.deploymentId,
environmentId: releaseTarget.releaseTarget.environmentId,
resourceId: releaseTarget.releaseTarget.resourceId,
},
version,
🤖 Prompt for AI Agents
In packages/trpc/src/routes/decisions.ts around lines 44–49, the evaluation
payload is using releaseTarget.deployment/environment/resource which are
undefined; construct releaseTarget using the nested releaseTarget.releaseTarget
object instead. Replace the three fields so they read from
releaseTarget.releaseTarget (e.g. releaseTarget.releaseTarget.deployment.id,
releaseTarget.releaseTarget.environment.id,
releaseTarget.releaseTarget.resource.id) and add null/undefined guards or throw
a clear error if those nested values are missing.

},
},
);
return decision.data?.versionDecision?.policyResults ?? [];
};

const getEnvironmentScopedResults = (
policyResults: WorkspaceEngine["schemas"]["DeployDecision"]["policyResults"],
) =>
policyResults.filter((result) =>
result.ruleResults.some(
(rule) => rule.actionType === "approval" && !rule.allowed,
),
);

export const decisionsRouter = router({
environmentVersion: protectedProcedure
.input(
z.object({
workspaceId: z.uuid(),
environmentId: z.uuid(),
versionId: z.uuid(),
}),
)
.query(async ({ input }) => {
const { workspaceId, environmentId, versionId } = input;
const version = await getDeploymentVersion(workspaceId, versionId);
const releaseTarget = await getOneReleaseTarget(
workspaceId,
environmentId,
version.deploymentId,
);
if (releaseTarget == null) return [];
const policyResults = await getPolicyResults(
workspaceId,
releaseTarget,
version,
);
return getEnvironmentScopedResults(policyResults);
}),
});
38 changes: 38 additions & 0 deletions packages/trpc/src/routes/deployment-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
import { z } from "zod";

import { Event, sendGoEvent } from "@ctrlplane/events";

import { protectedProcedure, router } from "../trpc.js";

export const deploymentVersionsRouter = router({
approve: protectedProcedure
.input(
z.object({
workspaceId: z.string().uuid(),
deploymentVersionId: z.string(),
environmentId: z.string(),
status: z.enum(["approved", "rejected"]).default("approved"),
}),
)
Comment on lines +11 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Authorization gap: don’t trust client‑supplied workspaceId for event routing.

A malicious client could emit approvals to another workspace. Derive the workspace from server‑side state (e.g., fetch the version and use its workspace; or use a ctx.workspaceId established by auth middleware) and ignore the input field.

-    .input(
+    .input(
       z.object({
-        workspaceId: z.string().uuid(),
-        deploymentVersionId: z.string(),
-        environmentId: z.string(),
+        // Remove workspaceId from client input
+        deploymentVersionId: z.string().uuid(),
+        environmentId: z.string().uuid(),
         status: z.enum(["approved", "rejected"]).default("approved"),
       }),
     )
-    .mutation(async ({ ctx, input }) => {
+    .mutation(async ({ ctx, input }) => {
       const userId = ctx.session.user.id;
+      // Derive workspaceId securely (examples; pick one that fits your stack)
+      // const workspaceId = ctx.workspace.id;
+      // or: const { workspaceId } = await getDeploymentVersion(input.deploymentVersionId);
 
       const record: WorkspaceEngine["schemas"]["UserApprovalRecord"] = {
         userId,
         versionId: input.deploymentVersionId,
         environmentId: input.environmentId,
         status: input.status,
         createdAt: new Date().toISOString(),
       };
 
-      await sendGoEvent({
-        workspaceId: input.workspaceId,
+      await sendGoEvent({
+        workspaceId, // derived server-side
         eventType: Event.UserApprovalRecordCreated,
         timestamp: Date.now(),
         data: record,
       });

Also applies to: 29-34

🤖 Prompt for AI Agents
In packages/trpc/src/routes/deployment-versions.ts around lines 11-17 (and
similarly lines 29-34), the handler currently accepts a client-supplied
workspaceId which can be abused to route events to other workspaces; remove
trust in that input by deriving the workspaceId from server-side state instead —
fetch the deployment version (or use ctx.workspaceId set by auth middleware),
verify it belongs to the authenticated user/team, and use that server-derived
workspaceId for event routing and authorization checks while completely ignoring
the workspaceId value from the request body/validation.

.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;

const record: WorkspaceEngine["schemas"]["UserApprovalRecord"] = {
userId,
versionId: input.deploymentVersionId,
environmentId: input.environmentId,
status: input.status,
createdAt: new Date().toISOString(),
};

await sendGoEvent({
workspaceId: input.workspaceId,
eventType: Event.UserApprovalRecordCreated,
timestamp: Date.now(),
data: record,
});

return record;
}),
});
Loading