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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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::<maple-account>: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::<maple-account>:user/maple-integrations-dev
# MAPLE_AWS_ACCESS_KEY_ID=
# MAPLE_AWS_SECRET_ACCESS_KEY=

Expand Down
5 changes: 4 additions & 1 deletion alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}),
Expand Down
6 changes: 4 additions & 2 deletions apps/alerting/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)
Expand All @@ -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,
},
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
},
}
Expand Down
2 changes: 1 addition & 1 deletion docs/pr-previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/domain/src/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/infra/src/aws/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./pg-url.ts"
export * from "./stage.ts"
export * from "./secrets.ts"
export * from "./integration-principal.ts"
58 changes: 58 additions & 0 deletions packages/infra/src/aws/integration-principal.ts
Original file line number Diff line number Diff line change
@@ -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<string>
readonly MAPLE_AWS_ACCESS_KEY_ID: Output.Output<string>
readonly MAPLE_AWS_SECRET_ACCESS_KEY: Output.Output<Redacted.Redacted<string>>
}

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),
Comment thread
Makisuo marked this conversation as resolved.
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(""),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return env
})
3 changes: 3 additions & 0 deletions packages/infra/src/cloudflare/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,6 +60,8 @@ export interface MapleStackContext {
readonly devEnv: Record<string, string> | 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__`. */
Expand Down
17 changes: 16 additions & 1 deletion packages/infra/src/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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,
})
})
})
8 changes: 6 additions & 2 deletions packages/infra/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -100,6 +102,7 @@ const US_PRD: MapleProfile = {
sandbox: true,
replayBlobs: true,
awsSecurityBaseline: "account",
awsIntegrationPrincipal: true,
},
ingest: {
desiredCount: 2,
Expand Down Expand Up @@ -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 },
Expand All @@ -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 {
Expand Down
Loading