From 22738baf3a1eca47546cafc6210ef023a8e246ab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 18:24:53 +0200 Subject: [PATCH 1/2] feat(infra): deploys create Maple's AWS integration identity Each prd instance and PR preview declares an IAM user that can only sts:AssumeRole into MapleIntegration-* roles, plus its access key, and binds both into api and alerting. Dev keeps the shared dev user from .env.local. --- .env.example | 1 + alchemy.run.ts | 5 +- apps/alerting/src/worker.ts | 6 +- apps/api/src/resources/env.ts | 2 +- apps/api/src/worker.ts | 4 +- docs/pr-previews.md | 2 +- packages/domain/src/aws.ts | 5 +- .../src/__snapshots__/profile.test.ts.snap | 5 ++ packages/infra/src/aws/index.ts | 1 + .../infra/src/aws/integration-principal.ts | 58 +++++++++++++++++++ packages/infra/src/cloudflare/stack.ts | 3 + packages/infra/src/profile.ts | 8 ++- 12 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 packages/infra/src/aws/integration-principal.ts diff --git a/.env.example b/.env.example index 11149792c3..3d5e0d97a9 100644 --- a/.env.example +++ b/.env.example @@ -201,6 +201,7 @@ 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. +# 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 # MAPLE_AWS_ACCESS_KEY_ID= # MAPLE_AWS_SECRET_ACCESS_KEY= diff --git a/alchemy.run.ts b/alchemy.run.ts index 1c6b6cc968..575533af28 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, @@ -238,6 +238,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 b6baf71f15..2bf50f130e 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -70,7 +70,7 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom planetScaleOAuthEnv, // 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"), @@ -86,7 +86,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) @@ -105,6 +105,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 3b27051a3d..119dedbe86 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -82,7 +82,7 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains cloudflareOAuthEnv, planetScaleOAuthEnv, 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/__snapshots__/profile.test.ts.snap b/packages/infra/src/__snapshots__/profile.test.ts.snap index 7398912585..fcaa1bfa77 100644 --- a/packages/infra/src/__snapshots__/profile.test.ts.snap +++ b/packages/infra/src/__snapshots__/profile.test.ts.snap @@ -5,6 +5,7 @@ exports[`resolveMapleProfile > pins every deployment's profile 1`] = ` "dev-alice": { "database": "managed", "deploys": { + "awsIntegrationPrincipal": false, "awsSecurityBaseline": "none", "collector": false, "electric": false, @@ -39,6 +40,7 @@ exports[`resolveMapleProfile > pins every deployment's profile 1`] = ` "dev-alice-eu": { "database": "managed", "deploys": { + "awsIntegrationPrincipal": false, "awsSecurityBaseline": "none", "collector": false, "electric": false, @@ -73,6 +75,7 @@ exports[`resolveMapleProfile > pins every deployment's profile 1`] = ` "pr-12": { "database": "preview", "deploys": { + "awsIntegrationPrincipal": true, "awsSecurityBaseline": "none", "collector": false, "electric": true, @@ -107,6 +110,7 @@ exports[`resolveMapleProfile > pins every deployment's profile 1`] = ` "prd": { "database": "ref", "deploys": { + "awsIntegrationPrincipal": true, "awsSecurityBaseline": "account", "collector": true, "electric": true, @@ -147,6 +151,7 @@ exports[`resolveMapleProfile > pins every deployment's profile 1`] = ` "prd-eu": { "database": "declared", "deploys": { + "awsIntegrationPrincipal": true, "awsSecurityBaseline": "regional", "collector": true, "electric": true, 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.ts b/packages/infra/src/profile.ts index 6401343d5c..734c895645 100644 --- a/packages/infra/src/profile.ts +++ b/packages/infra/src/profile.ts @@ -52,6 +52,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. */ @@ -94,6 +96,7 @@ const US_PRD: MapleProfile = { sandbox: true, replayBlobs: true, awsSecurityBaseline: "account", + awsIntegrationPrincipal: true, }, ingest: { desiredCount: 2, @@ -147,12 +150,13 @@ const PR: MapleProfile = { sandbox: false, replayBlobs: false, awsSecurityBaseline: "none", + awsIntegrationPrincipal: true, }, ingest: SMALL_INGEST, electric: { taskSize: { cpu: 256, memory: 512 }, dbPoolSize: undefined }, } -/** `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 }, @@ -163,7 +167,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 { From 87f4e624cd6ff539094f378d1a83571c41fb72f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 18:11:25 +0200 Subject: [PATCH 2/2] docs(env): the dev AWS integration user is maple-integrations-dev The deployed prd user takes maple-integrations. --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 25e7233b84..baf83713f1 100644 --- a/.env.example +++ b/.env.example @@ -203,7 +203,7 @@ INGEST_REQUIRE_TLS=false # 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. # 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 +# MAPLE_AWS_PRINCIPAL_ARN=arn:aws:iam:::user/maple-integrations-dev # MAPLE_AWS_ACCESS_KEY_ID= # MAPLE_AWS_SECRET_ACCESS_KEY=