diff --git a/apps/worker/scripts/ses-policies.ts b/apps/worker/scripts/ses-policies.ts index 463d5ddf6..58a7b1cfb 100644 --- a/apps/worker/scripts/ses-policies.ts +++ b/apps/worker/scripts/ses-policies.ts @@ -71,6 +71,43 @@ export function cossPolicies( ] : []; + // `/v1/domains` and the dashboard's domain pages run the same code (`SesIdentityProvider`), so + // both need the same permissions. + const manageDomains: PolicyDocument["Statement"] = [ + { + Sid: "ManageCossTenants", + Effect: "Allow", + Action: ["ses:CreateTenant", "ses:GetTenant", "ses:TagResource"], + Resource: [tenants], + }, + { + Sid: "AssociateTenantResources", + Effect: "Allow", + Action: ["ses:CreateTenantResourceAssociation", "ses:DeleteTenantResourceAssociation"], + Resource: [tenants, identities, ...configurationSets], + }, + { + Sid: "InspectIdentities", + Effect: "Allow", + Action: ["ses:GetEmailIdentity", "ses:ListResourceTenants", "ses:ListTagsForResource"], + Resource: [identities], + }, + { + Sid: "CreateTaggedIdentities", + Effect: "Allow", + Action: ["ses:CreateEmailIdentity", "ses:TagResource"], + Resource: [identities], + Condition: tagsNewResources, + }, + { + Sid: "ChangeCossIdentities", + Effect: "Allow", + Action: ["ses:DeleteEmailIdentity", "ses:PutEmailIdentityMailFromAttributes"], + Resource: [identities], + Condition: managed, + }, + ]; + return { api: { Version: "2012-10-17", @@ -87,44 +124,14 @@ export function cossPolicies( Action: ["ses:SendEmail"], Resource: [...configurationSets, dedicatedSets], }, + ...manageDomains, ...contentReads, ], }, dashboard: { Version: "2012-10-17", Statement: [ - { - Sid: "ManageCossTenants", - Effect: "Allow", - Action: ["ses:CreateTenant", "ses:GetTenant", "ses:TagResource"], - Resource: [tenants], - }, - { - Sid: "AssociateTenantResources", - Effect: "Allow", - Action: ["ses:CreateTenantResourceAssociation", "ses:DeleteTenantResourceAssociation"], - Resource: [tenants, identities, ...configurationSets], - }, - { - Sid: "InspectIdentities", - Effect: "Allow", - Action: ["ses:GetEmailIdentity", "ses:ListResourceTenants", "ses:ListTagsForResource"], - Resource: [identities], - }, - { - Sid: "CreateTaggedIdentities", - Effect: "Allow", - Action: ["ses:CreateEmailIdentity", "ses:TagResource"], - Resource: [identities], - Condition: tagsNewResources, - }, - { - Sid: "ChangeCossIdentities", - Effect: "Allow", - Action: ["ses:DeleteEmailIdentity", "ses:PutEmailIdentityMailFromAttributes"], - Resource: [identities], - Condition: managed, - }, + ...manageDomains, ...contentReads, ...(bucket ? [ diff --git a/apps/worker/test/ses-policies.test.ts b/apps/worker/test/ses-policies.test.ts index 4c723c73b..cfe9ffd97 100644 --- a/apps/worker/test/ses-policies.test.ts +++ b/apps/worker/test/ses-policies.test.ts @@ -22,9 +22,12 @@ describe("cossPolicies", () => { } }); - test("the api can only send, with the coss.com configuration sets", () => { + test("the api sends with the coss.com configuration sets", () => { // SES authorizes sends against the sender address, which carries no tags (verified live). - expect(policies.api.Statement).toEqual([ + const sends = policies.api.Statement.filter((statement) => + statement.Action.includes("ses:SendEmail"), + ); + expect(sends).toEqual([ expect.objectContaining({ Action: ["ses:SendEmail"], Resource: ["arn:aws:ses:us-east-2:123456789012:identity/*"], @@ -42,6 +45,32 @@ describe("cossPolicies", () => { ]); }); + test("the api can do everything /v1/domains asks of SES, and no more than the dashboard", () => { + const sesActions = (app: "api" | "dashboard") => + policies[app].Statement.filter((statement) => statement.Sid !== "SendFromIdentities") + .filter((statement) => statement.Sid !== "SendWithCossConfigurationSet") + .filter((statement) => statement.Action.some((action) => action.startsWith("ses:"))); + expect(sesActions("api")).toEqual(sesActions("dashboard")); + + // What `SesIdentityProvider` calls while creating, verifying and deleting a domain. + const allowed = sesActions("api").flatMap((statement) => statement.Action); + for (const action of [ + "ses:CreateTenant", + "ses:GetTenant", + "ses:TagResource", + "ses:CreateTenantResourceAssociation", + "ses:DeleteTenantResourceAssociation", + "ses:CreateEmailIdentity", + "ses:GetEmailIdentity", + "ses:ListResourceTenants", + "ses:ListTagsForResource", + "ses:PutEmailIdentityMailFromAttributes", + "ses:DeleteEmailIdentity", + ]) { + expect(allowed).toContain(action); + } + }); + test("identity changes require the coss_managed tag; tenants are limited to the prefix", () => { const destructive = ["ses:DeleteEmailIdentity", "ses:PutEmailIdentityMailFromAttributes"]; for (const statement of statements) { diff --git a/packages/db/src/boss-errors.ts b/packages/db/src/boss-errors.ts new file mode 100644 index 000000000..0994fe345 --- /dev/null +++ b/packages/db/src/boss-errors.ts @@ -0,0 +1,9 @@ +/** + * Logs an error pg-boss emitted. Some of them are plain copies (`{ ...error, message, stack }`), + * which the console capture can't recognize as errors: Sentry would report `[object Object]`. + * Imports nothing, so `migrate.ts` can use it before any pool exists. + */ +export function logPgBossError(error: Error): void { + const real = error instanceof Error ? error : Object.assign(new Error(), error as object); + console.error("[jobs] pg-boss error:", real); +} diff --git a/packages/db/src/jobs.ts b/packages/db/src/jobs.ts index 3577e51f5..3abfe7854 100644 --- a/packages/db/src/jobs.ts +++ b/packages/db/src/jobs.ts @@ -5,6 +5,7 @@ */ import { sql } from "drizzle-orm"; import { fromDrizzle, PgBoss } from "pg-boss"; +import { logPgBossError } from "./boss-errors.ts"; import type { Transaction } from "./client.ts"; export type { Job, SendOptions } from "pg-boss"; @@ -40,7 +41,7 @@ export async function startJobBoss(opts: JobBossOptions = {}): Promise { max: 1, application_name: "coss-producer-jobs", }); - boss.on("error", (error) => console.error("[jobs] pg-boss error:", error)); + boss.on("error", logPgBossError); return boss.start(); } diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 36170ec95..0356638fa 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import pg from "pg"; import { PgBoss } from "pg-boss"; +import { logPgBossError } from "./boss-errors.ts"; import { aheadPartitionsSql, HOOK_PARTITIONS } from "./partition-specs.ts"; const useTestDatabase = process.argv.includes("--test"); @@ -23,7 +24,7 @@ try { } // pg-boss installs or upgrades its own schema (`pgboss`) on start; app processes only check it. const boss = new PgBoss({ connectionString: url, max: 1, supervise: false, schedule: false }); - boss.on("error", (error) => console.error("[jobs] pg-boss error:", error)); + boss.on("error", logPgBossError); await boss.start(); await boss.stop({ graceful: false }); console.info(`Migrated ${new URL(url).pathname.slice(1)}`); diff --git a/packages/db/test/boss-errors.test.ts b/packages/db/test/boss-errors.test.ts new file mode 100644 index 000000000..d92c8f149 --- /dev/null +++ b/packages/db/test/boss-errors.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { logPgBossError } from "../src/boss-errors.ts"; + +describe("logPgBossError", () => { + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + afterEach(() => consoleError.mockClear()); + + const logged = () => consoleError.mock.calls[0]?.[1] as Error & { code?: string }; + + test("turns the plain copies pg-boss emits back into errors", () => { + const original = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + const copy = { ...original, message: original.message, stack: original.stack }; + + logPgBossError(copy as Error); + + expect(logged()).toBeInstanceOf(Error); + expect(logged().message).toBe("connect ECONNREFUSED"); + expect(logged().stack).toBe(original.stack as string); + expect(logged().code).toBe("ECONNREFUSED"); + }); + + test("logs real errors as they are", () => { + const error = new Error("boom"); + + logPgBossError(error); + + expect(logged()).toBe(error); + }); +});