Skip to content

refactor(infra): migrate cross-stack auth references to SSM Parameter Store#117

Open
okamoto-aws wants to merge 2 commits intomainfrom
feat/ssm-cross-stack-refs
Open

refactor(infra): migrate cross-stack auth references to SSM Parameter Store#117
okamoto-aws wants to merge 2 commits intomainfrom
feat/ssm-cross-stack-refs

Conversation

@okamoto-aws
Copy link
Copy Markdown
Contributor

Motivation

The problem

CDK auto-generates CloudFormation Exports / Fn::ImportValue pairs whenever one stack passes a construct reference to another stack. This creates a hidden rigidity:

  • Once an export is referenced by another stack, the exporting stack can no longer modify or delete that output until every importer drops its reference.
  • Any change that triggers resource replacement on an exported resource (e.g. a Cognito UserPoolClient scope change, which requires replacement) fails with Cannot delete export <name> as it is in use by <stack>.
  • Workarounds push configuration drift into downstream stacks via custom resources. PR fix: add sdpm-mcp/invoke scope to WebClient OAuth config #112 was a recent example: adding an OAuth scope to the WebClient would have required replacing the client, which downstream stacks were importing; the fix had to sidestep CloudFormation entirely via a custom-resource Cognito API call.

As the project grows, each new Auth change risks another round of custom resource workarounds.

The goal

Replace CDK's automatic cross-stack Export/Import with SSM Parameter Store for values passed from AuthStack to downstream stacks. This decouples AuthStack from RuntimeStack, AgentStack, and WebUiStack, so that future Auth changes can be implemented declaratively in AuthStack without fear of breaking downstream deployments.

Why SSM Parameter Store

AWS re:Post explicitly recommends SSM Parameter Store for cross-stack value sharing in production workloads: 'Using SSM parameters unties the relationship between exporting stack and importing stack.'. Industry guidance (Chariot Solutions, Darren Holland) converges on the same advice: 'use outputs lavishly, exports sparingly'.

Constraints

This repo is deployed across many independent environments on arbitrary prior commits. There is no central coordination or staged rollout.

Invariant: For any deployed state X and any target commit Y on main, running deploy.sh against commit Y must succeed on an environment at state X, in one invocation, without manual intervention.

This rules out phased migration PR series because we cannot require users to deploy intermediate versions.

Design

Values migrated

Value Was Now
UserPool ARN authStack.userPool.userPoolArn /sdpm/auth/user-pool-arn
UserPool ID authStack.userPool.userPoolId /sdpm/auth/user-pool-id
WebClient ID authStack.userPoolClient.userPoolClientId /sdpm/auth/web-client-id
MCP Client ID authStack.mcpClientId /sdpm/auth/mcp-client-id (sentinel - when unset)
MCP Custom Scope authStack.mcpCustomScope /sdpm/auth/mcp-custom-scope
Cognito Domain Prefix authStack.cognitoDomainPrefix /sdpm/auth/cognito-domain-prefix
OIDC Discovery URL authStack.oidcDiscoveryUrl /sdpm/auth/oidc-discovery-url

Downstream stacks receive a useAuthStack: boolean prop; when true they read values from SSM, when false (external IdP mode) they fall back to existing string props (oidcDiscoveryUrl, allowedClients).

Backward compatibility

Existing deployed environments have these auto-generated exports registered and imported by downstream stacks:

  • SdpmAuth:ExportsOutputRefUserPool6BA7E5F296FD7236
  • SdpmAuth:ExportsOutputRefUserPoolWebClient4C9370B02E2C9FF9
  • SdpmAuth:ExportsOutputFnGetAttUserPool6BA7E5F2Arn686ACC00

If we simply remove the construct references (which this PR does), CDK stops emitting those exports, and CloudFormation refuses to delete them while any deployed downstream template still imports them ('Cannot delete export').

The official remediation is to manually retain the legacy exports using overrideLogicalId and a matching exportName. This PR does exactly that in auth-stack.ts:

const userPoolArnExport = new cdk.CfnOutput(this, 'LegacyUserPoolArnExport', {
  value: this.userPool.userPoolArn,
  exportName: `${this.stackName}:ExportsOutputFnGetAttUserPool6BA7E5F2Arn686ACC00`,
});
userPoolArnExport.overrideLogicalId('ExportsOutputFnGetAttUserPool6BA7E5F2Arn686ACC00');
// ... same pattern for UserPool ID and WebClient ID exports

The legacy exports are retained indefinitely to satisfy the invariant above. Comments in auth-stack.ts warn future maintainers not to remove them.

Deployment order

bin/infra.ts adds explicit stack.addDependency(authStack) calls so that SdpmAuth deploys first and its SSM parameters exist before downstream stacks try to read them.

Compatibility matrix

