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: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"postinstall": "prisma generate",
"generate:openapi": "tsx scripts/generate-openapi.ts"
"generate:openapi": "tsx scripts/generate-openapi.ts",
"backfill:channel-secrets": "tsx scripts/backfill-channel-secrets.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
Expand Down
28 changes: 28 additions & 0 deletions scripts/backfill-channel-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env tsx
import { prisma } from "@/lib/prisma";
import { encryptChannelConfig, SENSITIVE_FIELDS_BY_TYPE } from "@/server/services/channel-secrets";

async function main() {
const channels = await prisma.notificationChannel.findMany({
select: { id: true, type: true, config: true },
});

let updated = 0, skipped = 0;
for (const ch of channels) {
if (!SENSITIVE_FIELDS_BY_TYPE[ch.type]) { skipped++; continue; }
const config = ch.config as Record<string, unknown>;
const encrypted = encryptChannelConfig(ch.type, config);
// Only write if at least one field actually changed (idempotent skip)
const changed = Object.keys(encrypted).some((k) => encrypted[k] !== config[k]);
if (!changed) { skipped++; continue; }
await prisma.notificationChannel.update({
where: { id: ch.id },
data: { config: encrypted as never },
});
updated++;
console.log(`encrypted channel ${ch.id} (${ch.type})`);
}
console.log(`done — updated=${updated} skipped=${skipped} total=${channels.length}`);
}

main().catch((e) => { console.error(e); process.exit(1); });
121 changes: 121 additions & 0 deletions src/server/routers/__tests__/alert-channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ vi.mock("@/server/services/channels", () => ({

import { prisma } from "@/lib/prisma";
import { alertChannelsRouter } from "@/server/routers/alert-channels";
import { encrypt, ENCRYPTION_DOMAINS } from "@/server/services/crypto";
import { ENCRYPTED_MARKER } from "@/server/services/channel-secrets";

const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
const caller = t.createCallerFactory(alertChannelsRouter)({
Expand Down Expand Up @@ -110,6 +112,44 @@ describe("alertChannelsRouter", () => {

expect(result).toEqual([]);
});

it("decrypts non-redacted fields so the edit form receives plaintext", async () => {
const encryptedUrl = ENCRYPTED_MARKER + encrypt(
"https://hooks.slack.com/services/T/B/SECRETTOKEN",
ENCRYPTION_DOMAINS.SECRETS,
);

prismaMock.notificationChannel.findMany.mockResolvedValue([
makeChannel({
type: "slack",
config: { webhookUrl: encryptedUrl },
}),
] as never);

const result = await caller.listChannels({ environmentId: "env-1" });

const config = result[0].config as Record<string, unknown>;
expect(config.webhookUrl).toBe("https://hooks.slack.com/services/T/B/SECRETTOKEN");
});

it("decrypts encrypted webhook headers and surfaces them as an object", async () => {
const encryptedHeaders = ENCRYPTED_MARKER + encrypt(
JSON.stringify({ Authorization: "Bearer abc" }),
ENCRYPTION_DOMAINS.SECRETS,
);

prismaMock.notificationChannel.findMany.mockResolvedValue([
makeChannel({
type: "webhook",
config: { url: "https://x.test", headers: encryptedHeaders },
}),
] as never);

const result = await caller.listChannels({ environmentId: "env-1" });

const config = result[0].config as Record<string, unknown>;
expect(config.headers).toEqual({ Authorization: "Bearer abc" });
});
});

// ─── createChannel ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -237,6 +277,25 @@ describe("alertChannelsRouter", () => {
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});

it("encrypts hmacSecret before persisting webhook channel", async () => {
prismaMock.environment.findUnique.mockResolvedValue({ id: "env-1" } as never);
prismaMock.notificationChannel.create.mockResolvedValue(
makeChannel({ type: "webhook" }) as never,
);

await caller.createChannel({
environmentId: "env-1",
name: "Webhook With Secret",
type: "webhook",
config: { url: "https://example.com/hook", hmacSecret: "raw-secret" },
});

const createCall = prismaMock.notificationChannel.create.mock.calls[0][0];
const persisted = (createCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toMatch(/^vfenc1:/);
expect(persisted.url).toBe("https://example.com/hook");
});
});

