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
3 changes: 3 additions & 0 deletions .env.compose
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ GITHUB_BOT_CLIENT_SECRET=
GITHUB_BOT_PRIVATE_KEY=
GITHUB_WEBHOOK_SECRET=

# TFE (optional)
TFE_WEBHOOK_SECRET= # openssl rand -hex 32

ROUTER_URL=http://workspace-engine-router:9091
WORKSPACE_ENGINE_URL=http://workspace-engine:9090
1 change: 1 addition & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const env = createEnv({
GITHUB_BOT_APP_ID: z.string().optional(),
GITHUB_BOT_PRIVATE_KEY: z.string().optional(),
GITHUB_WEBHOOK_SECRET: z.string().optional(),
TFE_WEBHOOK_SECRET: z.string().optional(),
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the config.ts file around line 35
fd -t f "config.ts" apps/api/src | head -5

Repository: ctrlplanedev/ctrlplane

Length of output: 88


🏁 Script executed:

# Then read the specific file with context around line 35
cat -n apps/api/src/config.ts | sed -n '30,50p'

Repository: ctrlplanedev/ctrlplane

Length of output: 815


🏁 Script executed:

# Check the routes/tfe/index.ts file to see how the secret is used
find apps/api/src -name "index.ts" -path "*/routes/tfe/*" -type f

Repository: ctrlplanedev/ctrlplane

Length of output: 98


🏁 Script executed:

# Check .env.compose for context
find . -name ".env*compose*" -type f | head -10

Repository: ctrlplanedev/ctrlplane

Length of output: 80


🏁 Script executed:

# Read the TFE route file to see how the secret is validated
cat -n apps/api/src/routes/tfe/index.ts

Repository: ctrlplanedev/ctrlplane

Length of output: 1919


🏁 Script executed:

# Read .env.compose to see how TFE_WEBHOOK_SECRET is configured
cat -n ./.env.compose

Repository: ctrlplanedev/ctrlplane

Length of output: 1322


Normalize blank webhook secrets to undefined.

The schema accepts TFE_WEBHOOK_SECRET= as a valid empty string, and the router only guards against null/undefined (line 15 of routes/tfe/index.ts). A blank env entry becomes a valid HMAC key instead of disabling the feature, allowing anyone to compute a valid signature using an empty secret.

Suggested fix
-    TFE_WEBHOOK_SECRET: z.string().optional(),
+    TFE_WEBHOOK_SECRET: z.preprocess(
+      (value) =>
+        typeof value === "string" && value.trim() === "" ? undefined : value,
+      z.string().min(1).optional(),
+    ),
📝 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
TFE_WEBHOOK_SECRET: z.string().optional(),
TFE_WEBHOOK_SECRET: z.preprocess(
(value) =>
typeof value === "string" && value.trim() === "" ? undefined : value,
z.string().min(1).optional(),
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/config.ts` at line 35, The TFE_WEBHOOK_SECRET environment schema
currently allows an empty string which becomes a valid HMAC key; update the zod
schema for TFE_WEBHOOK_SECRET in config.ts to normalize blank strings to
undefined (e.g., use z.preprocess((val) => val === "" ? undefined : val,
z.string().optional())) so that an empty env value disables the webhook feature
consistently with the router checks (routes/tfe index uses undefined/null
guarding).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's also the exact same pattern as GITHUB_WEBHOOK_SECRET on line 34. Fixing one without the other would be inconsistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@zacharyblasczyk, that's a fair point — the same empty-string concern applies to GITHUB_WEBHOOK_SECRET as well. For true consistency, the fix should be applied to both:

-    GITHUB_WEBHOOK_SECRET: z.string().optional(),
-    TFE_WEBHOOK_SECRET: z.string().optional(),
+    GITHUB_WEBHOOK_SECRET: z.preprocess(
+      (value) =>
+        typeof value === "string" && value.trim() === "" ? undefined : value,
+      z.string().min(1).optional(),
+    ),
+    TFE_WEBHOOK_SECRET: z.preprocess(
+      (value) =>
+        typeof value === "string" && value.trim() === "" ? undefined : value,
+      z.string().min(1).optional(),
+    ),

That said, if the project's convention is to leave webhook secrets as plain z.string().optional() and rely on runtime guards in the route handlers instead, I understand treating both consistently with the existing pattern. The risk is low as long as developers know not to set these variables to empty strings in production.

( ͡° ͜ʖ ͡°) 🐇


BASE_URL: z.string().optional(),

Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/routes/tfe/__tests__/run_notification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { JobStatus } from "@ctrlplane/validators/jobs";

import { mapTriggerToStatus } from "../run_notification.js";

describe("mapTriggerToStatus", () => {
it.each([
["run:created", JobStatus.Pending],
["run:planning", JobStatus.InProgress],
["run:needs_attention", JobStatus.ActionRequired],
["run:applying", JobStatus.InProgress],
["run:completed", JobStatus.Successful],
["run:errored", JobStatus.Failure],
])("maps trigger %s to %s", (trigger, expected) => {
expect(mapTriggerToStatus(trigger)).toBe(expected);
});

it("returns null for unknown triggers", () => {
expect(mapTriggerToStatus("run:unknown")).toBeNull();
expect(mapTriggerToStatus("")).toBeNull();
expect(mapTriggerToStatus("something:else")).toBeNull();
});
});
164 changes: 164 additions & 0 deletions apps/api/src/routes/tfe/__tests__/webhook_router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import crypto from "node:crypto";
import type { Request, Response } from "express";
import { describe, expect, it, vi, beforeEach } from "vitest";

// Mock config before importing the module under test
vi.mock("@/config.js", () => ({
env: { TFE_WEBHOOK_SECRET: "test-secret-123" },
}));

vi.mock("@ctrlplane/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));

const mockHandleRunNotification = vi.fn().mockResolvedValue(undefined);
vi.mock("../run_notification.js", () => ({
handleRunNotification: (...args: unknown[]) =>
mockHandleRunNotification(...args),
}));

import { createTfeRouter } from "../index.js";

function signPayload(body: object, secret: string): string {
const json = JSON.stringify(body);
return crypto.createHmac("sha512", secret).update(json).digest("hex");
}

function makeMockRes() {
const res = { statusCode: 200, _json: null as unknown };
return Object.assign(res, {
status: (code: number) => {
res.statusCode = code;
return res;
},
json: (data: unknown) => {
res._json = data;
return res;
},
}) as typeof res & Response;
}

function getWebhookHandler() {
const router = createTfeRouter();
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const layer = (router as any).stack.find(
(l: any) => l.route?.path === "/webhook" && l.route?.methods?.post,
);
if (!layer) throw new Error("POST /webhook route not found on router");
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const handlers = layer.route.stack.filter(
(s: any) => s.method === "post",
);
return handlers[handlers.length - 1].handle as (
req: Request,
res: Response,
) => Promise<void>;
}

describe("TFE webhook router", () => {
let handler: (req: Request, res: Response) => Promise<void>;

beforeEach(() => {
handler = getWebhookHandler();
vi.clearAllMocks();
});

const payload = {
payload_version: 1,
notification_configuration_id: "nc-test",
run_url: "https://app.terraform.io/runs/run-abc",
run_id: "run-abc",
run_message: "test",
run_created_at: "2024-01-01T00:00:00Z",
run_created_by: "user",
workspace_id: "ws-test",
workspace_name: "test-ws",
organization_name: "org",
notifications: [
{
message: "Applied",
trigger: "run:completed",
run_status: "applied",
run_updated_at: "2024-01-01T00:01:00Z",
run_updated_by: "user",
},
],
};

it("returns 200 and calls handler with valid signature", async () => {
const signature = signPayload(payload, "test-secret-123");
const req = {
headers: { "x-tfe-notification-signature": signature },
body: payload,
} as unknown as Request;
const res = makeMockRes();

await handler(req, res);

expect(res.statusCode).toBe(200);
expect((res as any)._json).toEqual({ message: "OK" });
expect(mockHandleRunNotification).toHaveBeenCalledOnce();
expect(mockHandleRunNotification).toHaveBeenCalledWith(payload);
});

it("returns 401 with missing signature header", async () => {
const req = {
headers: {},
body: payload,
} as unknown as Request;
const res = makeMockRes();

await handler(req, res);

expect(res.statusCode).toBe(401);
expect((res as any)._json).toEqual({ message: "Unauthorized" });
expect(mockHandleRunNotification).not.toHaveBeenCalled();
});

it("returns 401 with wrong signature", async () => {
const req = {
headers: {
"x-tfe-notification-signature": "deadbeef".repeat(16),
},
body: payload,
} as unknown as Request;
const res = makeMockRes();

await handler(req, res);

expect(res.statusCode).toBe(401);
expect(mockHandleRunNotification).not.toHaveBeenCalled();
});

it("returns 200 without calling handler when notifications is empty", async () => {
const emptyPayload = { ...payload, notifications: [] };
const signature = signPayload(emptyPayload, "test-secret-123");
const req = {
headers: { "x-tfe-notification-signature": signature },
body: emptyPayload,
} as unknown as Request;
const res = makeMockRes();

await handler(req, res);

expect(res.statusCode).toBe(200);
expect(mockHandleRunNotification).not.toHaveBeenCalled();
});

it("returns 500 when handler throws", async () => {
mockHandleRunNotification.mockRejectedValueOnce(
new Error("db connection lost"),
);
const signature = signPayload(payload, "test-secret-123");
const req = {
headers: { "x-tfe-notification-signature": signature },
body: payload,
} as unknown as Request;
const res = makeMockRes();

await handler(req, res);

expect(res.statusCode).toBe(500);
expect((res as any)._json).toEqual({ message: "db connection lost" });
});
});
49 changes: 49 additions & 0 deletions apps/api/src/routes/tfe/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Request, Response } from "express";
import crypto from "node:crypto";
import { env } from "@/config.js";
import { Router } from "express";

import { logger } from "@ctrlplane/logger";

import { handleRunNotification } from "./run_notification.js";

export const createTfeRouter = (): Router =>
Router().post("/webhook", handleWebhookRequest);

const verifySignature = (req: Request): boolean => {
const secret = env.TFE_WEBHOOK_SECRET;
if (secret == null) return false;

const signature = req.headers["x-tfe-notification-signature"]?.toString();
if (signature == null) return false;

const body = JSON.stringify(req.body);
const expected = crypto
.createHmac("sha512", secret)
.update(body)
.digest("hex");

const sigBuf = Buffer.from(signature, "hex");
const expBuf = Buffer.from(expected, "hex");
if (sigBuf.length !== expBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expBuf);
};

const handleWebhookRequest = async (req: Request, res: Response) => {
try {
if (!verifySignature(req)) {
res.status(401).json({ message: "Unauthorized" });
return;
}

const payload = req.body;
if (payload.notifications != null && payload.notifications.length > 0)
await handleRunNotification(payload);

res.status(200).json({ message: "OK" });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.error(message);
res.status(500).json({ message });
}
};
117 changes: 117 additions & 0 deletions apps/api/src/routes/tfe/run_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { eq, sql, takeFirstOrNull } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import { enqueueAllReleaseTargetsDesiredVersion } from "@ctrlplane/db/reconcilers";
import * as schema from "@ctrlplane/db/schema";
import { logger } from "@ctrlplane/logger";
import { ReservedMetadataKey } from "@ctrlplane/validators/conditions";
import { exitedStatus, JobStatus } from "@ctrlplane/validators/jobs";

/**
* TFC notification trigger → ctrlplane job status.
* https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications#notification-triggers
*/
const triggerStatusMap: Record<string, JobStatus> = {
"run:created": JobStatus.Pending,
"run:planning": JobStatus.InProgress,
"run:needs_attention": JobStatus.ActionRequired,
"run:applying": JobStatus.InProgress,
"run:completed": JobStatus.Successful,
"run:errored": JobStatus.Failure,
};

export const mapTriggerToStatus = (trigger: string): JobStatus | null =>
triggerStatusMap[trigger] ?? null;

const uuidRegex =
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/;

/**
* Extract the ctrlplane job ID from the TFC run message.
* The dispatcher sets: "Triggered by ctrlplane job <uuid>"
*/
const extractJobId = (runMessage: string): string | null => {
const match = uuidRegex.exec(runMessage);
return match ? match[0] : null;
};

export const handleRunNotification = async (payload: {
run_url: string;
run_id: string;
run_message: string;
workspace_name: string;
organization_name: string;
notifications: Array<{ message: string; trigger: string }>;
}) => {
if (payload.notifications.length === 0) return;

const notification = payload.notifications[0]!;
const status = mapTriggerToStatus(notification.trigger);
if (status == null) {
logger.warn("Unknown TFC notification trigger, ignoring", {
trigger: notification.trigger,
});
return;
}

const jobId = extractJobId(payload.run_message);
if (jobId == null) return;

const now = new Date();
const isCompleted = exitedStatus.includes(status);
const isInProgress = status === JobStatus.InProgress;

const [updated] = await db
.update(schema.job)
.set({
externalId: payload.run_id,
status,
updatedAt: now,
message: notification.message,
...(isInProgress
? { startedAt: sql`COALESCE(${schema.job.startedAt}, ${now})` }
: {}),
...(isCompleted ? { completedAt: now } : {}),
})
.where(eq(schema.job.id, jobId))
.returning();

if (updated == null) return;

// Derive workspace URL from run_url (works for both TFC and TFE)
const runUrlParts = payload.run_url.split("/runs/");
const workspaceUrl = runUrlParts[0] ?? payload.run_url;
const links = JSON.stringify({
Run: payload.run_url,
Workspace: workspaceUrl,
});
const metadataEntries = [
{ jobId, key: String(ReservedMetadataKey.Links), value: links },
{ jobId, key: "run_url", value: payload.run_url },
];

for (const entry of metadataEntries)
await db
.insert(schema.jobMetadata)
.values(entry)
.onConflictDoUpdate({
target: [schema.jobMetadata.key, schema.jobMetadata.jobId],
set: { value: entry.value },
});

const result = await db
.select({ workspaceId: schema.deployment.workspaceId })
.from(schema.releaseJob)
.innerJoin(
schema.release,
eq(schema.releaseJob.releaseId, schema.release.id),
)
.innerJoin(
schema.deployment,
eq(schema.release.deploymentId, schema.deployment.id),
)
.where(eq(schema.releaseJob.jobId, jobId))
.then(takeFirstOrNull);

if (result?.workspaceId != null)
enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId);
Comment on lines +115 to +116
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/src/routes/tfe/run_notification.ts | head -150

Repository: ctrlplanedev/ctrlplane

Length of output: 4556


🏁 Script executed:

rg "enqueueAllReleaseTargetsDesiredVersion" --type ts -B 5 -A 10

Repository: ctrlplanedev/ctrlplane

Length of output: 19957


🏁 Script executed:

fd "version-manager" -e ts -o | head -5

Repository: ctrlplanedev/ctrlplane

Length of output: 182


🏁 Script executed:

sed -n '37,115p' apps/api/src/routes/tfe/run_notification.ts

Repository: ctrlplanedev/ctrlplane

Length of output: 2392


Await the desired-version enqueue.

This promise is currently fire-and-forget, so a queue write failure will result in an unhandled rejection. Since the handler is async, await it here:

Suggested fix
  if (result?.workspaceId != null)
-   enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId);
+   await enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId);
📝 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
if (result?.workspaceId != null)
enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId);
if (result?.workspaceId != null)
await enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/tfe/run_notification.ts` around lines 113 - 114, The call
to enqueueAllReleaseTargetsDesiredVersion in run_notification.ts is currently
fire-and-forget and can cause unhandled promise rejections; change the
invocation to await the promise so failures propagate to the async handler
(i.e., replace the direct call to enqueueAllReleaseTargetsDesiredVersion(db,
result.workspaceId) with an awaited call: await
enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId)) so any queue
write errors are properly caught by the surrounding async flow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is intentional — it's an enqueue operation (write to a queue table) that's designed to be best-effort. If it fails, the reconciler will pick it up on the next cycle anyway. Adding await here would make the webhook response wait on the enqueue write, and if it throws, the webhook would return 500 to TFC causing unnecessary retries for a non-critical side effect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@zacharyblasczyk, that's a reasonable design choice — keeping the webhook response fast and not penalizing TFC for a non-critical side effect makes sense.

That said, there's still a risk of an unhandled promise rejection if the enqueue fails, which can crash or warn the Node.js process even though the behavior is intentional. The idiomatic fix for intentional fire-and-forget is to explicitly mark it with void and attach a .catch() to swallow/log the error:

if (result?.workspaceId != null)
  void enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId).catch(
    (err) => logger.warn("Failed to enqueue desired version (best-effort)", { err }),
  );

This way:

  • The webhook handler returns immediately (no awaiting).
  • No 500 is ever returned to TFC for this side effect.
  • The promise rejection is explicitly handled, preventing unhandled rejection warnings/crashes.
  • The warning log gives you observability if the enqueue fails.

✏️ Learnings added
Learnt from: zacharyblasczyk
Repo: ctrlplanedev/ctrlplane PR: 847
File: apps/api/src/routes/tfe/run_notification.ts:115-116
Timestamp: 2026-03-20T16:24:22.839Z
Learning: In apps/api/src/routes/tfe/run_notification.ts, the call to `enqueueAllReleaseTargetsDesiredVersion` at the end of `handleRunNotification` is intentionally fire-and-forget. The enqueue is a best-effort write to a queue table; if it fails, the reconciler picks it up on the next cycle. Awaiting it would delay the webhook response and could return 500 to TFC causing unnecessary retries. The preferred pattern here is `void enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId).catch(err => logger.warn(...))` to prevent unhandled rejections while keeping non-blocking behavior.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: adityachoudhari26
Repo: ctrlplanedev/ctrlplane PR: 627
File: apps/event-worker/src/workers/job-update/trigger-dependendent-targets.ts:0-0
Timestamp: 2025-07-23T22:42:20.933Z
Learning: In apps/event-worker/src/workers/job-update/trigger-dependendent-targets.ts, when implementing error handling for the triggerDependentTargets function, only a try-catch wrapper is needed around the entire function body. Additional null checks after takeFirst() calls are unnecessary since takeFirst throws an error if no record is found.

Learnt from: adityachoudhari26
Repo: ctrlplanedev/ctrlplane PR: 501
File: apps/webservice/src/app/api/v1/resources/route.ts:69-72
Timestamp: 2025-04-22T04:35:36.016Z
Learning: In Node.js applications, non-awaited Promises that reject after a function returns won't be caught by the outer try-catch block, potentially causing unhandled rejections. Either explicitly mark them with `void` and add `.catch()` handlers, or use `await` to ensure proper error handling.

};
4 changes: 3 additions & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { appRouter, createTRPCContext } from "@ctrlplane/trpc";

import swaggerDocument from "../openapi/openapi.json" with { type: "json" };
import { createGithubRouter } from "./routes/github/index.js";
import { createTfeRouter } from "./routes/tfe/index.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand All @@ -25,7 +26,7 @@ const specFile = join(__dirname, "../openapi/openapi.json");
const oapiValidatorMiddleware = OpenApiValidator.middleware({
apiSpec: specFile,
validateRequests: true,
ignorePaths: /\/api\/(auth|trpc|github|ui|healthz)/,
ignorePaths: /\/api\/(auth|trpc|github|tfe|ui|healthz)/,
});

const trpcMiddleware = trpcExpress.createExpressMiddleware({
Expand Down Expand Up @@ -79,6 +80,7 @@ const app = express()
.use("/api/v1", requireAuth)
.use("/api/v1", createV1Router())
.use("/api/github", createGithubRouter())
.use("/api/tfe", createTfeRouter())
.use("/api/trpc", trpcMiddleware)
.use(errorHandler);

Expand Down
Loading
Loading