Code Repository / projects / 06-zero-trust-access
Project 06 ยท AWS CloudFormation
Remove the attack surface entirely: no open ports, no internet path, and every session tied to a real identity and recorded.
README
Format: AWS CloudFormation (single template) ยท Est. cost: ~$40/month (5 interface endpoints ร 2 AZs dominate; t4g.nano is ~$3)
| Traditional | Zero-trust version here |
|---|---|
| Bastion host with port 22 open | No inbound rules at all on the workload |
| Shared SSH keys | Identity Center login, permission set valid for 1 hour |
| VPN into the network | No internet gateway or NAT; private VPC endpoints only |
| "Who ran that command?" | Every keystroke encrypted + recorded to CloudWatch Logs and S3 |
| Find out next week | EventBridge โ SNS notice the moment a session starts |
Access is tag-scoped (ABAC): the permission set can only start sessions on instances tagged zt-access=engineers.
Deploy
aws cloudformation deploy --stack-name zero-trust \
--template-file template.yaml --capabilities CAPABILITY_IAM \
--parameter-overrides NotificationEmail=you@example.com
# With IAM Identity Center (recommended)
aws cloudformation deploy --stack-name zero-trust \
--template-file template.yaml --capabilities CAPABILITY_IAM \
--parameter-overrides \
IdentityCenterInstanceArn=arn:aws:sso:::instance/ssoins-xxxxxxxx \
IdentityCenterGroupId=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
If Session Manager preferences were ever saved in the console, add
CreateSessionPreferences=false.
Connect
Install the Session Manager plugin, sign in through Identity Center, then:
aws ssm start-session --target <InstanceId>
# Reach a private database through the instance - still no open ports
aws ssm start-session --target <InstanceId> \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters host=mydb.cluster-xyz.us-east-1.rds.amazonaws.com,portNumber=5432,localPortNumber=5432
Prove it
nmapthe instance: nothing answers (it has no public IP and no inbound rules)- Open CloudWatch Logs
/zero-trust/session-manager: full session transcript, tied to the user's identity - Check your inbox: "Session started at … by arn:aws:sts::…:assumed-role/AWSReservedSSO_ZeroTrustSessionAccess_…/alice"
Clean up
aws cloudformation delete-stack --stack-name zero-trust
The session log bucket is versioned; empty it first if deletion fails.
Files (2)
README.md ยท 51 lines
# 06 ยท Zero-Trust Access: No Bastions, No SSH Keys, No VPN
**Format:** AWS CloudFormation (single template) ยท **Est. cost:** ~$40/month (5 interface endpoints ร 2 AZs dominate; t4g.nano is ~$3)
| Traditional | Zero-trust version here |
|---|---|
| Bastion host with port 22 open | **No inbound rules at all** on the workload |
| Shared SSH keys | **Identity Center** login, permission set valid for 1 hour |
| VPN into the network | **No internet gateway or NAT**; private VPC endpoints only |
| "Who ran that command?" | Every keystroke **encrypted + recorded** to CloudWatch Logs and S3 |
| Find out next week | **EventBridge โ SNS** notice the moment a session starts |
Access is **tag-scoped (ABAC)**: the permission set can only start sessions on instances tagged `zt-access=engineers`.
## Deploy
```bash
aws cloudformation deploy --stack-name zero-trust \
--template-file template.yaml --capabilities CAPABILITY_IAM \
--parameter-overrides NotificationEmail=you@example.com
# With IAM Identity Center (recommended)
aws cloudformation deploy --stack-name zero-trust \
--template-file template.yaml --capabilities CAPABILITY_IAM \
--parameter-overrides \
IdentityCenterInstanceArn=arn:aws:sso:::instance/ssoins-xxxxxxxx \
IdentityCenterGroupId=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
> If Session Manager preferences were ever saved in the console, add `CreateSessionPreferences=false`.
## Connect
Install the [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html), sign in through Identity Center, then:
```bash
aws ssm start-session --target <InstanceId>
# Reach a private database through the instance - still no open ports
aws ssm start-session --target <InstanceId> \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters host=mydb.cluster-xyz.us-east-1.rds.amazonaws.com,portNumber=5432,localPortNumber=5432
```
## Prove it
- `nmap` the instance: nothing answers (it has no public IP and no inbound rules)
- Open CloudWatch Logs `/zero-trust/session-manager`: full session transcript, tied to the user's identity
- Check your inbox: "Session started at ... by arn:aws:sts::...:assumed-role/AWSReservedSSO_ZeroTrustSessionAccess_.../alice"
## Clean up
```bash
aws cloudformation delete-stack --stack-name zero-trust
```
The session log bucket is versioned; empty it first if deletion fails.
template.yaml ยท 425 lines
AWSTemplateFormatVersion: '2010-09-09'
Description: >-
Zero-trust access: a private VPC with no internet gateway, no NAT, no inbound security
group rules and no SSH keys. Engineers reach instances only through SSM Session Manager
with identity-based, tag-scoped, time-limited access. Every session is encrypted,
recorded to CloudWatch Logs + S3, and announced in real time.
Parameters:
VpcCidr:
Type: String
Default: 10.20.0.0/16
InstanceType:
Type: String
Default: t4g.nano
AmiParameter:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
NotificationEmail:
Type: String
Default: ''
Description: Optional email that is notified whenever a session starts.
IdentityCenterInstanceArn:
Type: String
Default: ''
Description: Optional - arn:aws:sso:::instance/ssoins-xxxx to create a least-privilege permission set.
IdentityCenterGroupId:
Type: String
Default: ''
Description: Optional - Identity Center group ID to assign the permission set to (needs the instance ARN too).
CreateSessionPreferences:
Type: String
AllowedValues: ['true', 'false']
Default: 'true'
Description: >-
Creates the account-wide SSM-SessionManagerRunShell document. Set false if Session Manager
preferences were already saved in the console (the document then already exists).
Conditions:
HasEmail: !Not [!Equals [!Ref NotificationEmail, '']]
HasIdentityCenter: !Not [!Equals [!Ref IdentityCenterInstanceArn, '']]
HasAssignment: !And
- !Condition HasIdentityCenter
- !Not [!Equals [!Ref IdentityCenterGroupId, '']]
CreatePrefs: !Equals [!Ref CreateSessionPreferences, 'true']
Resources:
# ---------------------------------------------------------------- network: no way in, no way out
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsHostnames: true
EnableDnsSupport: true
Tags: [{ Key: Name, Value: zero-trust-vpc }]
PrivateSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 4, 8]]
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: zt-private-a }]
PrivateSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 4, 8]]
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: zt-private-b }]
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags: [{ Key: Name, Value: zt-private-no-internet }]
RouteAssocA:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateSubnetA, RouteTableId: !Ref PrivateRouteTable }
RouteAssocB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateSubnetB, RouteTableId: !Ref PrivateRouteTable }
EndpointSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: HTTPS from inside the VPC to AWS service endpoints
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: !Ref VpcCidr }
SsmEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.ssm
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSg]
SsmMessagesEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSg]
Ec2MessagesEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.ec2messages
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSg]
LogsEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.logs
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSg]
KmsEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.kms
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSg]
S3GatewayEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
VpcEndpointType: Gateway
RouteTableIds: [!Ref PrivateRouteTable]
# ---------------------------------------------------------------- encryption + recording
SessionKey:
Type: AWS::KMS::Key
Properties:
Description: Encrypts Session Manager sessions and session logs
EnableKeyRotation: true
KeyPolicy:
Version: '2012-10-17'
Statement:
- Sid: AccountAdmin
Effect: Allow
Principal: { AWS: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:root' }
Action: 'kms:*'
Resource: '*'
- Sid: EventBridgeToEncryptedSns
Effect: Allow
Principal: { Service: events.amazonaws.com }
Action: [kms:Decrypt, kms:GenerateDataKey]
Resource: '*'
- Sid: CloudWatchLogs
Effect: Allow
Principal: { Service: !Sub 'logs.${AWS::Region}.amazonaws.com' }
Action: [kms:Encrypt*, kms:Decrypt*, kms:ReEncrypt*, kms:GenerateDataKey*, kms:Describe*]
Resource: '*'
Condition:
ArnLike:
kms:EncryptionContext:aws:logs:arn: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/zero-trust/*'
SessionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /zero-trust/session-manager
RetentionInDays: 365
KmsKeyId: !GetAtt SessionKey.Arn
SessionLogBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: aws:kms
KMSMasterKeyID: !GetAtt SessionKey.Arn
BucketKeyEnabled: true
VersioningConfiguration: { Status: Enabled }
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: archive
Status: Enabled
Transitions: [{ StorageClass: GLACIER_IR, TransitionInDays: 30 }]
ExpirationInDays: 730
SessionLogBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SessionLogBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: DenyInsecureTransport
Effect: Deny
Principal: '*'
Action: 's3:*'
Resource: [!GetAtt SessionLogBucket.Arn, !Sub '${SessionLogBucket.Arn}/*']
Condition: { Bool: { 'aws:SecureTransport': 'false' } }
SessionPreferences:
Type: AWS::SSM::Document
Condition: CreatePrefs
Properties:
Name: SSM-SessionManagerRunShell
DocumentType: Session
DocumentFormat: JSON
UpdateMethod: NewVersion
Content:
schemaVersion: '1.0'
description: Zero-trust Session Manager preferences
sessionType: Standard_Stream
inputs:
s3BucketName: !Ref SessionLogBucket
s3KeyPrefix: sessions/
s3EncryptionEnabled: true
cloudWatchLogGroupName: !Ref SessionLogGroup
cloudWatchEncryptionEnabled: true
cloudWatchStreamingEnabled: true
kmsKeyId: !Ref SessionKey
idleSessionTimeout: '15'
maxSessionDuration: '60'
runAsEnabled: false
shellProfile:
linux: 'cd ~ && echo "This session is recorded. Access is logged under your identity."'
# ---------------------------------------------------------------- the protected workload
InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ec2.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
Policies:
- PolicyName: session-logging
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: [kms:Decrypt, kms:GenerateDataKey]
Resource: !GetAtt SessionKey.Arn
- Effect: Allow
Action: [logs:CreateLogStream, logs:PutLogEvents, logs:DescribeLogGroups, logs:DescribeLogStreams]
Resource: '*'
- Effect: Allow
Action: [s3:PutObject]
Resource: !Sub '${SessionLogBucket.Arn}/sessions/*'
- Effect: Allow
Action: [s3:GetEncryptionConfiguration]
Resource: !GetAtt SessionLogBucket.Arn
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles: [!Ref InstanceRole]
WorkloadSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: NO inbound rules - access is only via Session Manager
VpcId: !Ref Vpc
SecurityGroupEgress:
- { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: !Ref VpcCidr, Description: AWS endpoints only }
LaunchTemplate:
Type: AWS::EC2::LaunchTemplate
Properties:
LaunchTemplateData:
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
HttpPutResponseHopLimit: 1
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs: { VolumeSize: 8, VolumeType: gp3, Encrypted: true }
ProtectedInstance:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref AmiParameter
InstanceType: !Ref InstanceType
SubnetId: !Ref PrivateSubnetA
SecurityGroupIds: [!Ref WorkloadSg]
IamInstanceProfile: !Ref InstanceProfile
LaunchTemplate:
LaunchTemplateId: !Ref LaunchTemplate
Version: !GetAtt LaunchTemplate.LatestVersionNumber
Tags:
- { Key: Name, Value: zero-trust-protected }
- { Key: zt-access, Value: engineers }
# ---------------------------------------------------------------- identity: who may connect
EngineerPermissionSet:
Type: AWS::SSO::PermissionSet
Condition: HasIdentityCenter
Properties:
InstanceArn: !Ref IdentityCenterInstanceArn
Name: ZeroTrustSessionAccess
Description: Start Session Manager sessions only on instances tagged zt-access=engineers
SessionDuration: PT1H
InlinePolicy:
Version: '2012-10-17'
Statement:
- Sid: StartSessionOnTaggedInstancesOnly
Effect: Allow
Action: ssm:StartSession
Resource: !Sub 'arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*'
Condition:
StringEquals: { 'ssm:resourceTag/zt-access': engineers }
- Sid: UseApprovedSessionDocuments
Effect: Allow
Action: ssm:StartSession
Resource:
- !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/SSM-SessionManagerRunShell'
- !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSession'
- !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost'
- Sid: ManageOwnSessionsOnly
Effect: Allow
Action: [ssm:TerminateSession, ssm:ResumeSession]
Resource: !Sub 'arn:${AWS::Partition}:ssm:*:*:session/${!aws:userid}-*'
- Sid: DiscoverInstances
Effect: Allow
Action: [ssm:DescribeInstanceInformation, ssm:DescribeSessions, ssm:GetConnectionStatus, ec2:DescribeInstances]
Resource: '*'
- Sid: UseSessionKey
Effect: Allow
Action: kms:GenerateDataKey
Resource: !GetAtt SessionKey.Arn
EngineerAssignment:
Type: AWS::SSO::Assignment
Condition: HasAssignment
Properties:
InstanceArn: !Ref IdentityCenterInstanceArn
PermissionSetArn: !GetAtt EngineerPermissionSet.PermissionSetArn
PrincipalType: GROUP
PrincipalId: !Ref IdentityCenterGroupId
TargetType: AWS_ACCOUNT
TargetId: !Ref AWS::AccountId
# ---------------------------------------------------------------- real-time visibility
AccessTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: Zero-trust session activity
KmsMasterKeyId: !Ref SessionKey
AccessTopicPolicy:
Type: AWS::SNS::TopicPolicy
Properties:
Topics: [!Ref AccessTopic]
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: events.amazonaws.com }
Action: sns:Publish
Resource: !Ref AccessTopic
AccessEmail:
Type: AWS::SNS::Subscription
Condition: HasEmail
Properties:
TopicArn: !Ref AccessTopic
Protocol: email
Endpoint: !Ref NotificationEmail
SessionStartedRule:
Type: AWS::Events::Rule
Properties:
Description: Announce every Session Manager session start
EventPattern:
source: [aws.ssm]
detail-type: [AWS API Call via CloudTrail]
detail:
eventSource: [ssm.amazonaws.com]
eventName: [StartSession]
Targets:
- Id: sns
Arn: !Ref AccessTopic
InputTransformer:
InputPathsMap:
who: $.detail.userIdentity.arn
target: $.detail.requestParameters.target
ip: $.detail.sourceIPAddress
time: $.time
InputTemplate: '"Session started at <time> by <who> on <target> from <ip>"'
Outputs:
InstanceId:
Value: !Ref ProtectedInstance
ConnectCommand:
Value: !Sub aws ssm start-session --target ${ProtectedInstance} --region ${AWS::Region}
SessionLogGroupName:
Value: !Ref SessionLogGroup
SessionLogBucketName:
Value: !Ref SessionLogBucket
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.