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 private API Gateway integration.
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 1.1 | Updated: March 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, private API Gateway configurations with AWS WAF protection, and Lambda-based processors for ML service orchestration.
Figure 1: Overall System Architecture
| 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 | CloudFront, S3, CodeCommit |
| 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 | Private Endpoints, Throttling, Monitoring | Lambda, WAF, VPC |
| Lambda 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
Private API Gateway with WAF protection and Lambda authorization
SageMaker and Bedrock integration through Lambda processors
Lambda Authorizer responses are cached by API Gateway for up to 1 hour by default. Ensure your caching strategy accounts for user permission changes and token expiration. Consider implementing cache invalidation for critical permission updates.
| Authorization Pattern | Use Case | Implementation | Security Level |
|---|---|---|---|
| Role-Based Access Control (RBAC) | Standard user permissions | Cognito Groups + Lambda Authorizer | Medium |
| Attribute-Based Access Control (ABAC) | Dynamic permissions | JWT Claims + Context Evaluation | High |
| Resource-Based Authorization | Data-specific access | Lambda Function + DynamoDB | High |
| Time-Based Access | Scheduled operations | JWT exp claim + Lambda validation | 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 18 LTS (Debian/Ubuntu) curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs # 2 — Global CLIs npm install -g @angular/cli@16 npm install -g aws-cdk # AWS CLI v2 curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip unzip awscliv2.zip && sudo ./aws/install # 3 — Configure AWS credentials (interactive) aws configure # 4 — Install project dependencies (cd frontend && npm ci) (cd infrastructure && npm ci)
The key difference between environments is the domainPrefix. This is the Cognito hosted-UI prefix set under User Pool → App Integration → Domain — not the userPoolId.
// src/environments/environment.ts
export const environment = {
production: false,
cognito: {
userPoolId: 'us-east-1_XXXXXXXXX',
userPoolWebClientId: 'XXXXXXXXXXXXXXXXXX',
// Cognito hosted-UI prefix — NOT userPoolId
domainPrefix: 'my-company-ml-app-dev',
region: 'us-east-1',
identityPoolId:
'us-east-1:XXXXXXXX-XXXX-XXXX-XXXXXXXXXXXX'
},
api: {
baseUrl: 'https://api.example.com/dev',
region: 'us-east-1'
}
};
// src/environments/environment.prod.ts
export const environment = {
production: true,
cognito: {
userPoolId: 'us-east-1_YYYYYYYYY',
userPoolWebClientId: 'YYYYYYYYYYYYYYYYYY',
// Cognito hosted-UI prefix — NOT userPoolId
domainPrefix: 'my-company-ml-app',
region: 'us-east-1',
identityPoolId:
'us-east-1:YYYYYYYY-YYYY-YYYY-YYYYYYYYYYYY'
},
api: {
baseUrl: 'https://api.example.com/prod',
region: 'us-east-1'
}
};
The CognitoStack creates the User Pool, App Client, user groups, and the Entra ID SAML identity provider. Deploy this stack first — all other stacks reference its outputs.
generateSecret: false is required for browser-based SPAs — secret-based flows cannot run in a browser. Token validity is set to 1 hour (access/ID) with a 30-day refresh window.
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import { Construct } from 'constructs';
export class CognitoStack extends cdk.Stack {
public readonly userPool: cognito.UserPool;
public readonly userPoolClient: cognito.UserPoolClient;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ── User Pool ─────────────────────────────────────────────────
this.userPool = new cognito.UserPool(this, 'MLAppUserPool', {
userPoolName: 'ml-app-user-pool',
selfSignUpEnabled: false, // Corporate users only
signInAliases: { email: true },
passwordPolicy: {
minLength: 12,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
},
mfa: cognito.Mfa.REQUIRED,
mfaSecondFactor: { sms: true, otp: true },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// ── App Client ────────────────────────────────────────────────
this.userPoolClient = new cognito.UserPoolClient(this, 'MLAppClient', {
userPool: this.userPool,
userPoolClientName: 'ml-app-client',
generateSecret: false, // Required for browser SPAs
authFlows: { userSrp: true },
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
callbackUrls: [
'https://localhost:4200/auth/callback',
'https://your-domain.amplifyapp.com/auth/callback',
],
logoutUrls: [
'https://localhost:4200/auth/logout',
'https://your-domain.amplifyapp.com/auth/logout',
],
},
accessTokenValidity: cdk.Duration.hours(1),
idTokenValidity: cdk.Duration.hours(1),
refreshTokenValidity: cdk.Duration.days(30),
});
// ── User Groups ───────────────────────────────────────────────
[
{ name: 'Administrators', desc: 'Full ML access', precedence: 1 },
{ name: 'MLUsers', desc: 'Standard ML access', precedence: 2 },
{ name: 'ReadOnly', desc: 'Read-only access', precedence: 3 },
].forEach(g =>
new cognito.CfnUserPoolGroup(this, `Group-${g.name}`, {
userPoolId: this.userPool.userPoolId,
groupName: g.name,
description: g.desc,
precedence: g.precedence,
})
);
// ── Entra ID SAML Identity Provider ──────────────────────────
const samlProvider = new cognito.CfnUserPoolIdentityProvider(
this, 'EntraIDProvider', {
userPoolId: this.userPool.userPoolId,
providerName: 'EntraID',
providerType: 'SAML',
providerDetails: {
MetadataURL:
'https://login.microsoftonline.com/{tenant-id}'
+ '/federationmetadata/2007-06/federationmetadata.xml',
IDPSignout: 'true',
},
attributeMapping: {
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
given_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
family_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
},
}
);
this.userPoolClient.node.addDependency(samlProvider);
}
}
Cors.ALL_ORIGINS (wildcard *) in production. Enumerate only your actual Amplify and custom domains.dataTraceEnabled: false — must be false in production. When true, full request/response payloads including sensitive ML inputs are written to CloudWatch Logs — a serious security and compliance risk.import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
export class ApiGatewayStack extends cdk.Stack {
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: {
authorizerFunction: lambda.Function;
mlProcessorFunction: lambda.Function;
} & cdk.StackProps) {
super(scope, id, props);
// ── WAF Web ACL ───────────────────────────────────────────────
const webAcl = new wafv2.CfnWebACL(this, 'MLAppWebACL', {
scope: 'REGIONAL',
defaultAction: { allow: {} },
rules: [
{
name: 'AWSManagedRulesCommonRuleSet', priority: 1,
overrideAction: { none: {} },
statement: { managedRuleGroupStatement: { vendorName: 'AWS', name: 'AWSManagedRulesCommonRuleSet' } },
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'CommonRuleSet' },
},
{
name: 'AWSManagedRulesKnownBadInputsRuleSet', priority: 2,
overrideAction: { none: {} },
statement: { managedRuleGroupStatement: { vendorName: 'AWS', name: 'AWSManagedRulesKnownBadInputsRuleSet' } },
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'KnownBadInputs' },
},
{
name: 'RateLimitRule', priority: 3,
action: { block: {} },
statement: { rateBasedStatement: { limit: 2000, aggregateKeyType: 'IP' } },
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'RateLimit' },
},
],
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'MLAppWebACL' },
});
// ── REST API ──────────────────────────────────────────────────
this.api = new apigateway.RestApi(this, 'MLAppAPI', {
restApiName: 'ML App API',
endpointConfiguration: { types: [apigateway.EndpointType.REGIONAL] },
// ⚠ List explicit origins — never use Cors.ALL_ORIGINS in production
defaultCorsPreflightOptions: {
allowOrigins: ['https://your-domain.amplifyapp.com'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Amz-Date', 'X-Api-Key'],
allowCredentials: true,
},
deployOptions: {
stageName: 'prod',
throttlingRateLimit: 1000,
throttlingBurstLimit: 2000,
loggingLevel: apigateway.MethodLoggingLevel.ERROR,
dataTraceEnabled: false, // ⚠ NEVER true in production
metricsEnabled: true,
// Structured access logging — use instead of dataTrace
accessLogDestination: new apigateway.LogGroupLogDestination(
new logs.LogGroup(this, 'ApiAccessLogs', {
retention: logs.RetentionDays.THIRTY_DAYS,
})
),
accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(),
},
});
// Associate WAF
new wafv2.CfnWebACLAssociation(this, 'WebACLAssoc', {
resourceArn: this.api.deploymentStage.stageArn,
webAclArn: webAcl.attrArn,
});
// ── Lambda Authorizer ─────────────────────────────────────────
const authorizer = new apigateway.TokenAuthorizer(this, 'JWTAuthorizer', {
handler: props.authorizerFunction,
identitySource: 'method.request.header.Authorization',
authorizerName: 'JWTAuthorizer',
resultsCacheTtl: cdk.Duration.minutes(5),
});
// ── API Routes ────────────────────────────────────────────────
const ml = this.api.root.addResource('ml');
ml.addResource('sagemaker').addMethod(
'POST',
new apigateway.LambdaIntegration(props.mlProcessorFunction),
{ authorizer }
);
ml.addResource('bedrock').addMethod(
'POST',
new apigateway.LambdaIntegration(props.mlProcessorFunction),
{ authorizer }
);
}
}
The authorizer validates every JWT access token before any request reaches your ML services. It runs five checks in sequence:
Bearer prefix from the Authorization header.kid (key ID) for JWKS key lookup.exp, iss, client_id (not aud), and token_use == "access".cognito:groups (not groups) and allow only Administrators and MLUsers.client_id, not aud. Passing audience= to jwt.decode() always raises InvalidAudienceError. Use options={"verify_aud": False} and check client_id manually.cognito:groups, not groups. Using the wrong key silently returns an empty list and denies every user.import json, os, logging
from functools import lru_cache
from typing import Dict, Any, Optional
import jwt
import requests
logger = logging.getLogger()
logger.setLevel(logging.INFO)
COGNITO_REGION = os.environ['COGNITO_REGION']
USER_POOL_ID = os.environ['USER_POOL_ID']
CLIENT_ID = os.environ['CLIENT_ID'] # Validated manually — NOT via audience=
JWKS_URL = (
f'https://cognito-idp.{COGNITO_REGION}.amazonaws.com'
f'/{USER_POOL_ID}/.well-known/jwks.json'
)
@lru_cache(maxsize=1)
def get_jwks() -> Dict:
"""Fetch JWKS once and cache for the Lambda container lifetime."""
resp = requests.get(JWKS_URL, timeout=10)
resp.raise_for_status()
return resp.json()
def get_public_key(kid: str):
"""Return RSA public key matching the token's kid."""
for key in get_jwks()['keys']:
if key['kid'] == kid:
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
raise ValueError(f'Public key not found for kid: {kid}')
def validate_token(token: str) -> Dict:
"""Verify signature and validate claims. Returns claims dict."""
header = jwt.get_unverified_header(token)
public_key = get_public_key(header['kid'])
# ⚠ Cognito ACCESS tokens use client_id, NOT aud — disable audience check
claims = jwt.decode(
token,
public_key,
algorithms=['RS256'],
options={'verify_aud': False},
issuer=(
f'https://cognito-idp.{COGNITO_REGION}.amazonaws.com'
f'/{USER_POOL_ID}'
),
)
# Validate client_id manually (replaces the standard audience check)
if claims.get('client_id') != CLIENT_ID:
raise ValueError('client_id mismatch')
# Confirm access token (not ID token)
if claims.get('token_use') != 'access':
raise ValueError("token_use must be 'access'")
return claims
def generate_policy(principal: str, effect: str, resource: str,
context: Optional[Dict] = None) -> Dict:
policy = {
'principalId': principal,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{'Action': 'execute-api:Invoke',
'Effect': effect, 'Resource': resource}],
},
}
if context:
policy['context'] = context
return policy
def lambda_handler(event: Dict, context: Any) -> Dict:
try:
token = event.get('authorizationToken', '').replace('Bearer ', '')
if not token:
raise ValueError('No token provided')
claims = validate_token(token)
username = claims.get('username', claims.get('sub'))
# ⚠ Group claim is cognito:groups — NOT groups
groups = claims.get('cognito:groups', [])
if not any(g in {'Administrators', 'MLUsers'} for g in groups):
logger.warning(f'User {username} lacks required group')
return generate_policy(username, 'Deny', event['methodArn'])
logger.info(f'Authorised: {username} groups={groups}')
return generate_policy(username, 'Allow', event['methodArn'], {
'username': username,
'email': claims.get('email', ''),
'groups': ','.join(groups),
'sub': claims.get('sub', ''),
})
except Exception as exc:
logger.error(f'Authorizer error: {exc}')
raise Exception('Unauthorized')
PyJWT==2.8.0 cryptography==41.0.3 requests==2.31.0
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 — the OAuth domain field takes your domainPrefix, not the userPoolId.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule,
HTTP_INTERCEPTORS } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
// Amplify v6 — configure once at module load time
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 v6 configuration — domainPrefix is NOT the same as userPoolId
Amplify.configure({
Auth: {
Cognito: {
userPoolId: environment.cognito.userPoolId,
userPoolClientId: environment.cognito.userPoolWebClientId,
identityPoolId: environment.cognito.identityPoolId,
loginWith: {
oauth: {
// Format: <domainPrefix>.auth.<region>.amazoncognito.com
domain: `${environment.cognito.domainPrefix}.auth.${environment.cognito.region}.amazoncognito.com`,
scopes: ['email', 'openid', 'profile'],
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';
// Amplify v6 — named function imports, no Auth class
import {
signIn, signOut, getCurrentUser,
fetchAuthSession, signInWithRedirect,
type SignInOutput,
} from 'aws-amplify/auth';
export interface User {
username: string;
email: string;
groups: string[];
userId: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private userSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.userSubject.asObservable();
constructor(private router: Router) { 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: payload?.['email'] as string || '',
// ⚠ Group claim key is cognito:groups — NOT groups
groups: payload?.['cognito:groups'] as string[] || [],
});
} catch { /* no active session */ }
}
/** Redirect to Entra ID via Cognito hosted-UI */
async signInWithSAML(): Promise<void> {
await signInWithRedirect({ provider: { custom: 'EntraID' } });
}
/** Returns the access token string for Authorization headers */
async getAccessToken(): Promise<string> {
const session = await fetchAuthSession();
return session.tokens?.accessToken?.toString() ?? '';
}
async signOut(): Promise<void> {
await signOut();
this.userSubject.next(null);
this.router.navigate(['/auth/login']);
}
isAuthenticated(): boolean { return this.userSubject.value !== null; }
hasRole(r: string): boolean { return this.userSubject.value?.groups.includes(r) ?? false; }
hasAnyRole(rs: string[]): boolean { return rs.some(r => this.hasRole(r)); }
}
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, from, switchMap } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { environment } from '../../environments/environment';
export interface SageMakerRequest { modelName: string; inputData: any; parameters?: any; }
export interface BedrockRequest { modelId: string; prompt: string; parameters?: any; }
export interface MLResponse { success: boolean; data: any; error?: string; }
@Injectable({ providedIn: 'root' })
export class MLApiService {
private readonly base = environment.api.baseUrl;
constructor(private http: HttpClient, private auth: AuthService) {}
/** Build an Authorization header from the current access token */
private headers$(): Observable<HttpHeaders> {
return from(this.auth.getAccessToken()).pipe(
switchMap(token => [new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
})])
);
}
invokeSageMaker(req: SageMakerRequest): Observable<MLResponse> {
return this.headers$().pipe(
switchMap(h => this.http.post<MLResponse>(`${this.base}/ml/sagemaker`, req, { headers: h }))
);
}
invokeBedrock(req: BedrockRequest): Observable<MLResponse> {
return this.headers$().pipe(
switchMap(h => this.http.post<MLResponse>(`${this.base}/ml/bedrock`, req, { headers: h }))
);
}
}
The MainStack composes the Cognito, Lambda, and API Gateway stacks. It emits three outputs — User Pool ID, Client ID, and API URL — that the deployment script uses to write the frontend environment files automatically.
cdk deploy --all and CDK will sequence CognitoStack first, then Lambdas, then ApiGatewayStack.
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { CognitoStack } from './cognito-stack';
import { ApiGatewayStack } from './api-gateway-stack';
export class MainStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ── 1. Cognito — deploy first, outputs used below ─────────────
const cognitoStack = new CognitoStack(this, 'CognitoStack');
// ── 2. Lambda Authorizer (JWT validation) ─────────────────────
const authorizerFn = new lambda.Function(this, 'AuthorizerFn', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset('lambda/authorizer'),
timeout: cdk.Duration.seconds(30),
memorySize: 512,
environment: {
COGNITO_REGION: this.region,
USER_POOL_ID: cognitoStack.userPool.userPoolId,
CLIENT_ID: cognitoStack.userPoolClient.userPoolClientId,
},
});
// ── 3. ML Processor Lambda (SageMaker + Bedrock calls) ────────
const mlProcessorFn = new lambda.Function(this, 'MLProcessorFn', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset('lambda/ml-processor'),
timeout: cdk.Duration.minutes(15), // Long-running inference
memorySize: 2048, // Lambda supports 128 MB – 10,240 MB
environment: { REGION: this.region },
});
// Least-privilege: grant only the ML actions needed
mlProcessorFn.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'sagemaker:InvokeEndpoint',
'bedrock:InvokeModel',
'bedrock:InvokeModelWithResponseStream',
],
resources: ['*'], // Narrow to specific ARNs in production
}));
// ── 4. API Gateway (depends on Lambda functions above) ────────
const apiStack = new ApiGatewayStack(this, 'ApiGatewayStack', {
authorizerFunction: authorizerFn,
mlProcessorFunction: mlProcessorFn,
});
// ── Stack outputs ─────────────────────────────────────────────
new cdk.CfnOutput(this, 'UserPoolId',
{ value: cognitoStack.userPool.userPoolId, description: 'Cognito User Pool ID' });
new cdk.CfnOutput(this, 'UserPoolClientId',
{ value: cognitoStack.userPoolClient.userPoolClientId, description: 'Cognito App Client ID' });
new cdk.CfnOutput(this, 'ApiGatewayUrl',
{ value: apiStack.api.url, description: 'API Gateway base 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();
new MainStack(app, 'MLAppStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION || 'us-east-1',
},
tags: {
Project: 'ML-App',
Environment: process.env.ENVIRONMENT || 'dev',
},
});
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
frontend:
phases:
preBuild:
commands:
- cd frontend
- npm ci
build:
commands:
- npm run build:prod
artifacts:
baseDirectory: frontend/dist/ml-app # Match your Angular project dist folder name
files:
- '**/*'
cache:
paths:
- frontend/node_modules/**/*
name: Deploy ML App
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
NODE_VERSION: '18'
jobs:
# ────────────────────────────────────────────────────────────────
# Job 1: Test — runs on every push and PR
# ────────────────────────────────────────────────────────────────
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: cd frontend && npm ci
- run: cd frontend && npm run test:ci
- run: cd frontend && npm run lint
- run: cd frontend && npm run build:prod
# ────────────────────────────────────────────────────────────────
# Job 2: Deploy Infrastructure — main branch only, after test
# ────────────────────────────────────────────────────────────────
deploy-infrastructure:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: infrastructure/package-lock.json
- name: Build Lambda packages
run: |
pip install -r lambda/authorizer/requirements.txt -t lambda/authorizer/
pip install -r lambda/ml-processor/requirements.txt -t lambda/ml-processor/
- name: Deploy CDK stacks
run: cd infrastructure && npm ci && npm run build && npx cdk deploy --all --require-approval never
env:
CDK_DEFAULT_ACCOUNT: ${{ secrets.AWS_ACCOUNT_ID }}
ENVIRONMENT: production
# ────────────────────────────────────────────────────────────────
# Job 3: Deploy Frontend — runs after infrastructure is ready
# ────────────────────────────────────────────────────────────────
deploy-frontend:
needs: [test, deploy-infrastructure]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- run: cd frontend && npm ci && npm run build:prod
- name: Trigger Amplify release
run: |
aws amplify start-job \
--app-id ${{ secrets.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.
#!/bin/bash
# Usage: ./scripts/deploy.sh [dev|staging|prod] [region]
set -euo pipefail
ENVIRONMENT=${1:-dev}
REGION=${2:-us-east-1}
echo "Deploying to: $ENVIRONMENT | region: $REGION"
# ── Resolve stack name and Amplify App ID per environment ────────
case $ENVIRONMENT in
dev) STACK_NAME="MLAppStack-Dev"; AMPLIFY_APP_ID="$DEV_AMPLIFY_APP_ID" ;;
staging) STACK_NAME="MLAppStack-Staging"; AMPLIFY_APP_ID="$STAGING_AMPLIFY_APP_ID" ;;
prod) STACK_NAME="MLAppStack-Prod"; AMPLIFY_APP_ID="$PROD_AMPLIFY_APP_ID" ;;
*) echo "Error: environment must be dev | staging | prod"; exit 1 ;;
esac
# ── Step 1: Deploy CDK infrastructure ───────────────────────────
echo "Step 1/4: Deploying CDK stack $STACK_NAME ..."
(cd infrastructure && npm run build && npx cdk deploy "$STACK_NAME" --require-approval never)
# ── Step 2: Read stack outputs ───────────────────────────────────
echo "Step 2/4: Reading stack outputs ..."
query() {
aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey==\`$1\`].OutputValue" \
--output text
}
API_URL=$(query ApiGatewayUrl)
USER_POOL_ID=$(query UserPoolId)
CLIENT_ID=$(query UserPoolClientId)
echo " API URL : $API_URL"
echo " User Pool : $USER_POOL_ID"
# ── Step 3: Write frontend environment file ──────────────────────
echo "Step 3/4: Writing environment file ..."
IS_PROD=$([ "$ENVIRONMENT" = "prod" ] && echo "true" || echo "false")
cat > "frontend/src/environments/environment.${ENVIRONMENT}.ts" << EOF
export const environment = {
production: ${IS_PROD},
cognito: {
userPoolId: '${USER_POOL_ID}',
userPoolWebClientId: '${CLIENT_ID}',
region: '${REGION}',
},
api: {
baseUrl: '${API_URL}',
region: '${REGION}',
},
};
EOF
# ── Step 4: Build and trigger Amplify release ────────────────────
echo "Step 4/4: Building Angular and triggering Amplify release ..."
(cd frontend && npm run "build:${ENVIRONMENT}")
aws amplify start-job \
--app-id "$AMPLIFY_APP_ID" \
--branch-name "$ENVIRONMENT" \
--job-type RELEASE
echo ""
echo "Deployment complete."
echo " Environment : $ENVIRONMENT"
echo " API URL : $API_URL"
echo " User Pool : $USER_POOL_ID"
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_ACCOUNT_IDAMPLIFY_APP_IDDEV_AMPLIFY_APP_IDSTAGING_AMPLIFY_APP_IDPROD_AMPLIFY_APP_IDenvironment.tsdataTraceEnabled: false confirmedSymptoms: Browser console shows CORS policy errors
Cause: Incorrect CORS configuration in API Gateway or Cognito
Solution:
Symptoms: 401 Unauthorized errors from Lambda Authorizer
Cause: Token expiration, invalid signature, or incorrect claims
Solution:
Symptoms: 504 Gateway Timeout errors
Cause: Lambda function execution time exceeds API Gateway timeout
Solution:
Symptoms: 429 Too Many Requests errors
Cause: Exceeded API Gateway or Lambda concurrency limits
Solution:
Symptoms: Model inference failures or high latency
Cause: Endpoint configuration, model issues, or resource constraints
Solution:
Symptoms: Access denied or model not available errors
Cause: Insufficient permissions or model not enabled in region
Solution:
Monitoring is split into two parts: a CloudWatch Dashboard that visualises key metrics across all services, and a CDK MonitoringStack that provisions SNS-backed alarms so the team is paged before issues become outages.
new cloudwatch.Dashboard() with dashboardBody: JSON.stringify(widgetDefs).
{
"widgets": [
{
"type": "metric",
"properties": {
"title": "API Gateway — Request & Error Rates",
"metrics": [
["AWS/ApiGateway", "Count", "ApiName", "ML App API"],
[".", "Latency", ".", "." ],
[".", "4XXError", ".", "." ],
[".", "5XXError", ".", "." ]
],
"period": 300,
"stat": "Sum",
"region": "us-east-1"
}
},
{
"type": "metric",
"properties": {
"title": "Lambda Authorizer — Invocations & Health",
"metrics": [
["AWS/Lambda", "Invocations", "FunctionName", "AuthorizerFunction"],
[".", "Duration", ".", "." ],
[".", "Errors", ".", "." ],
[".", "Throttles", ".", "." ]
],
"period": 300,
"stat": "Sum",
"region": "us-east-1"
}
},
{
"type": "metric",
"properties": {
"title": "ML Processor Lambda — Concurrency & Duration",
"metrics": [
["AWS/Lambda", "Invocations", "FunctionName", "MLProcessorFunction"],
[".", "Duration", ".", "." ],
[".", "Errors", ".", "." ],
[".", "ConcurrentExecutions",".", "." ]
],
"period": 300,
"stat": "Average",
"region": "us-east-1"
}
},
{
"type": "metric",
"properties": {
"title": "SageMaker Endpoint — Latency & Errors",
"metrics": [
["AWS/SageMaker/Endpoints", "Invocations", "EndpointName", "ml-model-endpoint"],
[".", "ModelLatency", ".", "." ],
[".", "OverheadLatency", ".", "." ],
[".", "Invocation4XXErrors",".", "." ],
[".", "Invocation5XXErrors",".", "." ]
],
"period": 300,
"stat": "Sum",
"region": "us-east-1"
}
},
{
"type": "log",
"properties": {
"title": "Recent Authorization Errors",
"query": "SOURCE '/aws/lambda/AuthorizerFunction'\n| fields @timestamp, @message\n| filter @message like /ERROR/\n| sort @timestamp desc\n| limit 100",
"region": "us-east-1"
}
},
{
"type": "log",
"properties": {
"title": "ML Processing Errors & Timeouts",
"query": "SOURCE '/aws/lambda/MLProcessorFunction'\n| fields @timestamp, @message, @requestId\n| filter @message like /ERROR/ or @message like /TIMEOUT/\n| sort @timestamp desc\n| limit 100",
"region": "us-east-1"
}
}
]
}
[email protected] with your actual on-call address before deploying. For Slack or PagerDuty routing, add an HTTPS subscription to the SNS topic instead of (or alongside) the email subscription.
import * as cdk from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import { Construct } from 'constructs';
export class MonitoringStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ── SNS Alert Topic ───────────────────────────────────────────
const alertTopic = new sns.Topic(this, 'MLAppAlerts', {
displayName: 'ML App Monitoring Alerts',
});
// Replace with your ops team address (or add SNS→Slack/PagerDuty subscription)
alertTopic.addSubscription(
new subscriptions.EmailSubscription('[email protected]')
);
// Helper: create an alarm and wire it to the SNS topic
const alarm = (
id: string, name: string, desc: string,
metric: cloudwatch.Metric,
threshold: number, periods: number,
) =>
new cloudwatch.Alarm(this, id, {
alarmName: name,
alarmDescription: desc,
metric,
threshold,
evaluationPeriods: periods,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(new cloudwatch.SnsAction(alertTopic));
// ── Alarm 1: API Gateway 4XX error rate ───────────────────────
alarm(
'APIGatewayErrorRate',
'ML-App-API-4XX-Rate',
'API Gateway client error rate exceeded threshold',
new cloudwatch.Metric({
namespace: 'AWS/ApiGateway',
metricName: '4XXError',
dimensionsMap: { ApiName: 'ML App API' },
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
10, // > 10 errors per 5-min window
2, // for 2 consecutive periods
);
// ── Alarm 2: ML Processor Lambda error count ──────────────────
alarm(
'LambdaErrorRate',
'ML-App-Lambda-Errors',
'ML Processor Lambda function error count is too high',
new cloudwatch.Metric({
namespace: 'AWS/Lambda',
metricName: 'Errors',
dimensionsMap: { FunctionName: 'MLProcessorFunction' },
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
5, // > 5 errors per 5-min window
2,
);
// ── Alarm 3: SageMaker endpoint model latency ─────────────────
alarm(
'SageMakerLatency',
'ML-App-SageMaker-Latency',
'SageMaker model latency exceeded 5 s',
new cloudwatch.Metric({
namespace: 'AWS/SageMaker/Endpoints',
metricName: 'ModelLatency',
dimensionsMap: { EndpointName: 'ml-model-endpoint' },
statistic: 'Average',
period: cdk.Duration.minutes(5),
}),
5000, // 5,000 ms = 5 seconds
3,
);
// ── Alarm 4: Estimated AWS monthly spend ──────────────────────
alarm(
'CostAlarm',
'ML-App-Cost-Alert',
'Estimated monthly AWS charges exceeded budget',
new cloudwatch.Metric({
namespace: 'AWS/Billing',
metricName: 'EstimatedCharges',
dimensionsMap: { Currency: 'USD' },
statistic: 'Maximum',
period: cdk.Duration.hours(6),
}),
1000, // $1,000 USD monthly budget — adjust to your limit
1,
);
}
}
MonitoringStack to infrastructure.tscdk deploy MonitoringStackdashboard.json in CloudWatch Console| Version | Date | Author | Changes |
|---|---|---|---|
| 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