Previously deployed on Action on upgrade Result
Pre–PR #83 deploy.sh Legacy exports retained with correct names → no deletion. New SSM params added. Downstream switches to SSM read. ✅
PR #83 (with mcpCustomScope) deploy.sh Same, plus mcpCustomScope SSM param populated. ✅
PR #112 (401 fix) deploy.sh Same. UpdateCognitoCallbackUrls custom resource retained for now (out of scope for this PR). ✅
Fresh deploy deploy.sh SSM params created, legacy exports created, downstream uses SSM. ✅

Changes

File Change
infra/lib/auth-stack.ts Publish 7 SSM parameters, retain 3 legacy exports via overrideLogicalId, export AUTH_SSM_PARAMS constant
infra/lib/web-ui-stack.ts Read UserPool ID / ARN / WebClient ID / MCP scope from SSM; reconstruct UserPool via fromUserPoolArn; always include MCP scope in UpdateCognitoCallbackUrls and aws-exports.json
infra/lib/runtime-stack.ts Add useAuthStack: boolean prop; in AuthStack mode, read OIDC URL / UserPool ID / Domain Prefix / MCP Client ID / MCP Scope from SSM; fall back to props for external IdP
infra/lib/agent-stack.ts Add useAuthStack: boolean prop; in AuthStack mode, read OIDC URL and WebClient ID from SSM; fall back to props.allowedClients for external IdP
infra/bin/infra.ts Stop passing authStack.userPool / authStack.userPoolClient / authStack.clientId / authStack.oidcDiscoveryUrl etc. to downstream stacks (that triggered CDK auto-generated exports). Pass useAuthStack: !!authStack instead. Add explicit stack.addDependency(authStack)

Risks and mitigations

  • section 'Outputs' already contains <id> at synth time: CDK emits an auto-export whenever a downstream stack holds a construct reference to an AuthStack resource. If that auto-export shares the logical ID of our manually retained legacy export, synth fails. Mitigated by removing every authStack.userPool, authStack.userPoolClient, authStack.clientId, and authStack.oidcDiscoveryUrl reference in bin/infra.ts. Verified via cdk synth SdpmAuth.
  • Deploy-time SSM resolution: ssm.StringParameter.valueForStringParameter emits a CFN template parameter of type AWS::SSM::Parameter::Value<String>; CloudFormation resolves the real value at deploy time. Values are always fresh.
  • Legacy exports retained forever: If a future refactor renames the underlying resources (e.g. replaces the UserPool), these exports would need careful handling. Comments warn future maintainers.
  • UpdateCognitoCallbackUrls custom resource (from PR fix: add sdpm-mcp/invoke scope to WebClient OAuth config #112) remains: Out of scope for this PR. The custom resource continues to be the single source of truth for WebClient OAuth scopes, now always including sdpm-mcp/invoke.

Verification

  • npx tsc --noEmit — clean, 0 errors
  • npx cdk synth SdpmAuth — success; template contains:
    • 7 AWS::SSM::Parameter resources
    • 3 Outputs with the exact legacy export names above
    • 3 informational Outputs (UserPoolId, UserPoolClientId, OidcDiscoveryUrl)

Deployment verification is out of scope for this PR; the author will test after merge.

References

… Store

Replaces CDK-auto-generated CloudFormation Export/Import between
AuthStack and downstream stacks (RuntimeStack, AgentStack, WebUiStack)
with SSM Parameter Store. Decouples AuthStack from downstream so that
future Auth changes can be implemented declaratively without triggering
cross-stack export deletion errors (of the kind PR #112 had to work
around with a custom resource).

- AuthStack publishes 7 SSM parameters under /sdpm/auth/*
  (user-pool-id, user-pool-arn, web-client-id, mcp-client-id,
   mcp-custom-scope, cognito-domain-prefix, oidc-discovery-url)
- RuntimeStack, AgentStack, and WebUiStack read values via SSM instead
  of receiving construct references through props
- AuthStack retains 3 legacy auto-generated exports explicitly via
  overrideLogicalId to keep existing deployments upgradeable in-place
  without "Cannot delete export" errors
- Downstream stacks receive a useAuthStack: boolean prop; when true,
  they read from SSM and enable Cognito-specific features, when false
  (external IdP) they fall back to the existing string props
- Explicit stack.addDependency(authStack) ensures SSM params exist
  before downstream reads them

Design: see docs/internal/ssm-cross-stack-refs.md
@okamoto-aws okamoto-aws added the blog:skip ブログ対象外 label May 3, 2026
detect-secrets flags the CDK-generated hex hashes in the legacy export
names (e.g. 6BA7E5F2Arn686ACC00) as Base64 high-entropy strings.
These are stable construct-path hashes, not credentials — retained for
backward compatibility of cross-stack imports. Annotate each line with
'// pragma: allowlist secret' to silence the scanner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blog:skip ブログ対象外

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant