06 ยท Zero-Trust Access on AWS: No Bastions, No SSH Keys, No VPN

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

  • 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

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.