Skip to content

feat(apigateway): AWS-service integration builder (#270)#276

Closed
laazyj wants to merge 1 commit into
mainfrom
feat/apigateway-aws-service-integration
Closed

feat(apigateway): AWS-service integration builder (#270)#276
laazyj wants to merge 1 commit into
mainfrom
feat/apigateway-aws-service-integration

Conversation

@laazyj

@laazyj laazyj commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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.

const scan = awsServiceIntegration("dynamodb", "Scan")
  .requestTemplates(ref("table", (r) => ({ "application/json": scanTemplate(r.table.tableName) })))
  .grant(tableGrants.read(ref("table", (r) => r.table))); // owns role; grantee

compose(
  { table: createTableBuilder(), api: createRestApiBuilder().addResource("gadgets", (g) => g.addMethod("GET", scan)) },
  { table: [], api: ["table"] }, // api → table; no reverse edge, no cycle
);

Why

addMethod previously accepted only a fully user-constructed Integration, so an AwsIntegration's credentialsRole — the identity API Gateway assumes to call the service — had no owner in the builder surface and could not be a grantee. This mirrors FunctionBuilder owning its execution role: you .grant(...) and it routes onto the owned role.

How

  • New branded builder (aws-service-integration.ts): 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<IGrantable> applied to that role. The grant is declared on the consumer, so the compose edge stays consumer → resource.
  • Plug-in seam: RestApiBuilder.addMethod accepts the branded integration; ResourceBuilder.applyTo detects the Symbol.for brand and builds it under the method's resource scope (rather than the plain resolve path, since it needs a build-time scope for its role).
  • Result completeness: the owned role is surfaced on RestApiBuilderResult.integrationRoles, keyed by "{path} {method}" (e.g. "/gadgets GET").

AWS Well-Architected

  • Least privilege by construction — the role starts empty; every permission comes from an explicit .grant(...).
  • Confused-deputy mitigation, default on — an aws:SourceArn trust 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

  • Scoped to RestApiBuilder (resource-tree). SpecRestApiBuilder (OpenAPI-driven) has no addMethod/ref path and is unchanged.
  • No ADR or combine() core utility in this PR — kept as a focused, internal apigateway change.
  • @composurecdk/iam added 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, integrationRoles surface, compose + grant + ref resolution, external-role override, mutual-exclusivity, missing-ref failure, .copy() sharing, all fluent options, and the IntegrationLatency alarm. npm run verify green across all 21 projects.

🤖 Generated with Claude Code

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 laazyj left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`. */

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no action prop - what is this mutually exclusive with?

@laazyj laazyj self-assigned this Jul 4, 2026
@laazyj laazyj added the blocked Blocked on another issue/PR before it can proceed label Jul 4, 2026
laazyj added a commit that referenced this pull request Jul 8, 2026
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
laazyj added a commit that referenced this pull request Jul 8, 2026
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
laazyj added a commit that referenced this pull request Jul 8, 2026
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
@laazyj

laazyj commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

#302 demonstrates the accepted, alternate, approach

@laazyj laazyj closed this Jul 15, 2026
@laazyj
laazyj deleted the feat/apigateway-aws-service-integration branch July 15, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Blocked on another issue/PR before it can proceed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(apigateway): AWS-service integration should own its credentials role (enables consumer-side grants)

1 participant