AWS (Day 2-3)

the last article you'll read to definitely understand AWS fundamentals

Dev oops

Disclaimers :

  1. Opinions expressed in this post (and in any of all my posts) are solely, unless otherwise specified, those of the authors, me. Those opinions absolutely do not reflect the views, policies, positions of any organizations, employers, affiliated groups.

  2. I don't agree with this, but my employer says I don't have the right to share the source code I've written in the course of my work. I don't ever want a problem. So, I'll try to say as much as I can without divulging any specific information.

  3. I've strived for accuracy throughout this piece, but if you catch any errors, please reach out—I'd be grateful for the feedback and happy to make updates!


Hook

Welcome to the second episode of this series. Already three days in the training. The pace is fast, there are many concepts to master, fatigue and exhaustion are setting in. Today, we'll talk about the security and audit systems that determine whether your cloud infrastructure can be trusted with sensitive data. The previous episode can be found here.


Table of contents

  1. Security and Audit - How AWS enforces permissions and tracks access


Security and Audit

In the previous article, we introduced IAM: users, groups, roles, policies, and the principle of least privilege. That was the "who can do what" overview. Today we go deeper into the how — how policies are actually evaluated, how to avoid hardcoding credentials entirely (even across organizations), how to encrypt things, and how to know exactly what happened in your account at 3am last Tuesday.

IAM Policies: how AWS actually decides

You write a policy, attach it to a user or role, and hope for the best. But what happens when multiple policies apply? When one says Allow and another says Deny? AWS follows a specific evaluation logic:

  1. Default deny — everything is denied unless explicitly allowed
  2. Explicit deny wins — if any policy says Deny, it's denied, period
  3. Allow must be present — the action must be explicitly allowed by at least one policy
  4. Boundaries are checked — SCPs, permission boundaries, and session policies can further restrict

Think of it like this: you need a "yes" from every gatekeeper, and a single "no" from anyone overrides everything.

Types of policies:

Policy TypeAttached ToPurpose
Identity-basedUsers, Groups, RolesGrant permissions to an identity
Resource-basedS3 buckets, SQS queues, etc.Control who can access this resource
Permission boundariesUsers, RolesSet the maximum permissions an identity can have
Service Control PoliciesOrganization accountsGuardrails across entire accounts
Session policiesTemporary sessionsLimit permissions for a specific session

Permission boundaries deserve special attention. They don't grant permissions — they set a ceiling on what an identity can do. Think of it as a safety net: an identity policy can allow an action, but if the permission boundary doesn't include it, the action is blocked.

Example:

  • Permission boundary allows: s3:* and ec2:DescribeInstances (read-only)
  • Identity policy allows: s3:*, ec2:* (full EC2 access)
  • Effective result: The user can do all S3 actions and only read EC2, not modify it

A permission boundary is a maximum limit. The effective permissions are the intersection (overlap) of:

  • What the permission boundary allows
  • What the identity policy allows

This is useful when you want to let developers create their own IAM roles. You set a permission boundary that says "you can have S3 and read-only EC2," and even if a developer accidentally adds a policy that grants ec2:TerminateInstances, that action is blocked by the boundary. The boundary prevents privilege escalation.

A more realistic policy example:

The day 1 article showed a simple S3 read policy. Here's what a real-world policy looks like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSharedDatasets",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::research-datasets",
        "arn:aws:s3:::research-datasets/shared/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "eu-west-3"
        }
      }
    },
    {
      "Sid": "UploadOwnResults",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::research-datasets/results/${aws:username}/*"
    },
    {
      "Sid": "DenyUnencryptedUploads",
      "Effect": "Deny",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::research-datasets/*",
      "Condition": {
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms"
        }
      }
    }
  ]
}

You read it like this (Sid -> Effet -> Action -> Resource -> Condition):

  • "ReadSharedDatasets: Allow s3:GetObject, s3:ListBucket on research-datasets when in eu-west-3"
  • "UploadOwnResults: Allow s3:PutObject on own results datasets owned by current user with username"
  • "DenyUnencryptedUploads: Deny s3:PutObject on research-datasets when there is no KMS encryption"

You understand it like this:

  • "ReadSharedDatasets: Allow reading from the shared research-datasets bucket, but only from eu-west-3"
  • "UploadOwnResults: Allow uploading to your personal results folder"
  • "DenyUnencryptedUploads: Deny uploading unencrypted files to the research-datasets bucket"

Now tet's imagine the following interaction:

A researcher tries to upload an unencrypted file to their results folder:

  • Policy 2 says: "You can upload files" ✅
  • Policy 3 says: "But NOT unencrypted files" ❌
  • Result: Upload is DENIED — because Explicit deny wins (rule #2 from the evaluation logic: Explicit deny wins — if any policy says Deny, it's denied, period)

Notice three things:
  • Conditions restrict where actions can happen (only eu-west-3)
  • Policy variables like ${aws:username} scope access per current user dynamically
  • Explicit Deny blocks everything A researcher can't bypass this, even if they accidentally add a permissive policy.

Why do we mention this?

To reinforce the critical IAM evaluation rule: "A single Deny overrides all Allows". This is one of the most important security principles in AWS.


Creating an IAM Role (practical example):

Let's say you have an EC2 instance running a Python script that needs to read from S3 and write to DynamoDB. Here's how you'd do it properly with the AWS CLI:

Step 1 — Create the trust policy (who can assume this role):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

Reading this in plain English:

"Allow the EC2 service to assume this role (ResearchAnalysisRole)"

Breaking it down:

  • Principal (ec2.amazonaws.com) = WHO can do this → the EC2 service
  • Action (sts:AssumeRole) = WHAT they can do → take on this role's permissions
  • Effect (Allow) = permission granted

Step 2 — Create the role and attach the trust policy:

aws iam create-role \
  --role-name ResearchAnalysisRole \
  --assume-role-policy-document file://trust-policy.json

Key distinction:

  • Trust policy (Step 1): "Who can assume this role?" → Answer: The EC2 service
  • Permissions policy (Step 3): "What can this role do?" → Answer: Read S3, write to DynamoDB

Step 3 — Create and attach the permissions policy:

First, create the permissions-policy.json file with the permissions the role needs:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadFromS3",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::research-data",
        "arn:aws:s3:::research-data/*"
      ]
    },
    {
      "Sid": "WriteToDynamoDB",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:UpdateItem"],
      "Resource": "arn:aws:dynamodb:eu-west-3:123456789012:table/analysis-results"
    }
  ]
}

Now attach this permissions policy to the role:

aws iam put-role-policy \
  --role-name ResearchAnalysisRole \
  --policy-name ResearchAnalysisPermissions \
  --policy-document file://permissions-policy.json

Step 4 — Create an instance profile and attach the role:

aws iam create-instance-profile \
  --instance-profile-name ResearchAnalysisProfile

aws iam add-role-to-instance-profile \
  --instance-profile-name ResearchAnalysisProfile \
  --role-name ResearchAnalysisRole

Now any EC2 instance launched with this profile gets temporary credentials based on the ResearchAnalysisRole's permissions. automatically. No access keys to manage, no secrets to rotate, no .env file to accidentally commit inside the Git repository. Your Python script just calls boto3.client('s3') and it works, the SDK picks up the credentials from the instance metadata.

The complete picture:

                          +------------------------------------------------------------+
                          | IAM Role: ResearchAnalysisRole                             |
                          +------------------------------------------------------------+
                          | Trust Policy:                                              |
                          |   "Allow EC2 service to assume this role"                  |
                          |                                                            |
                          | Permission Policy:                                         |
                          |   - Allow S3: GetObject, ListBucket                        |
                          |   - Allow DynamoDB: PutItem, UpdateItem                    |
                          +------------------------------------------------------------+
                                                 |
                                                 | (attached to)
                                                 |
                          +------------------------------------------------------------+
                          | Instance Profile: ResearchAnalysisProfile                  |
                          | (container that holds the role)                            |
                          +------------------------------------------------------------+
                                                 |
                                                 | (attached to)
                                                 |
                          +------------------------------------------------------------+
                          | EC2 Instance                                               |
                          +------------------------------------------------------------+
                          |                                                            |
                          |  +--------------------------------------------------+      |
                          |  | Python Script (running on instance)              |      |
                          |  |                                                  |      |
                          |  | import boto3                                     |      |
                          |  | s3 = boto3.client('s3')                          |      |
                          |  | db = boto3.client('dynamodb')                    |      |
                          |  +--------------------------------------+-----------+      |
                          |                                         |                  |
                          |  +--------------------------------------+---+              |
                          |  | Instance Metadata Service            |   |              |
                          |  | (provides temporary creds) ----------+   |              |
                          |  |                                      |   |              |
                          |  +--------------------------------------+---+              |
                          |                                         |                  |
                          +-----------------------------------------+------------------+
                                                                    |
                                                                    |
                                   +------------+-------------------+
                                   |            |
                               +---+---+   +----+--------+
                               |  S3   |   |  DynamoDB   |
                               |(read) |   |  (write)    |
                               +-------+   +-------------+
Assume role




OpenID Connect (OIDC)

So far, IAM handles identities inside AWS. But what about the outside world? What if your GitHub Actions pipeline needs to deploy to AWS? What if your researchers log in through your organization's Active Directory?

OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. It allows external identity providers to authenticate users and services, which can then assume AWS roles. No long-lived credentials stored anywhere.

How it works in practice:

                                            Step 1: Authenticate with Identity Provider
                                            +---------------------------+
                                            | GitHub / Google / Azure AD|
                                            | (Identity Provider)       |
                                            +---------------------------+
                                                       ^
                                                       |
                                                       | "Login with GitHub"
                                                       |
                                                    +--+--+
                                                    |User |
                                                    +--+--+
                                                       |
                                                       | Step 2: Receive JWT Token
                                                       | (cryptographic proof of identity)
                                                       v
                                            +---------------------------+
                                            | JWT Token                 |
                                            | Contains:                 |
                                            | - Repository name         |
                                            | - Branch                  |
                                            | - Commit SHA              |
                                            | - Timestamp               |
                                            +---------------------------+
                                                       |
                                                       | Step 3: Send token to AWS STS
                                                       v
                                            +---------------------------+
                                            | AWS STS                   |
                                            | (Security Token Service)  |
                                            +---------------------------+
                                                       |
                                                       | Step 4: STS checks trust policy
                                                       | "Is this token from the repo
                                                       |  I trust? YES -> OK"
                                                       |
                                                       v
                                            +---------------------------+
                                            | Temporary AWS Credentials |
                                            | - Access Key ID           |
                                            | - Secret Access Key       |
                                            | - Session Token           |
                                            | - Expires in 1 hour       |
                                            +---------------------------+

The flow:

  1. User/Service authenticates with an external identity provider (GitHub, Google, Azure AD, corporate LDAP)
  2. Provider issues a JSON Web Token, a cryptographic proof containing metadata (repository, branch, user identity, etc.)
  3. Token is sent to AWS STS (Security Token Service), STS examines it
  4. STS checks the trust policy — "Is this token from a trusted source?" If yes, temporary credentials are issued
  5. Credentials expire automatically — typically 1 hour, so no manual cleanup needed

Real-world example — GitHub Actions deploying to AWS:

Instead of storing AWS access keys as GitHub secrets, you configure an OIDC identity provider in AWS:

# .github/workflows/deploy.yml
jobs:
  deploy:
    permissions:
      id-token: write   # Required for OIDC
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployRole
          aws-region: eu-west-3
      - run: aws s3 sync ./build s3://my-app-bucket

Security: What if someone forks your repo?

If someone forks your repo, they can't assume your role. Here's why:

  1. The Github OIDC token includes metadata: repository name, branch, commit SHA, etc.
  2. Your trust policy is specific: it explicitly checks: "Only allow tokens from organization/original-repo-name"
  3. The fork has different metadata: when the fork's workflow runs, the token says fork-owner/forked-repo
  4. The policy rejects it: AWS compares: fork-owner/forked-repoorganization/original-repo-name → Denied
  5. Result: No credentials, no access — Even if the attacker has your exact workflow file, the OIDC token won't match the trust policy

Why this matters for a biomedical research center:

Researchers often come from partner institutions, universities, or international organizations. Instead of creating and managing IAM users for each collaborator, you can federate identity through their home institution's identity provider. They log in with their university credentials, get temporary AWS access scoped to exactly what they need, and when their collaboration ends, you revoke nothing — their access simply stops when the trust relationship is removed.

Security check




KMS and ACM: encryption and certificates

AWS KMS (Key Management Service)

Manages encryption keys for your data. If you're storing patient data, genomic sequences, or any sensitive research data, encryption isn't optional — it's a compliance requirement (HIPAA, GDPR, etc.).

Key concepts:

ConceptWhat it does
Customer Master Key (CMK)The top-level key. Never leaves AWS. You control who can use it. IAM policies define access.
Data keyGenerated by KMS, used to actually encrypt your data. You use it locally, then discard it.
Key policiesIAM-like policies controlling who can use or manage the key. Auditable via CloudTrail.
Automatic rotationAWS rotates key material annually. Old data remains decryptable. Compliance requirement.

How envelope encryption works:

Step 1: Request data key from KMS
Application --> KMS: "Give me a data key"

Step 2: KMS returns both plaintext and encrypted key
KMS --> Application:
  - Plaintext key (256-bit AES key)
  - Encrypted key (encrypted with CMK)

Step 3: Encrypt data locally with plaintext key
Application:
  - Encrypts patient data with plaintext key
  - Discards plaintext key from memory

Step 4: Store encrypted data + encrypted key
Database stores:
  - Encrypted patient data (gigabytes)
  - Encrypted data key (tiny, ~200 bytes)

Step 5: To decrypt later
Application: "KMS, decrypt this key for me"
KMS: Decrypts it, returns plaintext key
Application: Uses plaintext key to decrypt patient data

Why this design? Sending gigabytes of sensitive data to KMS would be slow, expensive, and create a bottleneck. Instead, KMS only handles small cryptographic keys, and the heavy encryption happens locally in your application. Best of both worlds: security + performance.

Practical example — patient records:

1. Patient uploads medical scan (500 MB)
2. You request a data key from KMS (costs ~$0.03)
3. Application encrypts scan with plaintext key
4. Plaintext key is immediately discarded
5. Database stores:
   - Encrypted scan (500 MB, unreadable)
   - Encrypted data key (200 bytes, small)
6. The CMK stays locked in KMS (never exported, never leaves AWS)
7. To decrypt: send encrypted key to KMS, get plaintext key
8. Audit trail in CloudTrail: "Who decrypted what and when"

AWS-managed vs. Customer-managed keys:

TypeCostControlUse when
AWS-managedFreeLimited (AWS rotates annually)Most AWS services (S3, RDS, DynamoDB by default)
Customer-managed~$1/month + API callsFull (you choose rotation, who can access, audit trail)Compliance-heavy (HIPAA, GDPR, biomedical data)

For biomedical research with patient data, customer-managed keys are typically required because auditors need to see exactly who accessed the encryption keys and when (CloudTrail).


AWS ACM (Certificate Manager)

Handles TLS/SSL certificates for your domains. If you've wrestled with Let's Encrypt renewals, ACM is the managed equivalent.

Public certificates (Free):

  • Domain validation: ACM verifies you own the domain
  • Use with: ALB, CloudFront, API Gateway
  • Automatic renewal: No more "certificate expired at 3am" alerts
  • Wildcard support: *.example.com covers all subdomains

Private CA (Paid):

  • For internal services that need TLS but aren't exposed to the internet
  • Example: Your research database server communicates with Python scripts using mutual TLS
  • Cost: ~$400/month for the CA, plus per-certificate fees
  • Use when: Internal services need encrypted communication, but you control both client and server

The tradeoff:

  • ACM public certificates: Free, automatic, but only work with AWS services
  • Let's Encrypt: Free, but you manage renewal automation yourself
  • ACM private CA: Paid, but manages all internal TLS automatically

For biomedical research centers: use free public certificates for your website (ACM + ALB), and use HTTP/2 between internal services (encryption handled by VPC itself, no need for certificates).


Speak louder for those at the back of the room




CloudTrail: who did what, and when

AWS CloudTrail records every API call made in your AWS account. Every. Single. One. Someone launched an EC2 instance? Logged. Someone changed a security group? Logged. Someone tried to access an S3 bucket and was denied? Logged.

What CloudTrail captures:

FieldExample
Whoarn:aws:iam::123456789012:user/jdoe
WhatRunInstances, PutBucketPolicy, DeleteObject
When2026-02-04T14:32:17Z
Whereeu-west-3, source IP 203.0.113.42
HowConsole, CLI, SDK, or assumed role
ResultSuccess or failure (with error code)

Event types:

  • Management events: Control plane operations (creating resources, changing configurations). Logged by default.
  • Data events: Data plane operations (reading S3 objects, invoking Lambda functions). Not logged by default — you must explicitly enable them. They're high-volume and cost money.
  • Insights events: CloudTrail analyzes your patterns and flags unusual activity (sudden spike in API calls, unusual error rates).

Practical example — investigating a security incident:

A researcher reports they can't access their S3 bucket anymore. You suspect someone changed the bucket policy. With CloudTrail and CloudWatch Logs Insights:

fields @timestamp, userIdentity.arn, eventName, requestParameters.bucketName
| filter eventSource = "s3.amazonaws.com"
| filter eventName like /PutBucket/
| filter requestParameters.bucketName = "research-datasets"
| sort @timestamp desc
| limit 20

This query shows you exactly who modified the bucket policy, when, and from where. No more guessing, no more "it wasn't me." The audit trail is immutable.

Best practices:

  1. Enable CloudTrail in all regions — attacks often target regions you're not watching
  2. Send logs to a centralized S3 bucket in a dedicated security account — so attackers can't delete their tracks even if they compromise a workload account
  3. Enable log file integrity validation — CloudTrail can detect if someone tampered with log files
  4. Set up CloudWatch alarms for critical events: root account login, IAM policy changes, security group modifications, console sign-in failures

The traditional infrastructure equivalent:

CloudTrail FeatureTraditional Equivalent
Management eventsauditd on Linux, syslog for config changes
Data eventsApplication-level access logs (Apache, Nginx logs)
InsightsCustom anomaly detection scripts, SIEM correlation rules
Log integrityAIDE, Tripwire, signed log shipping
Multi-region loggingCentralized syslog server, rsyslog forwarding

In the biomedical research context:

CloudTrail is required for compliance. HIPAA mandates audit trails for access to protected health information. GDPR requires you to demonstrate who accessed personal data and why. CloudTrail, combined with AWS Config (which tracks resource configuration changes over time), gives you a complete audit story.




Equivalence in the Linux world

The concepts we've covered aren't AWS innovations. They're fundamental security patterns that exist in every operating system. Here's how AWS services map to traditional Linux/Unix equivalents:

AWS ServiceLinux/Unix EquivalentPurpose
IAM Policies/etc/sudoers, file permissions (rwx), RBACDefine who can do what
Permission BoundariesAppArmor, SELinux policiesSet maximum permissions ceiling
OIDC (Federated Identity)LDAP, Kerberos, PAM modulesExternal identity provider authentication
Trust Policy/etc/sudoers, PAM rulesDefine who can assume a role/user
KMS (Encryption Keys)GPG, OpenSSL, PKCS#11, HSMManage encryption keys centrally
ACM (Certificates)Let's Encrypt + certbot, OpenSSLManage TLS/SSL certificates
CloudTrail (Audit)auditd, syslog, journalctlImmutable audit logs of system activity

Why AWS then? These equivalents work great on Linux, but AWS eliminates the plumbing work, automates, standardizes, and integrates these patterns across hundreds of services. AWS provides enterprise-grade reliability.



More on this topic

If Day 1 was about "where and what," Day 2-3 has been about "how and who." Security and audit should not be something you think about after. They're the foundation of any production infrastructure, especially even more for people handling sensitive data like what we do with 4S.

The patterns here generalize beyond AWS: whether you're running on-premises or on another cloud, the principles remain the same:

  • Default deny, explicit allow: the principle of least privilege applied everywhere
  • Separate the identity layer: authentication (who you are) from authorization (what you can do)
  • **Keep an immutable audit trail: you can't defend what you can't prove
  • Encrypt by default: both in transit and at rest
  • Use temporary credentials: no long-lived secrets scattered across your infrastructure

I don't think I have fully understood and mastered everything. I don't think I can explore everything in a single article. If you want to dive deeper into AWS security and audit, I strongly recommend the following links: