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
9 changes: 3 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,9 @@ INGEST_REQUIRE_TLS=false
# MAPLE_GCP_SERVICE_ACCOUNT_KEY=

# 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::<maple-account>:user/maple-integrations-dev
# MAPLE_AWS_ACCESS_KEY_ID=
# MAPLE_AWS_SECRET_ACCESS_KEY=
# Customer roles trust Maple's OIDC issuer, {MAPLE_API_BASE_URL}/oidc/aws. Every stack, dev included,
# mints its signing key (MAPLE_AWS_OIDC_SIGNING_KEY) in alchemy state; nothing to set here. AWS
# cannot reach a local issuer, so connection checks only pass on a deploy.

# OpenTelemetry (API self-observability) — read by @maple-dev/effect-sdk
# MAPLE_ENVIRONMENT=local # "local" = no export; any other value enables OTLP
Expand Down
6 changes: 2 additions & 4 deletions 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, createMapleAwsPrincipal, resolveAwsRegion } from "@maple/infra/aws"
import { AwsRegionMismatchError, createMapleAwsOidcKey, resolveAwsRegion } from "@maple/infra/aws"
import {
ApiWorker,
AiWorker,
Expand Down Expand Up @@ -239,9 +239,7 @@ const MapleStackLive = Layer.effect(
workerDev,
devEnv,
db: yield* declareMapleDb(deployment, profile),
awsPrincipal: profile.deploys.awsIntegrationPrincipal
? yield* createMapleAwsPrincipal(deployment)
: undefined,
awsOidc: yield* createMapleAwsOidcKey(),
}
return context
}),
Expand Down
9 changes: 2 additions & 7 deletions apps/alerting/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ 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 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"),
// Ticks post pull-request replies through the GitHub App; without it each one fails.
githubAppSourceEnv,
// Outbound config for `chat` destinations' connectors.
Expand All @@ -88,7 +84,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, awsPrincipal } = stack
const { stage, region, domains, devEnv, db, awsOidc } = 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,8 +103,7 @@ 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,
...awsOidc,
...devEnv,
},
}
Expand Down
4 changes: 0 additions & 4 deletions apps/api/src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,4 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains
planetScaleOAuthEnv,
supabaseOAuthEnv,
optionalPlain("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"),
// 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"),
)
82 changes: 82 additions & 0 deletions apps/api/src/routes/aws-oidc.http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { afterAll, describe, expect, it } from "@effect/vitest"
import { ConfigProvider, Layer, Schema } from "effect"
import { HttpRouter } from "effect/http"
import { Env } from "@maple/backend/platform/Env"
import { MAPLE_TEST_OIDC_KEY } from "@maple/backend/services/integrations/aws/fake-aws"
import { AwsOidcRouter } from "./aws-oidc.http"

const baseConfig = {
TINYBIRD_HOST: "https://api.tinybird.co",
TINYBIRD_TOKEN: "test-token",
MAPLE_AUTH_MODE: "self_hosted",
MAPLE_ROOT_PASSWORD: "test-root-password",
MAPLE_DEFAULT_ORG_ID: "default",
MAPLE_API_BASE_URL: "https://api.maple.test/",
MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 4).toString("base64"),
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret",
}

const handlerFor = (config: Record<string, string>) =>
HttpRouter.toWebHandler(
AwsOidcRouter.pipe(
Layer.provide(
Env.layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(config)))),
),
),
{ disableLogger: true },
)

const Discovery = Schema.Struct({
issuer: Schema.String,
jwks_uri: Schema.String,
id_token_signing_alg_values_supported: Schema.Array(Schema.String),
})
const Jwks = Schema.Struct({ keys: Schema.Array(Schema.Record(Schema.String, Schema.String)) })

const withKey = handlerFor({ ...baseConfig, MAPLE_AWS_OIDC_SIGNING_KEY: MAPLE_TEST_OIDC_KEY.privateKey })
const withoutKey = handlerFor(baseConfig)
afterAll(() => Promise.all([withKey.dispose(), withoutKey.dispose()]))

// Forged forwarding headers: the issuer must come from configuration, never from the request.
const get = (handler: typeof withKey.handler, path: string) =>
handler(
new Request(`https://evil.example${path}`, {
headers: { host: "evil.example", "x-forwarded-host": "evil.example" },
}),
)

