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
71 changes: 39 additions & 32 deletions apps/worker/scripts/ses-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
? [
Expand Down
33 changes: 31 additions & 2 deletions apps/worker/test/ses-policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"],
Expand All @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/db/src/boss-errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion packages/db/src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,7 +41,7 @@ export async function startJobBoss(opts: JobBossOptions = {}): Promise<PgBoss> {
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();
}

Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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)}`);
Expand Down
29 changes: 29 additions & 0 deletions packages/db/test/boss-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading