diff --git a/.env.example b/.env.example index 461bd648bd..baf83713f1 100644 --- a/.env.example +++ b/.env.example @@ -202,7 +202,8 @@ INGEST_REQUIRE_TLS=false # AWS integration (an administrator deploys a generated CloudFormation stack from CloudShell). # Maple's own IAM user, whose only permission is sts:AssumeRole on arn:aws:iam::*:role/MapleIntegration-*. # Customer roles trust it with their connection's external id. All three unset: AWS is unavailable. -# MAPLE_AWS_PRINCIPAL_ARN=arn:aws:iam:::user/maple-integrations +# Deploys create and bind their own (`packages/infra/src/aws/integration-principal.ts`); set these for dev only. +# MAPLE_AWS_PRINCIPAL_ARN=arn:aws:iam:::user/maple-integrations-dev # MAPLE_AWS_ACCESS_KEY_ID= # MAPLE_AWS_SECRET_ACCESS_KEY= diff --git a/alchemy.run.ts b/alchemy.run.ts index 0b3709fde0..9e015f47ea 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -15,7 +15,7 @@ import { SourceError } from "effect/ConfigProvider" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Redacted from "effect/Redacted" -import { AwsRegionMismatchError, resolveAwsRegion } from "@maple/infra/aws" +import { AwsRegionMismatchError, createMapleAwsPrincipal, resolveAwsRegion } from "@maple/infra/aws" import { ApiWorker, AiWorker, @@ -239,6 +239,9 @@ const MapleStackLive = Layer.effect( workerDev, devEnv, db: yield* declareMapleDb(deployment, profile), + awsPrincipal: profile.deploys.awsIntegrationPrincipal + ? yield* createMapleAwsPrincipal(deployment) + : undefined, } return context }), diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index 43a1fd724e..e16155e8bf 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -72,7 +72,7 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom supabaseOAuthEnv, // Sign-in for the Google Cloud metrics poller; unset leaves its tick a no-op. optionalSecret("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), - // Maple's AWS identity for the hourly connection check; unset leaves its tick a no-op. + // Maple's AWS identity on dev (`.env.local`); deploys bind their own. Unset: the tick is a no-op. optionalPlain("MAPLE_AWS_PRINCIPAL_ARN"), optionalPlain("MAPLE_AWS_ACCESS_KEY_ID"), optionalSecret("MAPLE_AWS_SECRET_ACCESS_KEY"), @@ -88,7 +88,7 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } const stack = yield* MapleStack - const { stage, region, domains, devEnv, db } = stack + const { stage, region, domains, devEnv, db, awsPrincipal } = stack // For `IncidentClassifier`. Yielded, not `Worker.ref`: a ref cannot see a sibling this deploy creates. const ai = yield* AiWorker const env = yield* configuredEnv(stage, region, domains) @@ -107,6 +107,8 @@ const props = Effect.gen(function* () { // Cron fires hop through it so their ticks run where placement applies. [SELF_BINDING]: Cloudflare.Workers.Self, ...env, + // After the config entries: a deploy's own identity wins over a stray MAPLE_AWS_* var. + ...awsPrincipal, ...devEnv, }, } diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index bb147efd5e..ffff023e15 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -85,7 +85,7 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains planetScaleOAuthEnv, supabaseOAuthEnv, optionalPlain("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), - // Maple's AWS identity: assumes customer roles. Unset leaves AWS unavailable. + // Maple's AWS identity on dev (`.env.local`); deploys bind their own (`awsPrincipal`). optionalPlain("MAPLE_AWS_PRINCIPAL_ARN"), optionalPlain("MAPLE_AWS_ACCESS_KEY_ID"), optionalSecret("MAPLE_AWS_SECRET_ACCESS_KEY"), diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 6ce4292d4c..40e70c66d7 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -27,7 +27,7 @@ import ClickHouseSchemaApplyWorkflow from "./workflows/ClickHouseSchemaApplyWork const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } const stack = yield* MapleStack - const { stage, region, domains, devEnv, db } = stack + const { stage, region, domains, devEnv, db, awsPrincipal } = stack // api keeps the hostname and OAuth, and forwards `/mcp` and chat to maple-ai. const ai = yield* AiWorker // Resolved up front so a misconfigured deploy fails before applying anything. @@ -50,6 +50,8 @@ const props = Effect.gen(function* () { // Minted once and kept in state; bound to this Worker alone. [VCS_SYNC_HOP_SECRET]: yield* makeRandom("VcsSyncHopSecret"), ...configuredEnv, + // After the config entries: a deploy's own identity wins over a stray MAPLE_AWS_* var. + ...awsPrincipal, ...devEnv, }, } diff --git a/docs/pr-previews.md b/docs/pr-previews.md index 48d5d051b3..5c12eb7ddb 100644 --- a/docs/pr-previews.md +++ b/docs/pr-previews.md @@ -26,7 +26,7 @@ setup snippets and connect flows hand out the preview's gateway. For UI work that does not need ingest, label the PR `preview:web` instead. It deploys the Workers (web, api, ai, alerting, chat-bot, electric-sync) on the same Neon and Tinybird -branches, seeded and smoke-tested the same way, but nothing on AWS: no ingest gateway, no +branches, seeded and smoke-tested the same way, but on AWS only the AWS integration's IAM user: no ingest gateway, no Electric (live sync stays off), no landing or local-ui. It holds no VPC, so it does not count against `MAX_LIVE_PREVIEWS`, and deploys faster. Its only data is the seed: setup snippets point at the shared ingest, which cannot see the preview's ingest keys, so connect flows and diff --git a/packages/domain/src/aws.ts b/packages/domain/src/aws.ts index b8cf666366..945698aa24 100644 --- a/packages/domain/src/aws.ts +++ b/packages/domain/src/aws.ts @@ -84,13 +84,16 @@ export const AWS_MODULE_SPECS = { export const isAwsModuleAvailable = (module: AwsModule) => AWS_MODULE_SPECS[module].available +/** Every connection's role starts with it; Maple's own identity may assume only these roles. */ +export const AWS_ROLE_NAME_PREFIX = "MapleIntegration-" + /** * Names of what a connection's setup creates in the customer's account. Derived from the * connection id alone, so a caller can never pick the role Maple assumes. */ export const awsConnectionResourceNames = (connectionId: AwsConnectionId) => { const hex = connectionId.replaceAll("-", "").slice(0, 24) - const name = `MapleIntegration-${hex}` + const name = `${AWS_ROLE_NAME_PREFIX}${hex}` return { roleName: name, stackName: `maple-integration-${hex}`, diff --git a/packages/infra/src/aws/index.ts b/packages/infra/src/aws/index.ts index b1806f9010..25f4160c98 100644 --- a/packages/infra/src/aws/index.ts +++ b/packages/infra/src/aws/index.ts @@ -1,3 +1,4 @@ export * from "./pg-url.ts" export * from "./stage.ts" export * from "./secrets.ts" +export * from "./integration-principal.ts" diff --git a/packages/infra/src/aws/integration-principal.ts b/packages/infra/src/aws/integration-principal.ts new file mode 100644 index 0000000000..15ac80b1fe --- /dev/null +++ b/packages/infra/src/aws/integration-principal.ts @@ -0,0 +1,58 @@ +/** + * Maple's own AWS identity for the AWS integration: an IAM user that can only assume customer + * `MapleIntegration-*` roles, and its access key. Customer stacks trust the user's ARN, so each + * instance (and each preview) has its own. The key lives in alchemy state; rename the + * `AccessKey` to rotate it. + */ +import { AWS_ROLE_NAME_PREFIX } from "@maple/domain/aws" +import * as AWS from "alchemy/AWS" +import * as Output from "alchemy/Output" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" +import type { MapleStage } from "../cloudflare/stage.ts" +import type { MapleRegion } from "../region.ts" +import { resolveAwsResourceName } from "./stage.ts" + +/** The env api and alerting read the identity from (`packages/backend/src/platform/Env.ts`). */ +export interface MapleAwsPrincipalEnv { + readonly MAPLE_AWS_PRINCIPAL_ARN: Output.Output + readonly MAPLE_AWS_ACCESS_KEY_ID: Output.Output + readonly MAPLE_AWS_SECRET_ACCESS_KEY: Output.Output> +} + +export interface MapleAwsPrincipalOptions { + readonly stage: MapleStage + readonly region: MapleRegion +} + +export const createMapleAwsPrincipal = ({ stage, region }: MapleAwsPrincipalOptions) => + Effect.gen(function* () { + const user = yield* AWS.IAM.User("aws-integrations-user", { + userName: resolveAwsResourceName("integrations", stage, region), + inlinePolicies: { + "assume-integration-roles": { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["sts:AssumeRole"], + Resource: [`arn:aws:iam::*:role/${AWS_ROLE_NAME_PREFIX}*`], + }, + ], + }, + }, + tags: { Service: "maple-aws-integration", Region: region }, + }) + const key = yield* AWS.IAM.AccessKey("aws-integrations-key", { userName: user.userName }) + const env: MapleAwsPrincipalEnv = { + MAPLE_AWS_PRINCIPAL_ARN: user.userArn, + MAPLE_AWS_ACCESS_KEY_ID: key.accessKeyId, + // AWS returns the secret only when it creates the key. A blank one reads as unset at + // runtime, so a key alchemy did not create leaves AWS unavailable rather than broken. + MAPLE_AWS_SECRET_ACCESS_KEY: Output.map( + key.secretAccessKey, + (secret) => secret ?? Redacted.make(""), + ), + } + return env + }) diff --git a/packages/infra/src/cloudflare/stack.ts b/packages/infra/src/cloudflare/stack.ts index 9688d51dce..14eabaf2a1 100644 --- a/packages/infra/src/cloudflare/stack.ts +++ b/packages/infra/src/cloudflare/stack.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect" import type { WorkerDev } from "@maple/alchemy-portless" import { type DevApp, isDevApp } from "../dev-urls.ts" import { Stage } from "alchemy/Stage" +import type { MapleAwsPrincipalEnv } from "../aws/integration-principal.ts" import type { MapleProfile } from "../profile.ts" import type { MapleRegion } from "../region.ts" import { @@ -59,6 +60,8 @@ export interface MapleStackContext { readonly devEnv: Record | undefined /** The declared database (prd and previews); undefined on dev. */ readonly db: MapleDbResources | undefined + /** Maple's AWS integration identity, as api's and alerting's env; undefined on dev. */ + readonly awsPrincipal: MapleAwsPrincipalEnv | undefined } /** The deploy context Worker props read. Plan-time only: read behind `__ALCHEMY_RUNTIME__`. */ diff --git a/packages/infra/src/profile.test.ts b/packages/infra/src/profile.test.ts index e8471e333f..243c884515 100644 --- a/packages/infra/src/profile.test.ts +++ b/packages/infra/src/profile.test.ts @@ -23,7 +23,7 @@ describe("resolveMapleProfile", () => { expect(profile("dev-alice").deploys.ingest).toBe(false) }) - it("runs a `preview:web` PR on its own database with nothing on AWS", () => { + it("runs a `preview:web` PR on its own database without the AWS services", () => { const full = profile("pr-12") const web = resolveMapleProfile(parseMapleDeployment("pr-12"), { webOnlyPreview: true }) expect(web.deploys).toEqual({ ...full.deploys, sharedApps: false, ingest: false, electric: false }) @@ -48,4 +48,19 @@ describe("resolveMapleProfile", () => { "dev-alice-eu": "none", }) }) + + // Customer AWS stacks trust one Maple IAM user each; dev signs with the shared dev user. + it("gives every deployed instance and preview its own AWS integration identity", () => { + const web = resolveMapleProfile(parseMapleDeployment("pr-12"), { webOnlyPreview: true }) + expect(web.deploys.awsIntegrationPrincipal).toBe(true) + expect( + Object.fromEntries(DEPLOYMENTS.map((raw) => [raw, profile(raw).deploys.awsIntegrationPrincipal])), + ).toEqual({ + prd: true, + "prd-eu": true, + "pr-12": true, + "dev-alice": false, + "dev-alice-eu": false, + }) + }) }) diff --git a/packages/infra/src/profile.ts b/packages/infra/src/profile.ts index f75b172430..ae4c4cf43b 100644 --- a/packages/infra/src/profile.ts +++ b/packages/infra/src/profile.ts @@ -54,6 +54,8 @@ export interface MapleProfile { readonly replayBlobs: boolean /** prd only: previews and dev share the AWS account and must not own its audit controls. */ readonly awsSecurityBaseline: AwsSecurityBaseline + /** Maple's IAM user for the AWS integration. Dev uses the shared dev user from `.env.local`. */ + readonly awsIntegrationPrincipal: boolean } readonly ingest: { /** The per-org replay byte budget is process-local, so it scales with this. */ @@ -100,6 +102,7 @@ const US_PRD: MapleProfile = { sandbox: true, replayBlobs: true, awsSecurityBaseline: "account", + awsIntegrationPrincipal: true, }, ingest: { desiredCount: 2, @@ -157,13 +160,14 @@ const PR: MapleProfile = { sandbox: false, replayBlobs: false, awsSecurityBaseline: "none", + awsIntegrationPrincipal: true, }, ingest: SMALL_INGEST, electric: { taskSize: { cpu: 256, memory: 512 }, dbPoolSize: undefined }, scraper: { taskSize: { cpu: 256, memory: 512 } }, } -/** `preview:web` PRs: the Workers on their own Neon and Tinybird branches, nothing on AWS. */ +/** `preview:web` PRs: the Workers on their own Neon and Tinybird branches; on AWS, only an IAM user. */ const PR_WEB: MapleProfile = { ...PR, deploys: { ...PR.deploys, sharedApps: false, ingest: false, electric: false }, @@ -174,7 +178,7 @@ const DEV: MapleProfile = { ...PR, database: "managed", migratesDatabase: false, - deploys: { ...PR.deploys, ingest: false, electric: false }, + deploys: { ...PR.deploys, ingest: false, electric: false, awsIntegrationPrincipal: false }, } export interface MapleProfileOptions {