describe("AwsOidcRouter", () => {
it("publishes the issuer AWS discovers, under the configured origin", async () => {
const response = await get(withKey.handler, "/oidc/aws/.well-known/openid-configuration")
expect(response.status).toBe(200)
const doc = Schema.decodeUnknownSync(Discovery)(await response.json())
expect(doc.issuer).toBe("https://api.maple.test/oidc/aws")
expect(doc.jwks_uri).toBe("https://api.maple.test/oidc/aws/jwks.json")
expect(doc.id_token_signing_alg_values_supported).toEqual(["RS256"])
})

it("serves the public key the tokens verify with, and nothing private", async () => {
const response = await get(withKey.handler, "/oidc/aws/jwks.json")
expect(response.status).toBe(200)
const { keys } = Schema.decodeUnknownSync(Jwks)(await response.json())
expect(keys).toHaveLength(1)
expect(keys[0]).toMatchObject({ kty: "RSA", alg: "RS256", use: "sig" })
const [jwk] = keys
expect(jwk?.d).toBeUndefined()
// It is the key the test signs with: the same modulus as the PEM's public half.
const published = await crypto.subtle.importKey(
"jwk",
{ ...jwk },
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
)
const spki = Buffer.from(await crypto.subtle.exportKey("spki", published)).toString("base64")
expect(MAPLE_TEST_OIDC_KEY.publicKey.replace(/-----[A-Z ]+-----|\s/gu, "")).toBe(spki)
})

it("is absent without a signing key", async () => {
expect((await get(withoutKey.handler, "/oidc/aws/jwks.json")).status).toBe(404)
expect((await get(withoutKey.handler, "/oidc/aws/.well-known/openid-configuration")).status).toBe(404)
})
})
55 changes: 55 additions & 0 deletions apps/api/src/routes/aws-oidc.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { awsOidcIssuer } from "@maple/domain/aws"
import { Effect, Option } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/http"
import { Env } from "@maple/backend/platform/Env"
import {
type AwsOidcSigner,
awsOidcDiscovery,
makeAwsOidcSigner,
} from "@maple/backend/services/integrations/aws/oidc"

/**
* Maple's OIDC issuer for AWS, `{MAPLE_API_BASE_URL}/oidc/aws`: AWS reads the discovery document
* and the JWKS to verify the tokens Maple assumes customer roles with. Public and cacheable. The
* issuer comes from configuration, never from request headers: customer accounts trust it as is.
*/
const PUBLIC_CACHE = { "cache-control": "public, max-age=300" }

export const AwsOidcRouter = HttpRouter.use((router) =>
Effect.gen(function* () {
const env = yield* Env
const issuer = awsOidcIssuer(env.MAPLE_API_BASE_URL)
// No key, or one that does not load: the issuer is absent, as AWS sees it.
const signer = yield* Option.match(env.MAPLE_AWS_OIDC_SIGNING_KEY, {
onNone: () => Effect.succeed(Option.none()),
onSome: (key) =>
makeAwsOidcSigner(issuer, key).pipe(
Effect.tapError((error) =>
Effect.logWarning(`AWS OIDC issuer disabled: ${error.message}`),
),
Effect.option,
),
})
const notFound = HttpServerResponse.jsonUnsafe(
{ error: "This Maple deployment has no AWS issuer." },
{ status: 404 },
)
const serve = <A extends object>(body: (signer: AwsOidcSigner) => A) =>
Effect.succeed(
Option.match(signer, {
onNone: () => notFound,
onSome: (found) => HttpServerResponse.jsonUnsafe(body(found), { headers: PUBLIC_CACHE }),
}),
)
yield* router.add(
"GET",
"/oidc/aws/.well-known/openid-configuration",
serve(() => awsOidcDiscovery(issuer)),
)
yield* router.add(
"GET",
"/oidc/aws/jwks.json",
serve((found) => ({ keys: [found.jwk] })),
)
}),
)
22 changes: 10 additions & 12 deletions apps/api/src/routes/v2/integrations-aws.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AuthService } from "@maple/backend/services/auth/AuthService"
import { AuditLogService } from "@maple/backend/services/audit/AuditLogService"
import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService"
import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService"
import { MAPLE_TEST_OIDC_KEY } from "@maple/backend/services/integrations/aws/fake-aws"
import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService"
import { V2TransportErrorBoundaryLive } from "./error-envelope"
import {
Expand Down Expand Up @@ -49,14 +50,9 @@ const testConfig = (awsAvailable: boolean) =>
MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"),
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret",
MAPLE_INGEST_PUBLIC_URL: "https://ingest.test",
MAPLE_API_BASE_URL: "https://api.maple.test",
INTERNAL_SERVICE_TOKEN: "test-internal-token",
...(awsAvailable
? {
MAPLE_AWS_PRINCIPAL_ARN: "arn:aws:iam::999999999999:user/maple-integrations",
MAPLE_AWS_ACCESS_KEY_ID: "AKIAMAPLETEST0000001",
MAPLE_AWS_SECRET_ACCESS_KEY: "maple-test-secret",
}
: undefined),
...(awsAvailable ? { MAPLE_AWS_OIDC_SIGNING_KEY: MAPLE_TEST_OIDC_KEY.privateKey } : undefined),
}),
)

