feat(apigateway): AWS-service integration builder (#270)#276
Conversation
Add `awsServiceIntegration(service, action)`, a first-class integration for direct API Gateway -> AWS service calls (e.g. DynamoDB, SQS, S3) that owns its credentials role and is a consumer-side grantee. Previously `addMethod` accepted only a fully user-constructed `Integration`, so an `AwsIntegration`'s `credentialsRole` had no owner in the builder surface and could not be a grantee (deferred from ADR-0013, issue #270). The new builder creates a least-privilege role assumed by `apigateway.amazonaws.com`, sets it as the integration's credentialsRole, resolves its target via a single `ref`, and exposes `.grant(...)` backed by a GrantQueue applied to that role -- so permissions are declared on the consumer and the compose edge stays consumer -> resource. RestApiBuilder detects the branded integration in `addMethod`, builds it under the method's resource scope, and surfaces the owned role on `RestApiBuilderResult.integrationRoles`, keyed by "{path} {method}". Well-Architected defaults: least privilege by construction (the role starts empty; permissions come only from explicit grants) and a confused-deputy `aws:SourceArn` trust condition scoping the role to the owning API (default on, overridable via `.restrictToApi(false)`, `.configureRole`, or an external `.role`). Also add the AWS-recommended IntegrationLatency alarm (p90, 2000ms) to the shared REST API alarm set, on by default -- the meaningful backend latency signal for a direct AWS-service integration.
laazyj
left a comment
There was a problem hiding this comment.
This becomes a lot. Although I can imagine a situation where each method in a RestApi might use a distinct role - it seems like an extreme security case, in which I doubt that auto-created roles would be preferable over explicitly defined roles.
It looks like the main functionality provided by was-service-integration.ts is to auto-create associated IAM roles and plumb them in. This is not a big value-add and arguably obscures important parts of the system design. In contrast with something like FunctionBuilder where the composureCDK builder is emulating aws-cdk-lib functionality by auto-creating a role, this is adding obfuscation where aws-cdk-lib doesn't already have it.
I think this demonstration illustrates that the option B, as imagined in #270, is misplaced making the option C (combine()) approach look much better.
| export { | ||
| awsServiceIntegration, | ||
| isAwsServiceIntegration, | ||
| AWS_SERVICE_INTEGRATION, |
There was a problem hiding this comment.
Do the brand and the type checker function need to be exported from the module? They feel like internal implementation details that consumers should not depend on.
| region?: string; | ||
| /** A service subdomain (e.g. a bucket name for S3 path-style access). */ | ||
| subdomain?: string; | ||
| /** The resource path used in the integration URI. Mutually exclusive with `action`. */ |
There was a problem hiding this comment.
there is no action prop - what is this mutually exclusive with?
Add `combine(refs, transform?)` to `@composurecdk/core`: a Ref combinator that resolves a record of Resolvable values against the build context and returns a single Ref to the merged record, optionally transformed. `ref()` reaches exactly one component, so a consumer that assembles a construct from more than one sibling (e.g. a direct API Gateway -> AWS-service AwsIntegration needing both a target's identifier for its VTL template and a credentials role) had no single reference to hand to a Resolvable<T> seam. The workaround was a fake Lifecycle whose only job was to merge two sibling refs -- a graph node modelling no resource. combine closes that gap without new machinery: it returns an ordinary Ref, so it drops into every existing Resolvable seam with no builder change, owns nothing (every sibling stays a first-class compose node), and infers the merged record's types from the input. This is the resolution to #270 chosen over the option B builder in #276, which would have auto-created an IAM role per integration -- net-new behaviour aws-cdk-lib does not provide, obscuring design CDK keeps explicit. The credentials role stays an explicit sibling granted against with consumer-side grants (ADR-0013). Documents the concept in architecture.md, adds ADR-0015 describing the intent and when (not) to use it, and cross-references it from ADR-0013's out-of-scope note. Closes #270
Add `combine(refs, transform?)` to `@composurecdk/core`: a Ref combinator that resolves a record of Resolvable values against the build context and returns a single Ref to the merged record, optionally transformed. `ref()` reaches exactly one component, so a consumer that assembles a construct from more than one sibling (e.g. a direct API Gateway -> AWS-service AwsIntegration needing both a target's identifier for its VTL template and a credentials role) had no single reference to hand to a Resolvable<T> seam. The workaround was a fake Lifecycle whose only job was to merge two sibling refs -- a graph node modelling no resource. combine closes that gap without new machinery: it returns an ordinary Ref, so it drops into every existing Resolvable seam with no builder change, owns nothing (every sibling stays a first-class compose node), and infers the merged record's types from the input. This is the resolution to #270 chosen over the option B builder in #276, which would have auto-created an IAM role per integration -- net-new behaviour aws-cdk-lib does not provide, obscuring design CDK keeps explicit. The credentials role stays an explicit sibling granted against with consumer-side grants (ADR-0013). Documents the concept in architecture.md, adds ADR-0015 describing the intent and when (not) to use it, and cross-references it from ADR-0013's out-of-scope note. Closes #270
Add `combine(refs, transform?)` to `@composurecdk/core`: a Ref combinator that resolves a record of Resolvable values against the build context and returns a single Ref to the merged record, optionally transformed. `ref()` reaches exactly one component, so a consumer that assembles a construct from more than one sibling (e.g. a direct API Gateway -> AWS-service AwsIntegration needing both a target's identifier for its VTL template and a credentials role) had no single reference to hand to a Resolvable<T> seam. The workaround was a fake Lifecycle whose only job was to merge two sibling refs -- a graph node modelling no resource. combine closes that gap without new machinery: it returns an ordinary Ref, so it drops into every existing Resolvable seam with no builder change, owns nothing (every sibling stays a first-class compose node), and infers the merged record's types from the input. This is the resolution to #270 chosen over the option B builder in #276, which would have auto-created an IAM role per integration -- net-new behaviour aws-cdk-lib does not provide, obscuring design CDK keeps explicit. The credentials role stays an explicit sibling granted against with consumer-side grants (ADR-0013). Documents the concept in architecture.md, adds ADR-0015 describing the intent and when (not) to use it, and cross-references it from ADR-0013's out-of-scope note. Closes #270
|
#302 demonstrates the accepted, alternate, approach |
Closes #270.
What
Adds
awsServiceIntegration(service, action)— a first-class integration for direct API Gateway → AWS service calls (DynamoDB, SQS, S3, …) that owns its credentials role and is a consumer-side grantee, implementing the pattern ADR-0013 deferred to #270.Why
addMethodpreviously accepted only a fully user-constructedIntegration, so anAwsIntegration'scredentialsRole— the identity API Gateway assumes to call the service — had no owner in the builder surface and could not be a grantee. This mirrorsFunctionBuilderowning its execution role: you.grant(...)and it routes onto the owned role.How
aws-service-integration.ts): creates a least-privilege role assumed byapigateway.amazonaws.com, sets it as the integration'scredentialsRole, resolves its target via a singleref, and exposes.grant(...)backed by aGrantQueue<IGrantable>applied to that role. The grant is declared on the consumer, so the compose edge stays consumer → resource.RestApiBuilder.addMethodaccepts the branded integration;ResourceBuilder.applyTodetects theSymbol.forbrand and builds it under the method's resource scope (rather than the plainresolvepath, since it needs a build-time scope for its role).RestApiBuilderResult.integrationRoles, keyed by"{path} {method}"(e.g."/gadgets GET").AWS Well-Architected
.grant(...).aws:SourceArntrust condition scopes the role to the owning API. Overridable via.restrictToApi(false),.configureRole, or an external.role(ref).AWS recommended alarms
Adds the AWS-recommended IntegrationLatency alarm (p90, 2000 ms) to the shared REST API alarm set, on by default — the meaningful backend-latency signal for a direct AWS-service integration (no Lambda hop). Overridable/disablable like the other recommended alarms. Example-app snapshots updated accordingly.
Scope / notes
RestApiBuilder(resource-tree).SpecRestApiBuilder(OpenAPI-driven) has noaddMethod/ref path and is unchanged.combine()core utility in this PR — kept as a focused, internal apigateway change.@composurecdk/iamadded as a peer/dev dependency (mirrors@composurecdk/lambda).Testing
15 new unit/synth tests (100% function / line coverage on the new file); covers the integration/role shape, confused-deputy default + opt-out,
integrationRolessurface, compose + grant + ref resolution, external-role override, mutual-exclusivity, missing-ref failure,.copy()sharing, all fluent options, and the IntegrationLatency alarm.npm run verifygreen across all 21 projects.🤖 Generated with Claude Code