This comprehensive guide provides detailed instructions for implementing secure, scalable AWS Amplify applications with integrated machine learning services including SageMaker and Bedrock, featuring enterprise authentication through Azure Entra ID and Lambda processors through Regional API Gateway integration with private backend connectivity options.
Multi-layer security with JWT validation, WAF protection, and compliance frameworks
Complete implementation with CI/CD pipelines and Infrastructure as Code
Seamless integration with SageMaker and Bedrock for AI-powered applications
Version 2.4.1 | Remediated: July 2026
Target Audience: Enterprise Developers, DevOps Engineers, Solution Architects
This guide provides comprehensive instructions for designing, developing, integrating, and deploying AWS Amplify hosting solutions for Single Page Applications (SPAs), specifically Angular applications, with secure access to corporate machine learning services including Amazon SageMaker and Amazon Bedrock.
The integration architecture emphasizes enterprise-grade security through Azure Entra ID authentication via Amazon Cognito, Regional API Gateway configurations with AWS WAF protection, and Lambda-based processors for ML service orchestration.
Authorization: Bearer header to the public Regional API path.Figure 1: Remediated overall system architecture. AWS-managed ML services remain outside any customer-VPC boundary; interface endpoints may be added when private service access is required.
Boundary note: Private APIs require private client connectivity and an execute-api VPC endpoint; they are not directly callable by a public browser.
| Component | Purpose | Key Features | Integration Points |
|---|---|---|---|
| Angular SPA | Frontend application | Responsive UI, Authentication, ML Service Calls | Cognito, API Gateway |
| AWS Amplify | Hosting & CI/CD | Auto-deployment, SSL, Custom Domains | Managed CDN, Git repository, custom domain |
| CloudFront | Content Delivery | Global CDN, Edge Caching, SSL Termination | Amplify, Lambda@Edge |
| Cognito | Authentication | User Pools, Identity Pools, SAML/OIDC | Azure Entra ID, API Gateway |
| API Gateway | API Management | Regional endpoint, throttling, monitoring | Lambda, WAF, VPC |
| Cognito Authorizer | Authorization | JWT Validation, Policy Generation, Caching | Cognito, API Gateway |
| Lambda Processors | Business Logic | ML Orchestration, Data Processing, Response Formatting | SageMaker, Bedrock, DynamoDB |
| SageMaker | ML Inference | Custom Models, Real-time Endpoints, Batch Processing | Lambda, S3, ECR |
| Bedrock | Foundation Models | LLMs, Text Generation, Embeddings | Lambda, S3, CloudWatch |
Angular SPA hosted on AWS Amplify with CloudFront distribution
Cognito User Pools with Azure Entra ID federation
Regional API Gateway with WAF protection and native Cognito authorization
SageMaker and Bedrock integration through Lambda processors
Authorization: Bearer header to the public Regional API path.Use the native Cognito authorizer for standard access-token validation. If a custom authorizer is introduced for tenant, resource, or risk decisions, design its cache key, policy scope, token-expiry handling, and revocation latency together; disable caching when a safe reusable policy cannot be produced.
| Authorization Pattern | Use Case | Implementation | Security Level |
|---|---|---|---|
| Role-Based Access Control (RBAC) | Standard user permissions | Mapped Entra app role + application policy enforcement | Medium |
| Attribute-Based Access Control (ABAC) | Dynamic permissions | Mapped claims + policy engine/context evaluation | High |
| Resource-Based Authorization | Data-specific access | Resource policy or policy engine with authoritative ownership data | High |
| Time-Based Access | Scheduled operations | Policy engine evaluates current time, task state, and credential/session expiry | Medium |
Configure your Lambda function with appropriate timeout and memory allocation based on your model's requirements. Critical: For synchronous API Gateway integrations, API Gateway enforces a hard 29-second integration timeout, regardless of the Lambda timeout setting. Long-running inference jobs must use an asynchronous pattern (SQS + polling, WebSocket, or Step Functions).
Before writing any application code, set up the full toolchain. Complete all four steps below in order.
$PATH.aws configure with an IAM user or role that has permissions for Cognito, API Gateway, Lambda, Amplify, and Bedrock.npm ci inside both frontend/ and infrastructure/.# 1 — Node.js 22 LTS (Debian/Ubuntu) curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get update sudo apt-get install -y nodejs unzip # 2 — AWS CLI v2 curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip unzip -q awscliv2.zip sudo ./aws/install --update rm -rf aws awscliv2.zip # 3 — Authenticate for local development with short-lived SSO credentials aws configure sso aws sso login # 4 — Install project dependencies from lock files (cd frontend && npm ci) (cd infrastructure && npm ci) # 5 — Run CLIs from the project dependencies; avoid unpinned global versions (cd frontend && npx ng version) (cd infrastructure && npx cdk --version)
Each environment must receive the complete Cognito managed-login domain emitted by infrastructure. Do not derive it from the user-pool ID, and do not configure an identity pool unless the SPA actually requires AWS credentials.
// src/environments/environment.ts
export const environment = {
production: false,
cognito: {
userPoolId: 'us-east-1_XXXXXXXXX',
userPoolWebClientId: 'XXXXXXXXXXXXXXXXXX',
domain: 'my-company-ml-app-dev.auth.us-east-1.amazoncognito.com',
region: 'us-east-1',
apiScope: 'ml-api/invoke',
},
api: {
baseUrl: 'https://api.example.com/dev',
region: 'us-east-1',
},
} as const;
// src/environments/environment.prod.ts
export const environment = {
production: true,
cognito: {
userPoolId: 'us-east-1_YYYYYYYYY',
userPoolWebClientId: 'YYYYYYYYYYYYYYYYYY',
domain: 'my-company-ml-app.auth.us-east-1.amazoncognito.com',
region: 'us-east-1',
apiScope: 'ml-api/invoke',
},
api: {
baseUrl: 'https://api.example.com/prod',
region: 'us-east-1',
},
} as const;
This remediated construct creates the user pool, Entra SAML provider, managed-login domain, and an app client that explicitly supports the Entra provider. Production uses retention and deletion protection. Authorization entitlements must be mapped explicitly from Entra application roles or another governed source; creating Cognito groups alone does not populate federated users.
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import { Construct } from 'constructs';
export interface CognitoConstructProps {
readonly domainPrefix: string;
readonly callbackUrls: string[];
readonly logoutUrls: string[];
}
export class CognitoConstruct extends Construct {
readonly userPool: cognito.UserPool;
readonly userPoolClient: cognito.UserPoolClient;
readonly domainPrefix: string;
readonly apiScope = 'ml-api/invoke';
constructor(scope: Construct, id: string, props: CognitoConstructProps) {
super(scope, id);
this.userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: false,
signInAliases: { email: true },
removalPolicy: cdk.RemovalPolicy.RETAIN,
deletionProtection: true,
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
mfa: cognito.Mfa.OFF, // Enforce corporate MFA in Entra for federated-only users.
customAttributes: {
enterpriseRole: new cognito.StringAttribute({ mutable: true, maxLen: 256 }),
tenantId: new cognito.StringAttribute({ mutable: true, maxLen: 64 }),
},
});
const entra = new cognito.UserPoolIdentityProviderSaml(this, 'EntraID', {
userPool: this.userPool,
name: 'EntraID',
metadata: cognito.UserPoolIdentityProviderSamlMetadata.url(
'https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml'
),
attributeMapping: {
email: cognito.ProviderAttribute.other(
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
),
givenName: cognito.ProviderAttribute.other(
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'
),
familyName: cognito.ProviderAttribute.other(
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'
),
custom: {
enterpriseRole: cognito.ProviderAttribute.other(
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role'
),
tenantId: cognito.ProviderAttribute.other(
'http://schemas.microsoft.com/identity/claims/tenantid'
),
},
},
});
const invokeScope = new cognito.ResourceServerScope({
scopeName: 'invoke',
scopeDescription: 'Invoke approved ML operations',
});
const resourceServer = this.userPool.addResourceServer('MlApiResourceServer', {
identifier: 'ml-api',
scopes: [invokeScope],
});
this.domainPrefix = props.domainPrefix;
this.userPool.addDomain('ManagedLoginDomain', {
cognitoDomain: { domainPrefix: props.domainPrefix },
});
this.userPoolClient = this.userPool.addClient('SpaClient', {
generateSecret: false,
supportedIdentityProviders: [
cognito.UserPoolClientIdentityProvider.custom('EntraID'),
],
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
cognito.OAuthScope.resourceServer(resourceServer, invokeScope),
],
callbackUrls: props.callbackUrls,
logoutUrls: props.logoutUrls,
},
readAttributes: new cognito.ClientAttributes()
.withStandardAttributes({ email: true, givenName: true, familyName: true })
.withCustomAttributes('enterpriseRole', 'tenantId'),
writeAttributes: new cognito.ClientAttributes()
.withStandardAttributes({ email: true, givenName: true, familyName: true })
.withCustomAttributes('enterpriseRole', 'tenantId'),
accessTokenValidity: cdk.Duration.minutes(60),
idTokenValidity: cdk.Duration.minutes(60),
refreshTokenValidity: cdk.Duration.days(1),
enableTokenRevocation: true,
preventUserExistenceErrors: true,
});
this.userPoolClient.node.addDependency(entra);
}
}The public browser path uses a Regional REST API. AWS WAF protects the stage, and a native Cognito user-pool authorizer validates access tokens before Lambda invocation. A private REST API is a separate pattern that requires private client connectivity and an API Gateway interface VPC endpoint.
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import { Construct } from 'constructs';
export interface ApiConstructProps {
readonly userPool: cognito.UserPool;
readonly processor: lambda.Function;
readonly allowedOrigin: string;
readonly authorizationScope: string;
}
export class ApiConstruct extends Construct {
readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: ApiConstructProps) {
super(scope, id);
const accessLogs = new logs.LogGroup(this, 'AccessLogs', {
retention: logs.RetentionDays.THIRTY_DAYS,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
this.api = new apigateway.RestApi(this, 'Api', {
restApiName: 'ML App API',
endpointConfiguration: { types: [apigateway.EndpointType.REGIONAL] },
defaultCorsPreflightOptions: {
allowOrigins: [props.allowedOrigin],
allowMethods: ['POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
},
deployOptions: {
stageName: 'prod',
dataTraceEnabled: false,
metricsEnabled: true,
loggingLevel: apigateway.MethodLoggingLevel.ERROR,
accessLogDestination: new apigateway.LogGroupLogDestination(accessLogs),
accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(),
},
});
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
this,
'CognitoAuthorizer',
{ cognitoUserPools: [props.userPool] }
);
const integration = new apigateway.LambdaIntegration(props.processor, {
timeout: cdk.Duration.seconds(25),
});
const methodOptions: apigateway.MethodOptions = {
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
authorizationScopes: [props.authorizationScope],
};
const ml = this.api.root.addResource('ml');
ml.addResource('sagemaker').addMethod('POST', integration, methodOptions);
ml.addResource('bedrock').addMethod('POST', integration, methodOptions);
const webAcl = new wafv2.CfnWebACL(this, 'ApiWebAcl', {
defaultAction: { allow: {} },
scope: 'REGIONAL',
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MlApiWebAcl',
sampledRequestsEnabled: true,
},
rules: [
{
name: 'AWSManagedCommonRules',
priority: 0,
overrideAction: { none: {} },
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'CommonRules',
sampledRequestsEnabled: true,
},
},
{
name: 'RateLimit',
priority: 1,
action: { block: {} },
statement: { rateBasedStatement: { aggregateKeyType: 'IP', limit: 2000 } },
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'RateLimit',
sampledRequestsEnabled: true,
},
},
],
});
const association = new wafv2.CfnWebACLAssociation(this, 'ApiWebAclAssociation', {
resourceArn: cdk.Stack.of(this).formatArn({
service: 'apigateway',
resource: 'restapis',
resourceName: `${this.api.restApiId}/stages/${this.api.deploymentStage.stageName}`,
arnFormat: cdk.ArnFormat.SLASH_RESOURCE_NAME,
}),
webAclArn: webAcl.attrArn,
});
association.node.addDependency(this.api.deploymentStage);
}
}methodArn unless caching is disabled.Authentication proves that Cognito issued a valid access token. The Lambda processor must still enforce application entitlements, tenant boundaries, approved operation types, and server-side resource allowlists. Never trust a client-supplied SageMaker endpoint name or Bedrock model identifier directly.
from collections.abc import Mapping
from typing import Any
APPROVED_OPERATIONS = {
'summarize': {'service': 'bedrock', 'resource_key': 'SUMMARY_MODEL_ARN'},
'classify': {'service': 'sagemaker', 'resource_key': 'CLASSIFIER_ENDPOINT_NAME'},
}
REQUIRED_SCOPE = 'ml-api/invoke'
def authorize_request(
claims: Mapping[str, Any],
body: Mapping[str, Any],
env: Mapping[str, str],
) -> dict[str, str]:
scopes = set(str(claims.get('scope', '')).split())
if REQUIRED_SCOPE not in scopes:
raise PermissionError('required OAuth scope is missing')
operation = str(body.get('operation', ''))
mapping = APPROVED_OPERATIONS.get(operation)
if mapping is None:
raise PermissionError('operation is not approved')
resource = env.get(mapping['resource_key'])
if not resource:
raise RuntimeError(f"missing configuration: {mapping['resource_key']}")
return {
'service': mapping['service'],
'resource': resource,
'subject': str(claims.get('sub', '')),
}import { Auth } from 'aws-amplify' class no longer exists in v6. All auth functions must be imported individually from 'aws-amplify/auth'. The Amplify.configure() structure changed too. Supply the full Cognito managed-login domain emitted by infrastructure rather than deriving it from the user-pool ID.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import {
HttpClientModule,
HTTP_INTERCEPTORS,
} from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { Amplify } from 'aws-amplify';
import { environment } from '../environments/environment';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { AuthModule } from './auth/auth.module';
import { MLServicesModule } from './ml-services/ml-services.module';
import { AuthInterceptor } from './auth/auth.interceptor';
import { ErrorInterceptor } from './shared/error.interceptor';
Amplify.configure({
Auth: {
Cognito: {
userPoolId: environment.cognito.userPoolId,
userPoolClientId: environment.cognito.userPoolWebClientId,
loginWith: {
oauth: {
domain: environment.cognito.domain,
scopes: ['email', 'openid', 'profile', environment.cognito.apiScope],
redirectSignIn: [`${window.location.origin}/auth/callback`],
redirectSignOut: [`${window.location.origin}/auth/logout`],
responseType: 'code',
},
},
},
},
});
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,
ReactiveFormsModule,
AppRoutingModule,
AuthModule,
MLServicesModule,
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
],
bootstrap: [AppComponent],
})
export class AppModule {}
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import {
fetchAuthSession,
getCurrentUser,
signInWithRedirect,
signOut as amplifySignOut,
} from 'aws-amplify/auth';
export interface User {
username: string;
email: string;
enterpriseRole: string;
userId: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly userSubject = new BehaviorSubject<User | null>(null);
readonly currentUser$ = this.userSubject.asObservable();
constructor(private readonly router: Router) {
void this.initializeAuth();
}
private async initializeAuth(): Promise<void> {
try {
const { username, userId } = await getCurrentUser();
const session = await fetchAuthSession();
const payload = session.tokens?.idToken?.payload;
this.userSubject.next({
username,
userId,
email: String(payload?.['email'] ?? ''),
enterpriseRole: String(payload?.['custom:enterpriseRole'] ?? ''),
});
} catch {
this.userSubject.next(null);
}
}
async signInWithSAML(): Promise<void> {
await signInWithRedirect({ provider: { custom: 'EntraID' } });
}
async getAccessToken(): Promise<string> {
const session = await fetchAuthSession();
const token = session.tokens?.accessToken?.toString();
if (!token) {
throw new Error('No Cognito access token is available');
}
return token;
}
async signOut(): Promise<void> {
await amplifySignOut();
this.userSubject.next(null);
await this.router.navigate(['/auth/login']);
}
isAuthenticated(): boolean {
return this.userSubject.value !== null;
}
hasRole(role: string): boolean {
return this.userSubject.value?.enterpriseRole === role;
}
}
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, from, map, switchMap } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { environment } from '../../environments/environment';
export interface ClassifyRequest {
operation: 'classify';
inputData: unknown;
parameters?: Record<string, unknown>;
}
export interface SummarizeRequest {
operation: 'summarize';
prompt: string;
parameters?: Record<string, unknown>;
}
export interface MLResponse {
success: boolean;
data?: unknown;
error?: string;
}
@Injectable({ providedIn: 'root' })
export class MLApiService {
private readonly base = environment.api.baseUrl.replace(/\/$/, '');
constructor(
private readonly http: HttpClient,
private readonly auth: AuthService,
) {}
private headers$(): Observable<HttpHeaders> {
return from(this.auth.getAccessToken()).pipe(
map(token => new HttpHeaders({
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
})),
);
}
classify(req: ClassifyRequest): Observable<MLResponse> {
return this.headers$().pipe(
switchMap(headers => this.http.post<MLResponse>(
`${this.base}/ml/sagemaker`,
req,
{ headers },
)),
);
}
summarize(req: SummarizeRequest): Observable<MLResponse> {
return this.headers$().pipe(
switchMap(headers => this.http.post<MLResponse>(
`${this.base}/ml/bedrock`,
req,
{ headers },
)),
);
}
}
The MainStack composes reusable CDK constructs for Cognito, Lambda, and API Gateway. It emits three outputs — User Pool ID, Client ID, and API URL — that the deployment script uses to write the frontend environment files automatically.
NestedStack only when nested-stack lifecycle semantics are intended.
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import { ApiConstruct } from './api-construct';
import { CognitoConstruct } from './cognito-construct';
export interface MainStackProps extends cdk.StackProps {
readonly environmentName: string;
readonly applicationOrigin: string;
readonly cognitoDomainPrefix: string;
}
export class MainStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: MainStackProps) {
super(scope, id, props);
const bedrockModelArn = new cdk.CfnParameter(this, 'ApprovedBedrockModelArn', {
type: 'String',
description: 'ARN of the approved Bedrock foundation model or inference profile',
});
const classifierEndpointName = new cdk.CfnParameter(this, 'ClassifierEndpointName', {
type: 'String',
description: 'Name of the approved SageMaker endpoint',
});
const identity = new CognitoConstruct(this, 'Identity', {
domainPrefix: props.cognitoDomainPrefix,
callbackUrls: [`${props.applicationOrigin}/auth/callback`],
logoutUrls: [`${props.applicationOrigin}/auth/logout`],
});
const processor = new lambda.Function(this, 'MLProcessor', {
functionName: `MLProcessorFunction-${props.environmentName}`,
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset('lambda/ml-processor'),
timeout: cdk.Duration.seconds(25),
memorySize: 2048,
environment: {
SUMMARY_MODEL_ARN: bedrockModelArn.valueAsString,
CLASSIFIER_ENDPOINT_NAME: classifierEndpointName.valueAsString,
},
});
processor.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: [bedrockModelArn.valueAsString],
}));
processor.addToRolePolicy(new iam.PolicyStatement({
actions: ['sagemaker:InvokeEndpoint'],
resources: [cdk.Stack.of(this).formatArn({
service: 'sagemaker',
resource: 'endpoint',
resourceName: classifierEndpointName.valueAsString,
})],
}));
const api = new ApiConstruct(this, 'Api', {
userPool: identity.userPool,
processor,
allowedOrigin: props.applicationOrigin,
authorizationScope: identity.apiScope,
});
new cdk.CfnOutput(this, 'UserPoolId', {
value: identity.userPool.userPoolId,
});
new cdk.CfnOutput(this, 'UserPoolClientId', {
value: identity.userPoolClient.userPoolClientId,
});
new cdk.CfnOutput(this, 'CognitoDomain', {
value: `${identity.domainPrefix}.auth.${this.region}.amazoncognito.com`,
});
new cdk.CfnOutput(this, 'CognitoApiScope', {
value: identity.apiScope,
});
new cdk.CfnOutput(this, 'ApiGatewayUrl', {
value: api.api.url,
});
}
}
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { MainStack } from '../lib/main-stack';
const app = new cdk.App();
const environmentName = String(
app.node.tryGetContext('environment') ?? process.env.ENVIRONMENT ?? 'dev'
).toLowerCase();
const suffix: Record<string, string> = {
dev: 'Dev',
staging: 'Staging',
prod: 'Prod',
};
if (!(environmentName in suffix)) {
throw new Error('environment must be dev, staging, or prod');
}
const region = process.env.CDK_DEFAULT_REGION ?? 'us-east-1';
const account = process.env.CDK_DEFAULT_ACCOUNT;
const applicationOrigin = String(
app.node.tryGetContext('applicationOrigin') ?? 'https://your-domain.example'
);
const cognitoDomainPrefix = String(
app.node.tryGetContext('cognitoDomainPrefix') ??
`ml-app-${environmentName}-${account ?? 'local'}-${region}`
).toLowerCase();
new MainStack(app, `MLAppStack-${suffix[environmentName]}`, {
environmentName,
applicationOrigin,
cognitoDomainPrefix,
env: { account, region },
tags: {
Project: 'ML-App',
Environment: environmentName,
},
});
Three jobs run in sequence: test → deploy-infrastructure → deploy-frontend. CDK infrastructure runs in a dedicated GitHub Actions job — never inside Amplify Hosting.
cdk deploy inside the Amplify build spec. The Amplify build role has minimal IAM permissions by design. All CDK deployments must run from a GitHub Actions runner (or AWS CodePipeline) that assumes a role with the required permissions.
version: 1
applications:
- appRoot: frontend
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- npm run build:prod
artifacts:
# Angular's current application builder normally emits browser assets here.
# Confirm the project/outputPath values in angular.json.
baseDirectory: dist/ml-app/browser
files:
- '**/*'
cache:
paths:
- node_modules/**/*
name: Deploy ML App
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
NODE_VERSION: '22'
jobs:
test:
runs-on: ubuntu-latest
env:
CDK_DEFAULT_ACCOUNT: '111111111111'
CDK_DEFAULT_REGION: us-east-1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: |
frontend/package-lock.json
infrastructure/package-lock.json
- run: cd frontend && npm ci && npm run lint && npm run test:ci && npm run build:prod
- run: cd infrastructure && npm ci && npm run build && npx cdk synth MLAppStack-Prod -c environment=prod
deploy-infrastructure:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: github-ml-app-${{ github.run_id }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: infrastructure/package-lock.json
- run: cd infrastructure && npm ci && npm run build
- name: Deploy CDK stack
run: |
cd infrastructure
npx cdk deploy MLAppStack-Prod \
-c environment=prod \
-c applicationOrigin='${{ vars.APPLICATION_ORIGIN }}' \
-c cognitoDomainPrefix='${{ vars.COGNITO_DOMAIN_PREFIX }}' \
--parameters ApprovedBedrockModelArn='${{ vars.APPROVED_BEDROCK_MODEL_ARN }}' \
--parameters ClassifierEndpointName='${{ vars.CLASSIFIER_ENDPOINT_NAME }}' \
--require-approval never
release-frontend:
needs: deploy-infrastructure
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_AMPLIFY_RELEASE_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- run: >-
aws amplify start-job
--app-id '${{ vars.AMPLIFY_APP_ID }}'
--branch-name main
--job-type RELEASE
Promote builds through three stages with explicit quality gates. No code reaches production without passing development and staging validation.
#!/usr/bin/env bash
# Usage: ./scripts/deploy.sh [dev|staging|prod] [region]
set -euo pipefail
ENVIRONMENT=${1:-dev}
REGION=${2:-us-east-1}
case "$ENVIRONMENT" in
dev) STACK_NAME='MLAppStack-Dev'; AMPLIFY_BRANCH='dev' ;;
staging) STACK_NAME='MLAppStack-Staging'; AMPLIFY_BRANCH='staging' ;;
prod) STACK_NAME='MLAppStack-Prod'; AMPLIFY_BRANCH='main' ;;
*) echo 'Error: environment must be dev, staging, or prod' >&2; exit 1 ;;
esac
: "${AMPLIFY_APP_ID:?Set AMPLIFY_APP_ID}"
: "${APPLICATION_ORIGIN:?Set APPLICATION_ORIGIN}"
: "${COGNITO_DOMAIN_PREFIX:?Set COGNITO_DOMAIN_PREFIX}"
: "${APPROVED_BEDROCK_MODEL_ARN:?Set APPROVED_BEDROCK_MODEL_ARN}"
: "${CLASSIFIER_ENDPOINT_NAME:?Set CLASSIFIER_ENDPOINT_NAME}"
export AWS_REGION="$REGION"
export CDK_DEFAULT_REGION="$REGION"
(
cd infrastructure
npm ci
npm run build
npx cdk deploy "$STACK_NAME" \
-c environment="$ENVIRONMENT" \
-c applicationOrigin="$APPLICATION_ORIGIN" \
-c cognitoDomainPrefix="$COGNITO_DOMAIN_PREFIX" \
--parameters ApprovedBedrockModelArn="$APPROVED_BEDROCK_MODEL_ARN" \
--parameters ClassifierEndpointName="$CLASSIFIER_ENDPOINT_NAME" \
--require-approval never
)
query_output() {
aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey==\`$1\`].OutputValue | [0]" \
--output text
}
API_URL=$(query_output ApiGatewayUrl)
USER_POOL_ID=$(query_output UserPoolId)
CLIENT_ID=$(query_output UserPoolClientId)
COGNITO_DOMAIN=$(query_output CognitoDomain)
API_SCOPE=$(query_output CognitoApiScope)
case "$ENVIRONMENT" in
prod) ENV_FILE='frontend/src/environments/environment.prod.ts' ;;
*) ENV_FILE="frontend/src/environments/environment.${ENVIRONMENT}.ts" ;;
esac
IS_PROD=false
[[ "$ENVIRONMENT" == 'prod' ]] && IS_PROD=true
cat > "$ENV_FILE" <<EOF
export const environment = {
production: ${IS_PROD},
cognito: {
userPoolId: '${USER_POOL_ID}',
userPoolWebClientId: '${CLIENT_ID}',
domain: '${COGNITO_DOMAIN}',
region: '${REGION}',
apiScope: '${API_SCOPE}',
},
api: {
baseUrl: '${API_URL}',
region: '${REGION}',
},
} as const;
EOF
(
cd frontend
npm ci
npm run "build:${ENVIRONMENT}"
)
aws amplify start-job \
--app-id "$AMPLIFY_APP_ID" \
--branch-name "$AMPLIFY_BRANCH" \
--job-type RELEASE
echo "Deployment complete: $STACK_NAME"
echo "API URL: $API_URL"
echo "User pool: $USER_POOL_ID"
AWS_DEPLOY_ROLE_ARN (GitHub environment variable)AWS_AMPLIFY_RELEASE_ROLE_ARN (GitHub environment variable)AMPLIFY_APP_ID (GitHub environment variable)DEV_AMPLIFY_APP_IDSTAGING_AMPLIFY_APP_IDPROD_AMPLIFY_APP_IDenvironment.tsdataTraceEnabled: false confirmedSymptoms: The browser blocks an API request or preflight request and reports a CORS-policy error.
Likely causes: The SPA origin is not explicitly allowed; Authorization is missing from allowed headers; the OPTIONS method is absent; or gateway/integration error responses omit CORS headers.
Resolution:
Authorization and Content-Type.OPTIONS succeeds without requiring user authorization.Symptoms: Redirect loop, invalid redirect URI, SAML error, PKCE failure, or the browser returns to the SPA without a usable session.
Likely causes: Callback/logout URL mismatch, incorrect Cognito domain, unsupported app-client IdP, Entra ACS/entity-ID mismatch, expired SAML assertion, invalid state/nonce, or PKCE verifier mismatch.
Resolution:
Symptoms: API Gateway returns 401, or Lambda/application policy returns 403.
Likely causes: Expired token, wrong issuer, wrong app client, wrong token type, missing OAuth scope, or missing entitlement/group mapping.
Resolution:
iss, token_use=access, client_id, exp, iat, and the required scope such as ml-api/invoke.Symptoms: API Gateway returns 504 while Lambda or a downstream model may continue processing.
Likely causes: The integration did not return a usable response within the configured timeout because of Lambda duration, cold start, SageMaker/Bedrock latency, dependency timeout, retry amplification, or malformed integration response.
Resolution:
Latency and IntegrationLatency with Lambda duration and downstream service latency.Symptoms: API Gateway, Lambda, Bedrock, SageMaker, or SQS-related requests are throttled.
Resolution:
Throttles, Bedrock/SageMaker error metrics, and service quotas.ApproximateAgeOfOldestMessage, visible messages, in-flight messages, and DLQ depth.Invocations, Invocation4XXErrors, Invocation5XXErrors, InvocationModelErrors, ModelLatency, and OverheadLatency.AWS/SageMaker namespace with EndpointName and VariantName dimensions; ModelLatency is reported in microseconds.bedrock:InvokeModel or the relevant streaming permission, plus SCPs, permission boundaries, resource policies, and guardrail permissions.The production baseline uses service metrics, metric math, structured logs, traces, and alarms tied to SLOs. The example values below are placeholders; calibrate them from observed traffic, error budgets, model latency objectives, environment criticality, and expected spend.
| Layer | Minimum signals | Operational purpose |
|---|---|---|
| Amplify / CloudFront | Build/deploy failures, request count, 4XX/5XX rate, cache-hit rate, origin latency | Detect failed releases, broken rewrites, domain/TLS problems, and delivery regressions |
| AWS WAF | Allowed, blocked, counted, rate-based rule, managed-rule labels, sampled requests | Detect attack traffic and false positives |
| Cognito / Entra | Authentication failures, redirect/SAML errors, Conditional Access denials, entitlement failures | Distinguish authentication, federation, and authorization faults |
| API Gateway | Count, 4XX/5XX rate, Latency, IntegrationLatency, access logs, per-route status | Separate client, authorization, gateway, and integration failures |
| Lambda | Invocations, Errors, Throttles, p95/p99 Duration, ConcurrentExecutions, timeout and dependency metrics | Detect code defects, saturation, and downstream latency |
| Async path | SQS age/depth/DLQ, Step Functions failed/timed-out/aborted, stale-job count | Detect backlog, poison messages, and workflow failure |
| SageMaker | Invocations, 4XX/5XX/model errors, ModelLatency, OverheadLatency, capacity and model-monitor findings | Detect endpoint, model, capacity, and drift issues |
| Bedrock | Invocations, latency, client/server errors, throttles, token usage, guardrail interventions | Detect model access, quota, safety, latency, and cost issues |
| Cost | AWS Budgets, Cost Anomaly Detection, account/service forecasts | Detect unexpected spend and apply governed cost actions |
cloudwatch.CfnDashboard. The L2 cloudwatch.Dashboard uses widget objects and addWidgets(); it does not accept a dashboardBody property.{
"widgets": [
{
"type": "metric",
"width": 12,
"height": 6,
"properties": {
"title": "API Gateway — Traffic, Errors, and Latency",
"region": "us-east-1",
"period": 300,
"view": "timeSeries",
"metrics": [
["AWS/ApiGateway", "Count", "ApiName", "ML App API", {"id": "requests", "stat": "Sum"}],
[".", "4XXError", ".", ".", {"id": "e4", "stat": "Sum", "visible": false}],
[".", "5XXError", ".", ".", {"id": "e5", "stat": "Sum", "visible": false}],
[{"expression": "100*e4/MAX([requests,1])", "label": "4XX rate (%)", "id": "r4"}],
[{"expression": "100*e5/MAX([requests,1])", "label": "5XX rate (%)", "id": "r5"}],
[".", "Latency", ".", ".", {"stat": "p95", "label": "Latency p95"}],
[".", "IntegrationLatency", ".", ".", {"stat": "p95", "label": "Integration latency p95"}]
]
}
},
{
"type": "metric",
"width": 12,
"height": 6,
"properties": {
"title": "Lambda — Health and Saturation",
"region": "us-east-1",
"period": 300,
"metrics": [
["AWS/Lambda", "Invocations", "FunctionName", "MLProcessorFunction-prod", {"stat": "Sum"}],
[".", "Errors", ".", ".", {"stat": "Sum"}],
[".", "Throttles", ".", ".", {"stat": "Sum"}],
[".", "Duration", ".", ".", {"stat": "p95"}],
[".", "ConcurrentExecutions", ".", ".", {"stat": "Maximum"}]
]
}
},
{
"type": "metric",
"width": 12,
"height": 6,
"properties": {
"title": "SageMaker Endpoint — Invocations, Errors, and Latency",
"region": "us-east-1",
"period": 300,
"metrics": [
["AWS/SageMaker", "Invocations", "EndpointName", "approved-classifier-endpoint", "VariantName", "AllTraffic", {"stat": "Sum"}],
[".", "Invocation4XXErrors", ".", ".", ".", ".", {"stat": "Sum"}],
[".", "Invocation5XXErrors", ".", ".", ".", ".", {"stat": "Sum"}],
[".", "InvocationModelErrors", ".", ".", ".", ".", {"stat": "Sum"}],
[".", "ModelLatency", ".", ".", ".", ".", {"stat": "p95", "label": "Model latency p95 (µs)"}],
[".", "OverheadLatency", ".", ".", ".", ".", {"stat": "p95", "label": "Overhead latency p95 (µs)"}]
]
}
},
{
"type": "metric",
"width": 12,
"height": 6,
"properties": {
"title": "Async Queue — Backlog and DLQ",
"region": "us-east-1",
"period": 300,
"metrics": [
["AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "ml-jobs-prod", {"stat": "Maximum"}],
[".", "ApproximateAgeOfOldestMessage", ".", ".", {"stat": "Maximum"}],
[".", "ApproximateNumberOfMessagesNotVisible", ".", ".", {"stat": "Maximum"}],
["AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "ml-jobs-dlq-prod", {"stat": "Maximum", "label": "DLQ depth"}]
]
}
},
{
"type": "log",
"width": 24,
"height": 6,
"properties": {
"title": "Recent Lambda errors by correlation ID",
"region": "us-east-1",
"view": "table",
"query": "SOURCE '/aws/lambda/MLProcessorFunction-prod' | fields @timestamp, level, correlationId, requestId, errorType, message | filter level = 'ERROR' | sort @timestamp desc | limit 50"
}
}
]
}import * as cdk from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import { Construct } from 'constructs';
export interface MonitoringStackProps extends cdk.StackProps {
readonly alertEmail: string;
readonly apiName: string;
readonly lambdaFunctionName: string;
readonly sageMakerEndpointName: string;
readonly dashboardBody: string;
}
export class MonitoringStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: MonitoringStackProps) {
super(scope, id, props);
const alerts = new sns.Topic(this, 'OperationalAlerts', {
enforceSSL: true,
});
alerts.addSubscription(new subscriptions.EmailSubscription(props.alertEmail));
const notify = new actions.SnsAction(alerts);
const requests = new cloudwatch.Metric({
namespace: 'AWS/ApiGateway', metricName: 'Count',
dimensionsMap: { ApiName: props.apiName },
statistic: 'Sum', period: cdk.Duration.minutes(5),
});
const errors4xx = new cloudwatch.Metric({
namespace: 'AWS/ApiGateway', metricName: '4XXError',
dimensionsMap: { ApiName: props.apiName },
statistic: 'Sum', period: cdk.Duration.minutes(5),
});
const errorRate4xx = new cloudwatch.MathExpression({
expression: '100 * e4 / MAX([req, 1])',
usingMetrics: { e4: errors4xx, req: requests },
label: 'API 4XX rate (%)', period: cdk.Duration.minutes(5),
});
errorRate4xx.createAlarm(this, 'Api4xxRateAlarm', {
threshold: 5,
evaluationPeriods: 3,
datapointsToAlarm: 2,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(notify);
new cloudwatch.Metric({
namespace: 'AWS/Lambda', metricName: 'Errors',
dimensionsMap: { FunctionName: props.lambdaFunctionName },
statistic: 'Sum', period: cdk.Duration.minutes(5),
}).createAlarm(this, 'LambdaErrorsAlarm', {
threshold: 1, evaluationPeriods: 3, datapointsToAlarm: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(notify);
new cloudwatch.Metric({
namespace: 'AWS/SageMaker', metricName: 'ModelLatency',
dimensionsMap: {
EndpointName: props.sageMakerEndpointName,
VariantName: 'AllTraffic',
},
statistic: 'p95', period: cdk.Duration.minutes(5),
}).createAlarm(this, 'SageMakerLatencyAlarm', {
threshold: 5_000_000, // 5 seconds, expressed in microseconds
evaluationPeriods: 3, datapointsToAlarm: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(notify);
new cloudwatch.CfnDashboard(this, 'OperationsDashboard', {
dashboardName: 'ML-App-Operations',
dashboardBody: props.dashboardBody,
});
}
}us-east-1 after enabling billing alerts in the payer/management account. Prefer AWS Budgets and Cost Anomaly Detection for governed cost thresholds and forecast-based alerts. Merely setting a metric region does not relocate an alarm deployed in another Region.| Version | Date | Author | Changes |
|---|---|---|---|
| 2.4 | July 2026 | AI Security Architecture Review | Corrected and expanded troubleshooting, observability, dashboards, alarms, async monitoring, and incident-response guidance |
| 1.0 | July 2025 | Technical Documentation Team | Initial release with complete implementation guide |
| 0.9 | June 2025 | Technical Documentation Team | Beta release for internal review and testing |
| 0.8 | May 2025 | Technical Documentation Team | Added security architecture and compliance sections |
| 0.7 | April 2025 | Technical Documentation Team | Completed implementation guide and deployment sections |
| 0.6 | March 2025 | Technical Documentation Team | Added data flow architecture and ML integration details |
| 0.5 | February 2025 | Technical Documentation Team | Initial architecture overview and authentication flows |
Document Series Navigation