Expand Down Expand Up @@ -152,7 +148,8 @@ describe("v2 aws integration over HTTP", () => {
role_arn: expect.stringMatching(
/^arn:aws:iam::123456789012:role\/MapleIntegration-[0-9a-f]{24}$/,
),
external_id: expect.stringMatching(/^[0-9a-f]{32}$/),
oidc_issuer: "https://api.maple.test/oidc/aws",
oidc_subject: expect.stringMatching(/^aws-connection:[0-9a-f-]{36}$/),
modules: [],
granted_modules: null,
template_version: null,
Expand All @@ -177,13 +174,14 @@ describe("v2 aws integration over HTTP", () => {
expect(setup.body.object).toBe("aws_connection.setup")
expect(setup.body.setup_script).toContain(`--stack-name ${setup.body.stack_name}`)
expect(setup.body.setup_script).toContain('if [ "$account" != "123456789012" ]')
// The template names the role the connection reports and requires its external id.
// The template names the role the connection reports, and trusts only its subject's tokens.
const template = JSON.parse(setup.body.cloudformation_template)
const role = template.Resources.MapleRole.Properties
expect(created.body.role_arn.endsWith(`/${role.RoleName}`)).toBe(true)
expect(role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals["sts:ExternalId"]).toBe(
created.body.external_id,
)
expect(role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals).toEqual({
"api.maple.test/oidc/aws:aud": "sts.amazonaws.com",
"api.maple.test/oidc/aws:sub": created.body.oidc_subject,
})
expect(setup.body.terraform).toContain(`name = "${role.RoleName}"`)

// Members read the status; only admins render setups or change anything.
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/v2/integrations-aws.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const toV2Connection = (connection: AwsConnection): V2AwsConnection => ({
object: "aws_connection",
account_id: connection.accountId,
role_arn: connection.roleArn,
external_id: connection.externalId,
oidc_issuer: connection.oidcIssuer,
oidc_subject: connection.oidcSubject,
modules: connection.modules,
granted_modules: connection.grantedModules,
template_version: connection.templateVersion,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { HttpEmailPublicLive } from "@/routes/email-public.http"
import { HttpV2SharePublicLive } from "@/routes/v2/share.http"
import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary"
import { HttpDemoLive } from "@/routes/internal/demo.http"
import { AwsOidcRouter } from "@/routes/aws-oidc.http"
import { DiscoveryRouter, instanceApiV2, NotFoundRouter } from "@/routes/discovery.http"
import { HttpDigestLive } from "@/routes/internal/digest.http"
import { HttpCodeReviewLive } from "@/routes/internal/code-review.http"
Expand Down Expand Up @@ -241,6 +242,7 @@ const RawRoutes = rawRoutes(
DocsRoute,
DocsV2Route,
DiscoveryRouter,
AwsOidcRouter,
// Last by convention only — find-my-way ranks the wildcard below every other
// route regardless of registration order.
NotFoundRouter,
Expand Down
5 changes: 2 additions & 3 deletions 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, awsPrincipal } = stack
const { stage, region, domains, devEnv, db, awsOidc } = 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,8 +50,7 @@ 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,
...awsOidc,
...devEnv,
},
}
Expand Down
Loading
Loading