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
1,574 changes: 1,544 additions & 30 deletions THIRD_PARTY_NOTICES.md

Large diffs are not rendered by default.

30 changes: 20 additions & 10 deletions apps/site/app/api/email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,29 @@ Set server-only environment variables on the production deployment:
| --- | --- |
| `AWS_REGION` | The SES region with the verified sender identity |
| `POLARIS_EMAIL_FROM` | A verified sender email address (address only) |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Credentials permitted to call `ses:GetEmailAddressInsights` and `ses:SendEmail` |
| `POLARIS_EMAIL_CONFIGURATION_SET` | Required dedicated SES configuration set name, e.g. `polaris-download` |
| `AWS_SESSION_TOKEN` | Session token, if using temporary credentials |
| `POLARIS_EMAIL_USE_ROLE` | `true` to opt into the SDK's default credential chain for a hosted role instead of static credentials |
| `AWS_ROLE_ARN` | The IAM role assumed with Vercel's OIDC token (`arn:aws:iam::<account>:role/<name>`) |
| `AXIOM_TOKEN`, `AXIOM_DATASET` | Optional, shared with the update/download routes |

Credentials come only from the IAM role: `@vercel/oidc-aws-credentials-provider`
exchanges the deployment's OIDC token for short-lived STS credentials. Static
access keys (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) are
never read, and must not be set (docs/adr/0018). Setup:

1. Vercel: Project → Settings → Security → OIDC federation, issuer mode Team.
2. AWS IAM → Identity providers → OpenID Connect: URL `https://oidc.vercel.com/<team-slug>`,
audience `https://vercel.com/<team-slug>`.
3. A role trusting that provider for `sts:AssumeRoleWithWebIdentity`, with
`oidc.vercel.com/<team-slug>:aud` = `https://vercel.com/<team-slug>` and
`oidc.vercel.com/<team-slug>:sub` = `owner:<team-slug>:project:<project>:environment:production`.
4. Role permissions: `ses:GetEmailAddressInsights` (resource `*`) and `ses:SendEmail`
scoped to the sender identity and configuration set ARNs.

No public env variables, credentials in code, AWS config lookups for region or
sender, or automatic fallback to a mail app. Role mode relies on the hosting
environment's credential provider (for example container credentials or web
identity via `AWS_ROLE_ARN` / `AWS_WEB_IDENTITY_TOKEN_FILE`); grant Insights permission and sender-scoped send
permission. Credentials are resolved by the SDK at validation/send time.
Missing region, sender, configuration set, credential pair or role opt-in returns 503; resolution
or SES errors also return a generic 503 and discard provider details.
sender, or automatic fallback to a mail app. Credentials are resolved at
validation/send time. Missing region, sender, configuration set or a valid role
ARN returns 503; token exchange or SES errors also return a generic 503 and
discard provider details.

Development and tests always use a fake validator and transport, even with AWS credentials
present. The response header `X-Polaris-Email-Preview: 1` makes the form say
Expand Down Expand Up @@ -151,5 +161,5 @@ Docs: [BotID setup](https://vercel.com/docs/botid/get-started),
[SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html),
[SES IAM](https://docs.aws.amazon.com/service-authorization/latest/reference/list_sesv2.html),
[SESv2 SendEmail](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sesv2/command/SendEmailCommand/),
[credential chain](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html),
[Vercel OIDC for AWS](https://vercel.com/docs/oidc/aws),
[Vercel request headers](https://vercel.com/docs/headers/request-headers#x-vercel-forwarded-for).
19 changes: 12 additions & 7 deletions apps/site/app/api/email/_lib/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ const configured: EmailEnv = {
AWS_REGION: "us-east-1",
POLARIS_EMAIL_FROM: "download@example.com",
POLARIS_EMAIL_CONFIGURATION_SET: "polaris-download",
AWS_ACCESS_KEY_ID: "fake-key",
AWS_SECRET_ACCESS_KEY: "fake-secret",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/polaris-download-email",
};

const submission = (
Expand Down Expand Up @@ -189,8 +188,14 @@ describe("transport configuration", () => {
{ ...configured, AWS_REGION: "" },
{ ...configured, POLARIS_EMAIL_CONFIGURATION_SET: "" },
{ ...configured, POLARIS_EMAIL_FROM: "bad" },
{ ...configured, AWS_SECRET_ACCESS_KEY: "" },
{ ...configured, AWS_ACCESS_KEY_ID: "", AWS_SECRET_ACCESS_KEY: "" },
{ ...configured, AWS_ROLE_ARN: "" },
{ ...configured, AWS_ROLE_ARN: "not-an-arn" },
{
...configured,
AWS_ROLE_ARN: "",
AWS_ACCESS_KEY_ID: "static-key",
AWS_SECRET_ACCESS_KEY: "static-secret",
},
{ ...configured, POLARIS_EMAIL_TRANSPORT: "fake" },
])("fails closed before creating a client %#", (env) => {
let calls = 0;
Expand All @@ -203,7 +208,7 @@ describe("transport configuration", () => {
expect(calls).toBe(0);
});

test("role mode opts into the SDK credential chain and bounds retries", () => {
test("credentials come from the Vercel OIDC role and retries are bounded", () => {
const configs: SESv2ClientConfig[] = [];

const factory: SesFactory = (config) => {
Expand All @@ -219,12 +224,12 @@ describe("transport configuration", () => {
AWS_REGION: "us-east-1",
POLARIS_EMAIL_FROM: "download@example.com",
POLARIS_EMAIL_CONFIGURATION_SET: "polaris-download",
POLARIS_EMAIL_USE_ROLE: "true",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/polaris-download-email",
},
factory
)
).not.toBeNull();
expect(configs[0]?.credentials).toBeUndefined();
expect(configs[0]?.credentials).toBeInstanceOf(Function);
expect(configs[0]?.maxAttempts).toBe(1);
});
});
Expand Down
43 changes: 43 additions & 0 deletions apps/site/app/api/email/_lib/ses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import { readSesConfig } from "./ses";

const env = {
AWS_REGION: "us-east-1",
POLARIS_EMAIL_FROM: "download@example.com",
POLARIS_EMAIL_CONFIGURATION_SET: "polaris-download",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/polaris-download-email",
};

describe("readSesConfig", () => {
test("assumes the configured role and never builds static credentials", () => {
const roles: string[] = [];
const provider = async () => ({ accessKeyId: "from-sts", secretAccessKey: "from-sts" });

const config = readSesConfig(
{ ...env, AWS_ACCESS_KEY_ID: "static-key", AWS_SECRET_ACCESS_KEY: "static-secret" },
(roleArn) => {
roles.push(roleArn);

return provider;
}
);

expect(roles).toEqual([env.AWS_ROLE_ARN]);
expect(config?.credentials).toBe(provider);
});

test.each(["", "arn:aws:iam::123456789012:user/static", "arn:aws:iam::12:role/x"])(
"refuses a missing or non-role ARN %#",
(AWS_ROLE_ARN) => {
let calls = 0;

expect(
readSesConfig({ ...env, AWS_ROLE_ARN }, () => {
calls++;
throw new Error("must not run");
})
).toBeNull();
expect(calls).toBe(0);
}
);
});
37 changes: 19 additions & 18 deletions apps/site/app/api/email/_lib/ses.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
import type { SESv2ClientConfig } from "@aws-sdk/client-sesv2";
import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider";
import { validEmail } from "./validation";

export type EmailEnv = Readonly<Record<string, string | undefined>>;

/** Validation and delivery use the same explicit Region and credential policy. */
export function readSesConfig(env: EmailEnv): SESv2ClientConfig | null {
export type RoleCredentials = (roleArn: string) => NonNullable<SESv2ClientConfig["credentials"]>;

const ROLE_ARN = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/;

const vercelRole: RoleCredentials = (roleArn) => awsCredentialsProvider({ roleArn });

/**
* Validation and delivery use the same explicit Region and an IAM role assumed with
* Vercel's OIDC token; static access keys are never read (docs/adr/0018).
*/
export function readSesConfig(
env: EmailEnv,
credentialsFor: RoleCredentials = vercelRole
): SESv2ClientConfig | null {
if (env.POLARIS_EMAIL_TRANSPORT === "fake") return null;
const region = env.AWS_REGION?.trim();
const sender = env.POLARIS_EMAIL_FROM?.trim();
const roleArn = env.AWS_ROLE_ARN?.trim();

if (!region || !sender || !validEmail(sender) || !env.POLARIS_EMAIL_CONFIGURATION_SET?.trim())
return null;
const accessKeyId = env.AWS_ACCESS_KEY_ID;
const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;

if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) return null;

if (!accessKeyId && env.POLARIS_EMAIL_USE_ROLE !== "true") return null;
if (!roleArn || !ROLE_ARN.test(roleArn)) return null;

const config: SESv2ClientConfig = {
return {
region,
maxAttempts: 1,
requestHandler: { connectionTimeout: 3000, requestTimeout: 10000 },
credentials: credentialsFor(roleArn),
};

if (accessKeyId && secretAccessKey) {
config.credentials = { accessKeyId, secretAccessKey };

if (env.AWS_SESSION_TOKEN) {
config.credentials = { accessKeyId, secretAccessKey, sessionToken: env.AWS_SESSION_TOKEN };
}
}

return config;
}
38 changes: 10 additions & 28 deletions apps/site/app/api/email/_lib/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import {
const env = {
NODE_ENV: "production",
AWS_REGION: "us-east-1",
AWS_ACCESS_KEY_ID: "fake-key",
AWS_SECRET_ACCESS_KEY: "fake-secret",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/polaris-download-email",
POLARIS_EMAIL_FROM: "download@example.com",
POLARIS_EMAIL_CONFIGURATION_SET: "polaris-download",
};
Expand Down Expand Up @@ -261,11 +260,11 @@ describe("SES Insights route mapping", () => {
});

describe("SES Insights configuration and privacy", () => {
test("uses the send Region/credentials, one attempt, signed body command and bounded timeout", async () => {
test("uses the send Region and OIDC role, one attempt, signed body command and bounded timeout", async () => {
const configs: SESv2ClientConfig[] = [];

const validator = createValidator(
{ ...env, AWS_SESSION_TOKEN: "fake-token" },
{ ...env, AWS_ACCESS_KEY_ID: "static-key", AWS_SECRET_ACCESS_KEY: "static-secret" },
(config) => {
configs.push(config);

Expand All @@ -283,42 +282,25 @@ describe("SES Insights configuration and privacy", () => {
);

expect(await validator.validate(email)).toBe(true);
createTransport({ ...env, AWS_SESSION_TOKEN: "fake-token" }, (config) => {
createTransport(env, (config) => {
configs.push(config);

return { send: async () => ({}) };
});
expect(configs[0]?.region).toBe(configs[1]?.region);
expect(configs[0]?.credentials).toEqual(configs[1]?.credentials);
expect(configs[0]?.credentials).toEqual({
accessKeyId: "fake-key",
secretAccessKey: "fake-secret",
sessionToken: "fake-token",
});
// Static keys in the environment are ignored: credentials always come from the role.
expect(configs[0]?.credentials).toBeInstanceOf(Function);
expect(configs[1]?.credentials).toBeInstanceOf(Function);
expect(configs[0]?.maxAttempts).toBe(1);
expect(configs[0]?.requestHandler).toEqual({ connectionTimeout: 3000, requestTimeout: 5000 });
});

test("explicit hosted role uses the same SDK chain", async () => {
const validator = createValidator(
{ ...env, AWS_ACCESS_KEY_ID: "", AWS_SECRET_ACCESS_KEY: "", POLARIS_EMAIL_USE_ROLE: "true" },
(config) => {
expect(config.credentials).toBeUndefined();

return { send: async () => good() };
},
createValidationCounters()
);

expect(await validator.validate(email)).toBe(true);
});

test.each([
{ AWS_REGION: "" },
{ POLARIS_EMAIL_FROM: "bad" },
{ AWS_ACCESS_KEY_ID: "" },
{ AWS_SECRET_ACCESS_KEY: "" },
{ AWS_ACCESS_KEY_ID: "", AWS_SECRET_ACCESS_KEY: "" },
{ AWS_ROLE_ARN: "" },
{ AWS_ROLE_ARN: "arn:aws:iam::123456789012:user/static" },
{ AWS_ROLE_ARN: "", AWS_ACCESS_KEY_ID: "static-key", AWS_SECRET_ACCESS_KEY: "static-secret" },
{ POLARIS_EMAIL_TRANSPORT: "fake" },
{ POLARIS_EMAIL_CONFIGURATION_SET: "" },
])("unconfigured production never creates a client %#", async (missing) => {
Expand Down
8 changes: 7 additions & 1 deletion apps/site/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import appleIcon from "../../../design/assets/app-icon/polaris.iconset/icon_256x256.png";
Expand Down Expand Up @@ -34,7 +36,11 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
3 changes: 3 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"dependencies": {
"@aws-sdk/client-sesv2": "3.1146.0",
"@polaris/ui": "workspace:*",
"@vercel/analytics": "2.0.1",
"@vercel/oidc-aws-credentials-provider": "3.3.11",
"@vercel/speed-insights": "2.0.0",
"botid": "1.5.11",
"effect": "4.0.0-rc.118",
"next": "16.3.8",
Expand Down
Loading
Loading