// ─── updateChannel ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -289,6 +348,43 @@ describe("alertChannelsRouter", () => {

expect(mockValidatePublicUrl).toHaveBeenCalledWith("https://hooks.slack.com/new");
});

it("re-encrypts hmacSecret when rotated via update", async () => {
const existing = makeChannel({
type: "webhook",
config: { url: "https://x", hmacSecret: "vfenc1:v2:OLDCIPHERTEXT" },
});
prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never);
prismaMock.notificationChannel.update.mockResolvedValue(existing as never);

await caller.updateChannel({
id: "ch-1",
config: { url: "https://x", hmacSecret: "new-raw" },
});

const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0];
const persisted = (updateCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toMatch(/^vfenc1:/);
expect(persisted.hmacSecret).not.toBe("vfenc1:v2:OLDCIPHERTEXT");
});

it("preserves existing encrypted hmacSecret when not in input", async () => {
const existing = makeChannel({
type: "webhook",
config: { url: "https://x", hmacSecret: "vfenc1:v2:OLDCIPHERTEXT" },
});
prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never);
prismaMock.notificationChannel.update.mockResolvedValue(existing as never);

await caller.updateChannel({
id: "ch-1",
config: { url: "https://newurl" },
});

const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0];
const persisted = (updateCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toBe("vfenc1:v2:OLDCIPHERTEXT");
});
});

// ─── deleteChannel ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -352,5 +448,30 @@ describe("alertChannelsRouter", () => {
caller.testChannel({ id: "ch-missing" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});

it("decrypts hmacSecret before passing config to driver.test", async () => {
const encryptedSecret = ENCRYPTED_MARKER + encrypt("raw-secret", ENCRYPTION_DOMAINS.SECRETS);
expect(encryptedSecret.startsWith("vfenc1:")).toBe(true);

prismaMock.notificationChannel.findUnique.mockResolvedValue(
makeChannel({
type: "webhook",
config: {
url: "https://hooks.example.com/x",
hmacSecret: encryptedSecret,
},
}) as never,
);
mockChannelTest.mockResolvedValue({ success: true });

await caller.testChannel({ id: "ch-1" });

expect(mockChannelTest).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://hooks.example.com/x",
hmacSecret: "raw-secret",
}),
);
});
});
});
27 changes: 19 additions & 8 deletions src/server/routers/alert-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma";
import { withAudit } from "@/server/middleware/audit";
import { validatePublicUrl, validateSmtpHost } from "@/server/services/url-validation";
import { getDriver } from "@/server/services/channels";
import { encryptChannelConfig, decryptChannelConfig } from "@/server/services/channel-secrets";

export const alertChannelsRouter = router({
listChannels: protectedProcedure
Expand All @@ -27,12 +28,17 @@ export const alertChannelsRouter = router({
orderBy: { createdAt: "desc" },
});

// Redact sensitive config fields before sending to the client
// Decrypt sensitive fields, then redact the truly-secret ones before
// returning. The edit-form needs plaintext for non-redacted fields
// (slack webhookUrl, webhook headers); redacted fields are masked here
// and PRESERVE_IF_ABSENT carries them forward on save.
return channels.map((ch) => {
const config = ch.config as Record<string, unknown>;
const safeConfig = { ...config };
const decrypted = decryptChannelConfig(
ch.type,
ch.config as Record<string, unknown>,
);
const safeConfig = { ...decrypted };

// Redact passwords and secrets
if ("smtpPass" in safeConfig) safeConfig.smtpPass = "••••••••";
if ("hmacSecret" in safeConfig && safeConfig.hmacSecret)
safeConfig.hmacSecret = "••••••••";
Expand Down Expand Up @@ -111,7 +117,7 @@ export const alertChannelsRouter = router({
environmentId: input.environmentId,
name: input.name,
type: input.type,
config: input.config as Prisma.InputJsonValue,
config: encryptChannelConfig(input.type, input.config) as Prisma.InputJsonValue,
},
});
}),
Expand Down Expand Up @@ -194,12 +200,15 @@ export const alertChannelsRouter = router({
}
}

const encryptedConfig =
config !== undefined ? encryptChannelConfig(existing.type, config) : undefined;

return prisma.notificationChannel.update({
where: { id },
data: {
...rest,
...(config !== undefined
? { config: config as Prisma.InputJsonValue }
...(encryptedConfig !== undefined
? { config: encryptedConfig as Prisma.InputJsonValue }
: {}),
},
});
Expand Down Expand Up @@ -243,9 +252,11 @@ export const alertChannelsRouter = router({

try {
const driver = getDriver(channel.type);
const result = await driver.test(
const decrypted = decryptChannelConfig(
channel.type,
channel.config as Record<string, unknown>,
);
const result = await driver.test(decrypted);
return {
success: result.success,
error: result.error,
Expand Down
6 changes: 5 additions & 1 deletion src/server/routers/alert-deliveries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma";
import { withAudit } from "@/server/middleware/audit";
import type { ChannelPayload } from "@/server/services/channels/types";
import { getDriver } from "@/server/services/channels";
import { decryptChannelConfig } from "@/server/services/channel-secrets";

export const alertDeliveriesRouter = router({
listDeliveries: protectedProcedure
Expand Down Expand Up @@ -134,7 +135,10 @@ export const alertDeliveriesRouter = router({
channel.type,
channel.name,
async () => {
const result = await channelDriver.deliver(channel.config as Record<string, unknown>, payload);
const result = await channelDriver.deliver(
decryptChannelConfig(channel.type, channel.config as Record<string, unknown>),
payload,
);
return { success: result.success, error: result.error };
},
nextAttemptNumber,
Expand Down
59 changes: 59 additions & 0 deletions src/server/services/__tests__/channel-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeAll } from "vitest";
import { encryptChannelConfig, decryptChannelConfig } from "../channel-secrets";

beforeAll(() => {
process.env.NEXTAUTH_SECRET = "test-secret-for-vitest-runs-only";
});

describe("encryptChannelConfig", () => {
it("encrypts webhook hmacSecret as vfenc1:-prefixed ciphertext", () => {
const out = encryptChannelConfig("webhook", {
url: "https://example.com/hook",
hmacSecret: "raw-hmac-secret",
});
expect(out.url).toBe("https://example.com/hook");
expect(typeof out.hmacSecret).toBe("string");
expect(out.hmacSecret as string).toMatch(/^vfenc1:/);
});

it("does not treat user-supplied secrets that begin with v2: as already encrypted", () => {
const out = encryptChannelConfig("webhook", { hmacSecret: "v2:user-token" });
expect(out.hmacSecret as string).toMatch(/^vfenc1:/);
const round = decryptChannelConfig("webhook", out);
expect(round.hmacSecret).toBe("v2:user-token");
});
});

describe("encrypt+decrypt round trip", () => {
it.each([
["webhook", { url: "https://x.test/h", hmacSecret: "s1", headers: { Authorization: "Bearer abc" } }],
["slack", { webhookUrl: "https://hooks.slack.com/services/T/B/XYZ" }],
["pagerduty", { integrationKey: "ROUTING-KEY-123", severityMap: { error: "warning" } }],
["email", { smtpHost: "smtp.x", smtpUser: "u", smtpPass: "p", recipients: ["a@b"] }],
])("round-trips %s", (type, plain) => {
const enc = encryptChannelConfig(type, plain);
const dec = decryptChannelConfig(type, enc);
expect(dec).toEqual(plain);
});
});

describe("idempotency", () => {
it("does not re-encrypt already vfenc1-prefixed values", () => {
const once = encryptChannelConfig("webhook", { hmacSecret: "raw" });
const twice = encryptChannelConfig("webhook", once);
expect(twice.hmacSecret).toBe(once.hmacSecret);
});

it("decrypt is no-op on plaintext", () => {
const out = decryptChannelConfig("webhook", { hmacSecret: "plain" });
expect(out.hmacSecret).toBe("plain");
});
});

describe("unknown type", () => {
it("returns config unchanged for unknown channel type", () => {
const cfg = { foo: "bar" };
expect(encryptChannelConfig("unknown", cfg)).toEqual(cfg);
expect(decryptChannelConfig("unknown", cfg)).toEqual(cfg);
});
});
Loading
Loading