When we closed module 3 we left an uncomfortable list on the table. MercadoFresco is on the internet, serves from the edge, scales on its own and survives the loss of an Availability Zone; but the user mercadofresco-admin can do literally anything in account 111122223333, the roles rol-mercadofresco-tienda and rol-lambda-miniaturas were created on the fly just to get things working, and nobody has yet written a single policy thinking about what the minimum is that each piece actually needs.

That is not a pending detail. It is the layer everything else depends on: if the identities are wrong, encryption is worth nothing, because whoever can call the API can read the data already decrypted. That is why IAM is the first lesson of the module and the longest.

AWS Identity and Access Management (IAM) is the service that answers, on each of the billions of calls the AWS API receives every second, two questions: who are you? and am I letting you do this?. In this lesson Marta turns those two questions into concrete JSON policies for MercadoFresco.

Warning. The examples in this lesson are teaching material and simplified so that they can be understood. Any configuration of identities, permissions or compliance that is going to be applied to real customer data —and all the more so if it falls within the scope of the GDPR or PCI DSS— must be reviewed by a security or compliance professional before reaching production. Every identifier, account and piece of data in this course is fictitious.

Contents

  1. Authentication and authorisation: two different questions
  2. The vocabulary of IAM
  3. Anatomy of an ARN
  4. Users, groups and the problem with access keys
  5. Roles: identities nobody owns
  6. sts:AssumeRole and the trust relationship
  7. The six types of policy
  8. Anatomy of a JSON policy
  9. Wildcards, policy variables and conditions
  10. Policy evaluation logic
  11. Formalising rol-mercadofresco-tienda
  12. Formalising rol-lambda-miniaturas
  13. Instance profiles
  14. Least privilege in practice: reading the AccessDenied
  15. IAM Access Analyzer and policy generation
  16. The people of MercadoFresco: users and groups
  17. Mandatory MFA by condition
  18. Key rotation and the credential report
  19. IAM Identity Center and federation
  20. Diagnostic tools
  21. Cost and clean-up

Authentication and authorisation: two different questions

They are constantly confused and they are separate mechanisms:

Authentication Authorisation
Question Who are you? Can you do this?
Mechanism Cryptographic signature of the request (SigV4), password + MFA in the console Policy evaluation
Result An identified principal Allow or Deny
Where it fails InvalidClientTokenId, SignatureDoesNotMatch AccessDenied

When Luis runs aws s3 ls with the mercadofresco-dev profile, the CLI signs the request with his secret key. AWS recalculates the signature; if it matches, it knows who Luis is (authentication). Only then does it gather the applicable policies and decide if he may list buckets (authorisation).

Telling them apart saves hours of debugging: if the error is AccessDenied, the credentials are right and the problem is in a policy. If the error is a signature error, the permissions have not even been looked at yet.

The vocabulary of IAM

These seven terms appear throughout the AWS documentation and it is worth pinning them down now.

Term What it is Example in MercadoFresco
Principal Whoever makes the request Luis, rol-mercadofresco-tienda, the service s3.amazonaws.com
Identity IAM object that policies can be attached to User luis, group mercadofresco-desarrollo, role rol-lambda-miniaturas
Entity Identity AWS can authenticate A user or a role (a group is not an entity: nobody signs in as a group)
Resource The object being acted on The bucket mercadofresco-catalogo-fotos, the instance mercadofresco-tienda-01
Action An API operation s3:GetObject, ec2:TerminateInstances, kms:Decrypt
Policy JSON document that grants or denies The ones we write three sections from here
Session Temporary credentials resulting from assuming a role The session the instance gets from the metadata service

The distinction between identity and entity looks like pedantry and is not: it explains why you cannot "sign in as the analytics group", and why a group cannot appear as a Principal in a bucket policy. Groups are nothing more than a container of permissions for users.

Anatomy of an ARN

The Amazon Resource Name is the unique, global identifier of any AWS resource. It appears in every policy, so you have to be able to read it character by character:

arn:partition:service:region:account-id:resource-type/resource-id
Field What it means Typical values
arn Fixed prefix Always arn
partition AWS partition aws (global), aws-cn (China), aws-us-gov (GovCloud)
service Service namespace s3, ec2, iam, kms, lambda
region Region eu-west-1; empty in global services (IAM, S3, Route 53)
account-id 12-digit account 111122223333; empty in S3
resource Type and identifier Separated by /, : or nothing, depending on the service

Real examples from MercadoFresco, each with its own quirk:

arn:aws:s3:::mercadofresco-catalogo-fotos
arn:aws:s3:::mercadofresco-catalogo-fotos/productos/tomate-rama.jpg
arn:aws:iam::111122223333:user/luis
arn:aws:iam::111122223333:role/rol-mercadofresco-tienda
arn:aws:iam::aws:policy/ReadOnlyAccess
arn:aws:ec2:eu-west-1:111122223333:instance/i-0abc123def4567890
arn:aws:rds:eu-west-1:111122223333:db:mercadofresco-pedidos
arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab
arn:aws:kms:eu-west-1:111122223333:alias/mercadofresco-datos
arn:aws:lambda:eu-west-1:111122223333:function:mercadofresco-generar-miniaturas
arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas
arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/alb-mercadofresco-tienda/50dc6c495c0c9188

Four observations that prevent frequent mistakes:

  • S3 carries neither region nor account (arn:aws:s3:::, three colons in a row). Bucket names are globally unique, so they are not needed. Writing arn:aws:s3:eu-west-1:111122223333:mercadofresco-catalogo-fotos is a classic mistake and the policy will simply never match.
  • The bucket and the objects are different resources. arn:aws:s3:::mercadofresco-catalogo-fotos is for s3:ListBucket; arn:aws:s3:::mercadofresco-catalogo-fotos/* is for s3:GetObject. Confusing them produces the most common AccessDenied in the world.
  • IAM is global: empty region. And the AWS managed policies use aws in the account field: arn:aws:iam::aws:policy/ReadOnlyAccess.
  • RDS uses : as the separator (:db:mercadofresco-pedidos), not /. Each service picks its own; there is no general rule. Check the documentation or copy the ARN from the console.

Users, groups and the problem with access keys

An IAM user is a permanent identity with long-lived credentials: a password for the console and, optionally, a pair of access keys (AKIA... + secret key) for the API.

And there is the problem. An access key:

  • Never expires on its own.
  • Is a plaintext secret that somebody has to store somewhere.
  • If it leaks into a Git repository, a support ticket or the wrong clipboard, whoever holds it is that user, with no further checks.

The bots that crawl GitHub find leaked AWS keys within minutes and launch instances to mine cryptocurrency. The bill arrives before the warning does.

Hence the rule that governs the rest of the lesson:

Access keys are the last resort, not the first. An EC2 instance, a Lambda, a container or a pipeline must never carry access keys. That is what roles are for.

A group is a collection of users who share permissions. It has no credentials, cannot be assumed and cannot be nested inside another group. Its only job is that when somebody new joins Luis's team nobody has to remember which seven policies to attach to them.

Roles: identities nobody owns

An IAM role is an identity with permission policies but without permanent credentials. Nobody "is" a role: entities assume it temporarily and receive credentials that expire.

flowchart LR
    A["Instance<br/>mercadofresco-tienda-01"] -->|"1 asks the metadata<br/>service for credentials"| B["IMDSv2<br/>169.254.169.254"]
    B -->|"2 sts:AssumeRole"| C["rol-mercadofresco-tienda"]
    C -->|"3 temporary credentials<br/>expire in ~6 h"| A
    A -->|"4 signed call"| D["S3<br/>mercadofresco-catalogo-fotos"]

The important part of the diagram is step 3: the credentials expire and renew themselves before they do. If somebody steals them, they have a window of minutes or hours, not years. And there is nothing to rotate, nothing to store and nothing that can leak into Git, because it is in no file.

Roles are used in four scenarios:

Scenario Who assumes it Example
Service role An AWS service EC2, Lambda, ECS
Cross-account role A user or role from another account The production account and the development one (module 9)
Federated role A user authenticated by an external provider Google Workspace, Entra ID, IAM Identity Center
Changing hats A user from the same account Marta assumes rol-mercadofresco-emergencia only when she needs it

sts:AssumeRole and the trust relationship

Every role has two policies, and confusing them is the most frequent conceptual mistake in IAM:

Policy Technical name Answers How many
Trust Trust policy / AssumeRolePolicyDocument Who can assume this role? Exactly 1
Permissions Attached policies What can whoever assumes it do? Up to 10 managed + inline

The trust policy is the only resource-based policy where the resource is the role itself. This is the one for rol-mercadofresco-tienda, which only the EC2 service can assume:

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

Line by line:

  • Principal.Service: the one assuming it is not a person but the EC2 service. When you launch an instance with this role, EC2 will call sts:AssumeRole on your behalf.
  • Action: sts:AssumeRole: the special action that issues temporary credentials. There are variants: sts:AssumeRoleWithWebIdentity (Cognito, OIDC) and sts:AssumeRoleWithSAML (enterprise federation).
  • There is no Resource: the resource is implicitly the role the policy belongs to.

For cross-account roles there is one indispensable condition, the ExternalId, which prevents the confused deputy attack (a third party who knows your role's ARN and gets somebody to assume it on their behalf):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::444455556666:root" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": { "sts:ExternalId": "mercadofresco-proveedor-logistica-2026" },
        "Bool": { "aws:MultiFactorAuthPresent": "true" }
      }
    }
  ]
}

"AWS": "arn:aws:iam::444455556666:root" does not mean the root user of that account: it means "account 444455556666 may delegate to whoever it likes". That is the usual way of writing it, and the target account must also grant sts:AssumeRole to its own users. Both sides have to agree: without that there is no cross-account access.

The six types of policy

Type Attached to Effect Use in MercadoFresco
Identity-based User, group, role Grants permissions to the principal Most of the ones we will write
Resource-based Bucket, KMS key, queue, secret, role Grants access to the resource, naming a Principal Bucket policy, KMS key policy
Permissions boundary User or role Maximum ceiling: grants nothing, only limits The ceiling for the roles Luis creates
SCP Organizations account or OU Ceiling for the whole account Covered in detail in 09-04
Session policy Passed when assuming a role Reduces the permissions of that particular session Scoped pipeline sessions (module 8)
ACL S3 bucket (legacy) Old mechanism, predating IAM Do not use; disabled by default since 2023

The first two are the ones used daily, and there is a decisive difference between them:

  • An identity-based policy says: "Luis can read that bucket". It lives with Luis.
  • A resource-based policy says: "that bucket lets Luis read it". It lives with the bucket, and it is also the only way to grant access from another account without roles.

And a practical consequence: when the principal and the resource are in the same account, it is enough for one of the two to grant the permission. When they are in different accounts, you need both.

The AWS managed policies (AmazonS3ReadOnlyAccess, AdministratorAccess...) are convenient to start with and almost always too broad. AmazonS3ReadOnlyAccess grants read access over every bucket in the account, including mercadofresco-copias-basedatos. They are fine for prototyping; not for production.

Anatomy of a JSON policy

Every policy has the same structure. This is the complete template, with every possible element in it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "IdentificadorLegibleOpcional",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:role/rol-mercadofresco-tienda" },
      "Action": ["s3:GetObject", "s3:PutObject"],
      "NotAction": [],
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/*",
      "NotResource": [],
      "Condition": {
        "StringEquals": { "aws:PrincipalTag/Proyecto": "mercadofresco" }
      }
    }
  ]
}
Element Mandatory What it does
Version Yes Always 2012-10-17. It is not your policy's date: it is the version of the language. Without it, policy variables do not work
Statement Yes List of statements. All of them are evaluated; it does not stop at the first
Sid No Readable identifier. Useful for debugging and required to be unique in resource-based policies
Effect Yes Allow or Deny
Principal Only in resource-based policies Who. In identity-based policies it is forbidden (the principal is the owner)
Action Yes service:Operation. Accepts wildcards
Resource Yes (except in trust policies) Over which ARN
Condition No When it applies

NotAction and NotResource mean "everything except". They are dangerous: "NotAction": "s3:*" with "Effect": "Allow" grants everything else in AWS. Use them almost exclusively with Deny.

Wildcards, policy variables and conditions

Wildcards. * stands for any sequence of characters, ? for a single character:

Pattern Matches
s3:Get* s3:GetObject, s3:GetBucketPolicy, s3:GetObjectAcl...
s3:* Every S3 action
* Every AWS action (this is AdministratorAccess)
arn:aws:s3:::mercadofresco-* Every MercadoFresco bucket
arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/* Only the objects under that prefix

Careful with s3:Get*: it includes s3:GetBucketPolicy, which allows reading the bucket policy, and s3:GetObjectVersion, which allows reading old versions even of "deleted" objects.

Policy variables. They are substituted at evaluation time:

Variable Value
${aws:username} Name of the IAM user
${aws:userid} Unique identifier of the principal
${aws:PrincipalTag/Clave} Tag on the principal
${s3:prefix} Prefix requested in an S3 operation

With this, a single policy gives every person their own private folder in the development bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CadaUnoEnSuCarpeta",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::mercadofresco-tienda-web-desarrollo/personal/${aws:username}/*"
    }
  ]
}

Attached to the group mercadofresco-desarrollo, Luis can only touch personal/luis/, and whoever joins tomorrow only personal/<their-name>/, without writing a single extra policy.

Useful conditions. The Condition block has the form { "Operator": { "ConditionKey": "value" } }:

Key Typical operator What for
aws:SecureTransport Bool Require HTTPS: "Bool": {"aws:SecureTransport": "false"} with Deny
aws:SourceIp IpAddress Restrict to the office IP 192.168.10.0/24 (does not work if the request goes through a VPC endpoint)
aws:RequestedRegion StringEquals Confine activity to eu-west-1
aws:PrincipalTag/Clave StringEquals Attribute-based access control (ABAC)
aws:MultiFactorAuthPresent Bool Require MFA
aws:CurrentTime DateGreaterThan Temporary access with an expiry
s3:x-amz-server-side-encryption StringEquals Force every object to be uploaded encrypted
kms:ViaService StringEquals Let the key be used only through a specific service (04-02)

An important warning about aws:SourceIp: if the request travels through a VPC endpoint —such as vpce-mercadofresco-s3, which we set up in 03-01— the source IP is private and the condition fails. For that case the right key is aws:SourceVpce.

Another warning: aws:MultiFactorAuthPresent is false, not absent, in a service's role sessions. If you apply that condition to an EC2 role, you break it.

Policy evaluation logic

This is the heart of IAM and deserves memorising. AWS gathers every applicable policy and applies three rules in this order:

  1. Everything is denied by default (implicit deny).
  2. An explicit Allow in any applicable policy permits it.
  3. An explicit Deny in any policy always wins, without exception.

Put another way: explicit deny > explicit allow > implicit deny.

flowchart TD
    A["Authenticated request"] --> B{"Is there an explicit Deny<br/>in ANY policy?"}
    B -->|"Yes"| Z["DENIED"]
    B -->|"No"| C{"Does the Organizations<br/>SCP allow it?"}
    C -->|"No"| Z
    C -->|"Yes or N/A"| D{"Does the resource-based<br/>policy allow it?"}
    D -->|"Yes, with an explicit Principal"| Y["ALLOWED"]
    D -->|"Does not grant it"| E{"Is it within the<br/>permissions boundary?"}
    E -->|"No"| Z
    E -->|"Yes or N/A"| F{"Does the session<br/>policy allow it?"}
    F -->|"No"| Z
    F -->|"Yes or N/A"| G{"Does any identity-based<br/>policy allow it?"}
    G -->|"Yes"| Y
    G -->|"No"| Z

Practical consequences to internalise:

  • There is no "priority" between policies. There are no rule numbers as in the NACLs of 03-02. A single Deny lost in a forgotten policy blocks everything, and finding it is detective work.
  • The order of the statements inside a policy is irrelevant. All of them are evaluated.
  • Not even the root user escapes a Deny in an SCP. That is exactly why SCPs are the governance tool of 09-04.
  • Permissions boundary and identity policy intersect: the effective permission is the intersection of both. If the boundary allows only S3 and the policy grants S3 and EC2, you get S3.

And the asymmetry between the same account and different accounts, summarised:

Situation What is needed
Principal and resource in the same account It is enough for one of the two policies to grant it (identity or resource)
Different accounts You need both: the identity one in the source account and the resource one in the target
Exceptions (KMS, IAM) The resource policy must always grant it, even within the same account

That last row is the number one source of surprises in 04-02: if the KMS key policy does not name you, you cannot use the key even if you are the account administrator.

Formalising rol-mercadofresco-tienda

Until now the instance mercadofresco-tienda-01 has been running on generic permissions. We are going to write the policy it needs exactly, not one permission more. The shop has to:

  1. Read the photos in mercadofresco-catalogo-fotos/productos/.
  2. Write the generated thumbnails into miniaturas/.
  3. Publish business metrics (orders per hour) to CloudWatch.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LeerFotosDeProducto",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": [
        "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/*",
        "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/*"
      ]
    },
    {
      "Sid": "ListarSoloLosPrefijosNecesarios",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["productos/*", "miniaturas/*"]
        }
      }
    },
    {
      "Sid": "EscribirMiniaturasCifradas",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms"
        }
      }
    },
    {
      "Sid": "PublicarMetricasDeNegocio",
      "Effect": "Allow",
      "Action": "cloudwatch:PutMetricData",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "cloudwatch:namespace": "MercadoFresco/Tienda"
        }
      }
    },
    {
      "Sid": "ProhibirTraficoSinCifrar",
      "Effect": "Deny",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::mercadofresco-catalogo-fotos",
        "arn:aws:s3:::mercadofresco-catalogo-fotos/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

Statement by statement:

  • LeerFotosDeProducto: only s3:GetObject, and only under two prefixes. The shop cannot read anything outside productos/ and miniaturas/, not even inside the same bucket. Nor can it delete anything or read old versions.
  • ListarSoloLosPrefijosNecesarios: s3:ListBucket acts on the bucket, not on the objects, which is why the ARN has no /*. The s3:prefix condition stops anyone listing the whole bucket and discovering what other prefixes exist. It is a subtle but real information leak.
  • EscribirMiniaturasCifradas: s3:PutObject limited to miniaturas/, and requiring the upload to include the KMS encryption header. If the code forgets to encrypt, the upload is rejected. This connects directly with alias/mercadofresco-datos in 04-02.
  • PublicarMetricasDeNegocio: cloudwatch:PutMetricData takes no resource ARN, so you have to put "Resource": "*". But it is scoped with cloudwatch:namespace, so the shop can only write into its own namespace and cannot fake another component's metrics.
  • ProhibirTraficoSinCifrar: a reinforcing Deny. Remember the rule: a Deny always wins, even against the Allows above. If for whatever reason a call were made over HTTP without TLS, it is rejected.

Notice what is not there: no s3:DeleteObject, no s3:*, no access to mercadofresco-copias-basedatos, none to mercadofresco-informes-analitica, no ec2:*. If tomorrow somebody manages to run code on the instance, this is all they get.

Creation from the CLI:

# 1. Trust policy (who can assume the role)
cat > /tmp/confianza-ec2.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ec2.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}
JSON

aws iam create-role \
  --role-name rol-mercadofresco-tienda \
  --assume-role-policy-document file:///tmp/confianza-ec2.json \
  --description "Role for the MercadoFresco shop instances" \
  --max-session-duration 3600 \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=tienda Key=Propietario,Value=marta \
         Key=CentroCoste,Value=tecnologia \
  --profile mercadofresco-dev

# 2. Permissions policy (what it can do)
aws iam create-policy \
  --policy-name pol-mercadofresco-tienda \
  --policy-document file:///tmp/pol-tienda.json \
  --description "Minimum shop permissions: read catalogue, write thumbnails, metrics" \
  --profile mercadofresco-dev

# 3. Join the two together
aws iam attach-role-policy \
  --role-name rol-mercadofresco-tienda \
  --policy-arn arn:aws:iam::111122223333:policy/pol-mercadofresco-tienda \
  --profile mercadofresco-dev

--max-session-duration 3600 limits sessions to one hour. For EC2 roles the metadata service renews them automatically, so lowering it breaks nothing and shortens the window available after a credential theft.

Formalising rol-lambda-miniaturas

The function mercadofresco-generar-miniaturas from 02-05 fires when an object appears in productos/, generates the thumbnail and writes it into miniaturas/. Its needs are similar but not identical, and it also has to write its logs and send failures to the queue.

Trust policy —only the Principal changes:

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

Permissions policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EscribirRegistrosDeLaPropiaFuncion",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/lambda/mercadofresco-generar-miniaturas:*"
    },
    {
      "Sid": "LeerElOriginal",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/*"
    },
    {
      "Sid": "EscribirLaMiniatura",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/*"
    },
    {
      "Sid": "UsarLaClaveDelBucket",
      "Effect": "Allow",
      "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
      "Resource": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab",
      "Condition": {
        "StringEquals": { "kms:ViaService": "s3.eu-west-1.amazonaws.com" }
      }
    },
    {
      "Sid": "EnviarLosFallosALaCola",
      "Effect": "Allow",
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas"
    }
  ]
}

Four details that matter:

  • logs:CreateLogGroup is not there. The group is created once, by hand or with infrastructure as code, and the function only needs to write. That is one permission fewer.
  • The log group ARN ends in :*: that is the CloudWatch Logs syntax for including the streams. Without that suffix the function writes nothing and the failure is silent.
  • kms:ViaService limits use of the key to requests that arrive through S3. If somebody steals the function's credentials, they cannot call kms:Decrypt directly to decrypt something else. It is one of the most useful conditions in all of IAM and we develop it in 04-02.
  • The key is named by its key ARN, not by the alias. Identity-based policies accept aliases in some contexts, but the ARN is unambiguous and does not change if somebody reassigns the alias.

With the role assigned, the function's code holds not a single credential. boto3.client("s3") finds the temporary credentials on its own through the environment variables Lambda injects. That is the whole trick.

Instance profiles

Here is a piece almost nobody understands until it fails on them: an EC2 instance cannot receive a role directly. It needs an instance profile, which is a container with exactly one role inside it.

flowchart LR
    A["mercadofresco-tienda-01"] --> B["Instance profile<br/>rol-mercadofresco-tienda"]
    B --> C["IAM role<br/>rol-mercadofresco-tienda"]
    C --> D["Policy<br/>pol-mercadofresco-tienda"]

When you create the role from the console choosing the "EC2" use case, AWS creates the instance profile with the same name automatically and you never notice. When you create it from the CLI, it does not. You have to do it by hand:

aws iam create-instance-profile \
  --instance-profile-name rol-mercadofresco-tienda \
  --profile mercadofresco-dev

aws iam add-role-to-instance-profile \
  --instance-profile-name rol-mercadofresco-tienda \
  --role-name rol-mercadofresco-tienda \
  --profile mercadofresco-dev

# Attach it to the launch template (new version)
aws ec2 create-launch-template-version \
  --launch-template-name lt-mercadofresco-tienda \
  --source-version '$Latest' \
  --launch-template-data '{"IamInstanceProfile":{"Name":"rol-mercadofresco-tienda"}}' \
  --profile mercadofresco-dev

If you try to launch an instance with a role that has no profile, the error is Value (rol-mercadofresco-tienda) for parameter iamInstanceProfile.name is invalid, which says absolutely nothing about what is going on. Now you know.

And a check from inside the instance, using IMDSv2 as we saw in 02-01:

TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

# Returns: rol-mercadofresco-tienda
# And with that name you get the temporary credentials and their expiry date:
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/rol-mercadofresco-tienda

Least privilege in practice: reading the AccessDenied

The principle of least privilege says that every identity should have the minimum permissions needed for its job, and nothing more. Everyone agrees and almost nobody applies it, because guessing in advance which permissions are needed is impossible.

The method that does work is the opposite one:

flowchart TD
    A["Start with NO permissions"] --> B["Run the real use case"]
    B --> C{"AccessDenied?"}
    C -->|"Yes"| D["Read the message:<br/>exact action + resource"]
    D --> E["Add ONLY that permission"]
    E --> B
    C -->|"No"| F["Repeat with the less<br/>frequent cases"]
    F --> G["Real minimum policy"]

The key is that AWS error messages are extraordinarily precise:

An error occurred (AccessDenied) when calling the PutObject operation:
User: arn:aws:sts::111122223333:assumed-role/rol-mercadofresco-tienda/i-0abc123def4567890
is not authorized to perform: s3:PutObject
on resource: "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/tomate-rama.jpg"
because no identity-based policy allows the s3:PutObject action

That message gives you the four facts you need: the exact principal, the exact action, the exact resource and why it failed. That last sentence is gold:

Final phrase of the error What it means
because no identity-based policy allows Implicit deny: an Allow is missing. Add it
with an explicit deny in an identity-based policy There is a Deny in a policy attached to the principal
with an explicit deny in a resource-based policy The Deny is in the bucket, queue or key policy
with an explicit deny in a service control policy An Organizations SCP is blocking it (09-04)
with an explicit deny in a permissions boundary The permissions boundary does not include it

Look at the principal's ARN too: arn:aws:sts::111122223333:assumed-role/rol-.../i-0abc.... That is what an assumed-role session looks like: it starts with sts, not iam, and the last segment is the session name, which for EC2 is the instance identifier. Seeing it like that immediately confirms that the instance is using the role and not some forgotten keys.

IAM Access Analyzer and policy generation

There are two tools that do this work for you.

Generating policies from activity. Access Analyzer reads a role's CloudTrail history (the audit service we will cover in depth in 05-03) over a period and writes the policy that role has actually used. It is the fastest way of trimming existing permissions:

aws accessanalyzer start-policy-generation \
  --policy-generation-details '{"principalArn":"arn:aws:iam::111122223333:role/rol-mercadofresco-tienda"}' \
  --cloud-trail-details '{
    "trails": [{"cloudTrailArn":"arn:aws:cloudtrail:eu-west-1:111122223333:trail/mercadofresco-auditoria",
                "regions":["eu-west-1"],"allRegions":false}],
    "accessRole":"arn:aws:iam::111122223333:role/rol-access-analyzer",
    "startTime":"2026-07-01T00:00:00Z"
  }' \
  --profile mercadofresco-dev

An important warning: the generated policy reflects what was used within the analysed window. If the monthly close process only runs on the 30th and you analyse from the 1st to the 15th, that permission will not appear and you will break the close. Analyse at least one full business cycle, and for MercadoFresco that means including a peak Friday and a month end.

External access findings. The analyser reviews resource-based policies (buckets, queues, keys, roles) and flags everything reachable by somebody from outside your account or organisation. It is free and spots in seconds the bucket somebody opened "just for a test":

aws accessanalyzer create-analyzer \
  --analyzer-name mercadofresco-acceso-externo \
  --type ACCOUNT \
  --profile mercadofresco-dev

aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/mercadofresco-acceso-externo \
  --query 'findings[?status==`ACTIVE`].[resource,principal,action]' \
  --output table \
  --profile mercadofresco-dev

There is also the unused access analyser, which flags roles, users and permissions that have not been used for months. That one does cost money (of the order of 0.20 USD per identity analysed per month) and it is the natural tool for the half-yearly clean-up.

The people of MercadoFresco: users and groups

Until now only mercadofresco-admin and root existed. Marta creates the human structure:

Group Who What they can do
mercadofresco-administracion Marta Full administration with mandatory MFA
mercadofresco-desarrollo Luis EC2, Lambda, development S3, reading logs; nothing in production
mercadofresco-analitica Sara Read-only on mercadofresco-informes-analitica and queries
for g in mercadofresco-administracion mercadofresco-desarrollo mercadofresco-analitica; do
  aws iam create-group --group-name "$g" --profile mercadofresco-dev
done

aws iam create-user --user-name marta \
  --tags Key=Proyecto,Value=mercadofresco Key=Propietario,Value=marta \
  --profile mercadofresco-dev
aws iam add-user-to-group --user-name marta \
  --group-name mercadofresco-administracion --profile mercadofresco-dev

Sara's policy is the best example of least privilege for a non-technical profile:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListarSoloElBucketDeInformes",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::mercadofresco-informes-analitica"
    },
    {
      "Sid": "DescargarInformes",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::mercadofresco-informes-analitica/*"
    },
    {
      "Sid": "SoloDesdeEuWest1",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": ["eu-west-1", "us-east-1"]
        }
      }
    }
  ]
}
  • Sara can list and download, never upload or delete. If her laptop gets infected, she cannot destroy the reports.
  • The third statement confines all her activity to eu-west-1. us-east-1 is included because the global services (IAM, CloudFront, Route 53) sign their requests against that region and without it Sara could not even change her own password.
  • It is an identity-based policy, so it carries no Principal.

Mandatory MFA by condition

Requiring MFA in the console is a checkbox; requiring it for the API takes a policy. This is the one Marta attaches to the three groups, and it is AWS's canonical pattern:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PermitirGestionarLasPropiasCredenciales",
      "Effect": "Allow",
      "Action": [
        "iam:ChangePassword",
        "iam:GetUser",
        "iam:CreateVirtualMFADevice",
        "iam:EnableMFADevice",
        "iam:ListMFADevices",
        "iam:ResyncMFADevice"
      ],
      "Resource": [
        "arn:aws:iam::111122223333:user/${aws:username}",
        "arn:aws:iam::111122223333:mfa/${aws:username}"
      ]
    },
    {
      "Sid": "DenegarTodoLoDemasSinMFA",
      "Effect": "Deny",
      "NotAction": [
        "iam:CreateVirtualMFADevice",
        "iam:EnableMFADevice",
        "iam:GetUser",
        "iam:ListMFADevices",
        "iam:ListVirtualMFADevices",
        "iam:ResyncMFADevice",
        "sts:GetSessionToken",
        "iam:ChangePassword"
      ],
      "Resource": "*",
      "Condition": {
        "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" }
      }
    }
  ]
}

Three delicate points:

  • The first Statement is essential: without it, a new user with no MFA could not register their MFA, and would be locked out forever. It is this policy's chicken-and-egg paradox.
  • NotAction is correct here because it goes with Deny: "deny everything except what is needed to set up MFA".
  • BoolIfExists instead of Bool. If the key does not exist in the context —something that happens on certain service calls— Bool would make the condition fail to match and the Deny would not be applied. BoolIfExists treats the absence as false, that is, as "no MFA", which is the safe behaviour.

Careful: attaching this to a service role breaks everything, because EC2 or Lambda sessions never carry MFA. It is exclusively for groups of humans.

Key rotation and the credential report

If a user needs access keys (Luis, for instance, for the mercadofresco-dev profile on his laptop), they have to be rotated. IAM allows two active keys at the same time, and that is exactly why they exist: to rotate without interruption.

# 1. Create the second key
aws iam create-access-key --user-name luis --profile mercadofresco-dev

# 2. Update ~/.aws/credentials with the new one, test it for a few days

# 3. Deactivate the old one (without deleting it: it can be reverted in seconds)
aws iam update-access-key --user-name luis \
  --access-key-id AKIAIOSFODNN7EXAMPLE --status Inactive --profile mercadofresco-dev

# 4. Confirm nothing has broken and delete it for good
aws iam delete-access-key --user-name luis \
  --access-key-id AKIAIOSFODNN7EXAMPLE --profile mercadofresco-dev

The credential report is a CSV with the state of every user in the account and it is Marta's quarterly review:

aws iam generate-credential-report --profile mercadofresco-dev
aws iam get-credential-report --query 'Content' --output text \
  --profile mercadofresco-dev | base64 --decode > /tmp/credenciales.csv

The columns to look at: mfa_active (must be true for every human), access_key_1_last_used_date (if it is N/A, the key has never been used: delete it), access_key_1_last_rotated (more than 90 days: rotate it) and password_last_used (more than 90 days: the user probably does not work here any more).

IAM Identity Center and federation

Everything above has an obvious limit: it does not scale to people. With three employees it is manageable; with thirty, creating one IAM user per person and per account is an operational disaster, and when somebody leaves you have to remember to delete them everywhere.

The modern solution is AWS IAM Identity Center (formerly AWS SSO):

  • Users live in a single directory: Identity Center's own, Microsoft Entra ID, Okta or Google Workspace.
  • You define permission sets, which are nothing more than IAM roles that Identity Center creates automatically in every account.
  • The person goes to a portal, picks an account and a role, and receives temporary credentials. There are no permanent access keys anywhere.
  • When somebody leaves the company you deactivate them in the corporate directory and they lose access to every AWS account instantly.
  • It is free and integrates with CLI v2: aws configure sso and aws sso login.
IAM users IAM Identity Center
Credentials Permanent Temporary
Joining/leaving Manual and per account Centralised in the directory
Multi-account One user per account One sign-in, every account
Cost Free Free
When Residual cases The recommendation for people

MercadoFresco today has three people and one account, so IAM users are reasonable. As soon as there is a second account —which will happen in 09-04, when we split development from production with Organizations— migrating to Identity Center stops being optional.

Diagnostic tools

Four tools Marta uses when something does not add up.

1. sts get-caller-identity. The first question is always "who am I right now?":

aws sts get-caller-identity --profile mercadofresco-dev
{
    "UserId": "AIDAI23HXD2O5EXAMPLE",
    "Account": "111122223333",
    "Arn": "arn:aws:iam::111122223333:user/luis"
}

If the Arn is not the one you were expecting, the problem is not one of permissions but of credentials: review the precedence we saw in 01-05 (environment variables before the profile, the profile before the instance role).

2. Policy simulator. It evaluates an action without running it:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111122223333:role/rol-mercadofresco-tienda \
  --action-names s3:DeleteObject s3:GetObject \
  --resource-arns "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/tomate-rama.jpg" \
  --query 'EvaluationResults[].[EvalActionName,EvalDecision]' \
  --output table \
  --profile mercadofresco-dev
-------------------------------------
|     SimulatePrincipalPolicy       |
+------------------+----------------+
|  s3:DeleteObject |  implicitDeny  |
|  s3:GetObject    |  allowed       |
+------------------+----------------+

Exactly what we wanted: it can read, it cannot delete. The simulator does not evaluate other accounts' resource-based policies nor every contextual condition, so it is an aid, not a proof.

3. --dry-run. Many EC2 operations accept it and check permissions without doing anything:

aws ec2 terminate-instances --instance-ids i-0abc123def4567890 --dry-run \
  --profile mercadofresco-dev
# UnauthorizedOperation  -> no permission
# DryRunOperation        -> it does have it (and nothing was terminated)

4. get-account-authorization-details. The full dump of the account's users, groups, roles and policies. It is the snapshot for auditing or for versioning in Git:

aws iam get-account-authorization-details --profile mercadofresco-dev \
  > /tmp/iam-mercadofresco-$(date +%F).json

# Who has administrator permissions?
aws iam get-account-authorization-details --profile mercadofresco-dev \
  --query 'Policies[?PolicyName==`AdministratorAccess`]'

And in boto3, a check Marta runs at the quarterly review:

import boto3
from datetime import datetime, timezone

session = boto3.Session(profile_name="mercadofresco-dev", region_name="eu-west-1")
iam = session.client("iam")

now = datetime.now(timezone.utc)

for page in iam.get_paginator("list_users").paginate():
    for user in page["Users"]:
        name = user["UserName"]

        # Does it have MFA?
        mfa = iam.list_mfa_devices(UserName=name)["MFADevices"]
        no_mfa = "NO MFA" if not mfa else "ok"

        # Old keys?
        for key in iam.list_access_keys(UserName=name)["AccessKeyMetadata"]:
            days = (now - key["CreateDate"]).days
            warning = "ROTATE" if days > 90 else "ok"
            print(f"{name:20} {key['AccessKeyId']} {days:4} days  {warning}  {no_mfa}")

The script walks through every user, checks whether they have MFA registered and works out the age of each access key. get_paginator is necessary because list_users returns at most 100 users per call; with a paginator, boto3 takes care of walking every page.

Cost and clean-up

IAM is free. Users, groups, roles, policies and STS calls cost nothing. Neither does IAM Identity Center nor Access Analyzer's external access analyser. The only priced item is the unused access analyser, of the order of 0.20 USD per identity analysed per month: for MercadoFresco's ~8 identities, less than 2 USD a month.

Being free does not mean it has no cost: the cost of IAM is operational. Every badly written policy is a breach or an incident. And there are quotas worth knowing: 5,000 users per account, 1,000 roles, 10 managed policies per identity, 6,144 characters per managed policy and 2,048 per inline user policy.

To undo this lesson, the order matters: you cannot delete a role that has policies attached, nor an instance profile with a role inside it.

aws iam remove-role-from-instance-profile \
  --instance-profile-name rol-mercadofresco-tienda --role-name rol-mercadofresco-tienda \
  --profile mercadofresco-dev
aws iam delete-instance-profile --instance-profile-name rol-mercadofresco-tienda \
  --profile mercadofresco-dev
aws iam detach-role-policy --role-name rol-mercadofresco-tienda \
  --policy-arn arn:aws:iam::111122223333:policy/pol-mercadofresco-tienda --profile mercadofresco-dev
aws iam delete-role --role-name rol-mercadofresco-tienda --profile mercadofresco-dev
aws iam delete-policy --policy-arn arn:aws:iam::111122223333:policy/pol-mercadofresco-tienda \
  --profile mercadofresco-dev

To delete a user you first have to remove the password, the keys, the MFA devices, the group memberships and the attached policies. It is tedious on purpose.

Common Mistakes and Tips

Confusing the bucket ARN with the object ARN. arn:aws:s3:::my-bucket is for ListBucket; arn:aws:s3:::my-bucket/* for GetObject. Almost every S3 policy needs both statements. If your application can download an object whose name it knows but cannot list the bucket, you are missing the first one.

Putting Principal in an identity-based policy. It is a syntax error: AWS rejects it. The principal of an identity-based policy is the identity it is attached to.

Using Bool where BoolIfExists is needed. With Bool, if the condition key is not present in the request context, the condition does not match and your Deny is not applied. Rule of thumb: in Deny conditions that depend on a key that may be missing, always use the IfExists variant.

Attaching AdministratorAccess "temporarily" to unblock something. It is never temporary. If you need to unblock a release at eleven at night, use the simulator to find which permission is missing and add that one permission; it takes two minutes longer and leaves no bomb in the account.

Forgetting the instance profile. Creating the role from the CLI does not create the profile. Remember it when the error talks about an invalid iamInstanceProfile.name.

Putting access keys in user data or in an instance's environment variables. Anyone with access to the instance —or to the metadata, if IMDSv1 is enabled— reads them. Use the role. Always.

Believing that a Deny in one policy can be "overridden" with an Allow in another. It cannot. If something is denied and you cannot find where, look in this order: SCP, permissions boundary, resource-based policy, identity-based policies. And use the error message, which tells you which one.

Tip: name your policies with a prefix of your own. pol-mercadofresco-* stands out at a glance from the AWS managed ones in any listing and stops you attaching the wrong one.

Tip: version your policies in Git. One .json file per policy, reviewed by somebody else before it is applied. Policies are code and deserve the same treatment; in 09-01 and 09-02 we will turn them directly into CloudFormation templates and CDK constructs.

Tip: go through the credential report every three months. Five minutes that spot keys belonging to people who have left, users with no MFA and two-year-old keys.

Exercises

Exercise 1: writing the policy for a new role

MercadoFresco adds a Lambda function called mercadofresco-informe-ventas that runs every night. It needs to: read every object in mercadofresco-copias-basedatos under the prefix exportaciones/, write the resulting report into mercadofresco-informes-analitica under ventas/, write its own logs to CloudWatch Logs and publish a message to the SNS topic alertas-mercadofresco when it finishes. The account is 111122223333 and the region eu-west-1.

Write the complete trust policy and permissions policy, applying least privilege. Justify every ARN.

Exercise 2: diagnosing a denial

Luis runs a script from the instance mercadofresco-tienda-01 and gets:

An error occurred (AccessDenied) when calling the GetObject operation:
User: arn:aws:sts::111122223333:assumed-role/rol-mercadofresco-tienda/i-0abc123def4567890
is not authorized to perform: s3:GetObject
on resource: "arn:aws:s3:::mercadofresco-informes-analitica/ventas/2026-07.csv"
because no identity-based policy allows the s3:GetObject action

Answer: (a) which principal exactly is making the call and how do you know?; (b) is the problem an explicit or an implicit deny?; (c) what is the correct solution and what would be the convenient, wrong one?; (d) would anything change if the bucket had a bucket policy granting s3:GetObject to arn:aws:iam::111122223333:root?

Exercise 3: resolving a policy evaluation

Sara belongs to the group mercadofresco-analitica. Four policies act on her:

  1. Group policy: Allow of s3:GetObject on arn:aws:s3:::mercadofresco-informes-analitica/*.
  2. Inline user policy: Allow of s3:* on *.
  3. The mandatory MFA policy (the one from the corresponding section), and Sara has signed in with MFA.
  4. Bucket policy of mercadofresco-copias-basedatos: Deny of s3:* to every principal whose aws:PrincipalTag/Departamento is not tecnologia. Sara has the tag Departamento=analitica.

Work out whether Sara can perform each of these three operations and why:

  • (a) s3:GetObject on mercadofresco-informes-analitica/ventas/2026-07.csv
  • (b) s3:GetObject on mercadofresco-copias-basedatos/dump-2026-07-01.sql
  • (c) s3:DeleteObject on mercadofresco-catalogo-fotos/productos/tomate-rama.jpg

Solutions

Solution 1

Trust policy (the principal is the Lambda service, as in rol-lambda-miniaturas):

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

Permissions policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Registros",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/lambda/mercadofresco-informe-ventas:*"
    },
    {
      "Sid": "LeerExportaciones",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::mercadofresco-copias-basedatos/exportaciones/*"
    },
    {
      "Sid": "ListarSoloEsePrefijo",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::mercadofresco-copias-basedatos",
      "Condition": { "StringLike": { "s3:prefix": "exportaciones/*" } }
    },
    {
      "Sid": "EscribirElInforme",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::mercadofresco-informes-analitica/ventas/*"
    },
    {
      "Sid": "AvisarAlTerminar",
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco"
    }
  ]
}

ARN justification:

  • The log group carries a final :* to cover the streams, and the specific function is named: this Lambda cannot write into any other function's logs.
  • Reading is GetObject on exportaciones/* only. There is no access to the rest of mercadofresco-copias-basedatos, which holds the full database backups.
  • ListBucket goes on the ARN without /* and is scoped by s3:prefix.
  • Writing is only PutObject on ventas/*: it cannot delete earlier reports.
  • SNS carries the exact topic ARN, not *.
  • There is no logs:CreateLogGroup (created separately) nor any KMS action: if the buckets were encrypted with alias/mercadofresco-datos you would have to add kms:Decrypt and kms:GenerateDataKey with kms:ViaService, as we will see in 04-02.

Solution 2

(a) The principal is an assumed-role session: the ARN begins with arn:aws:sts:: and has the form assumed-role/<role>/<session-name>. For EC2 the session name is the instance identifier, i-0abc123def4567890. In other words: the code is running on the instance using rol-mercadofresco-tienda, exactly as it should. There are no access keys involved.

(b) Implicit. The last line says so: because no identity-based policy allows. If it were explicit, the message would say with an explicit deny in....

(c) The correct solution is to ask whether the shop should be reading analytics reports. Almost certainly not: that bucket is Sara's. What needs fixing is the script, not the policy. If it really were needed, you would add a statement with s3:GetObject on the minimum necessary prefix. The convenient, wrong solution is to attach AmazonS3ReadOnlyAccess to the role, which would grant read access over every bucket in the account, including the database backups with customers' personal data.

(d) Yes, it would change. With principal and resource in the same account, it is enough for one of the two policies to grant the permission. A bucket policy granting to arn:aws:iam::111122223333:root delegates to the account's identity-based policies... but that root in a resource-based policy means "the account", and on its own it grants nothing: the identity-based policy still has to allow it. If instead the bucket policy explicitly named arn:aws:iam::111122223333:role/rol-mercadofresco-tienda with an Allow of s3:GetObject, then it would work without touching the identity-based policy. It is a subtle distinction and the one that confuses people most in practice.

Solution 3

(a) Allowed. The group policy grants s3:GetObject on that bucket, the inline policy does too, there is no applicable Deny (policy 4 only affects mercadofresco-copias-basedatos) and the MFA condition is met because Sara signed in with MFA.

(b) Denied. The bucket policy contains an explicit Deny for principals whose Departamento is not tecnologia, and Sara's is analitica. An explicit deny always wins, in any policy, resource-based ones included. It makes no difference that the inline policy grants her s3:* on *.

(c) Allowed, and that is precisely the problem. The inline policy in point 2 grants s3:* on *, which includes s3:DeleteObject on the photo catalogue. Sara, a business analyst, can delete the shop's product photos. The inline policy completely wipes out the group's careful least privilege: it has to be removed. This is the real pattern by which accounts degrade over time: somebody adds a broad permission "just to test" and nobody takes it away. A permissions boundary on the user sara would have prevented the problem, because the effective permission is the intersection of the boundary and the policies.

Conclusion

IAM has stopped being the part you configure by clicking until something works. You now know that authentication and authorisation are two different things and that the type of error tells you which one has failed; you know how to read an ARN field by field, including S3's oddity of having neither region nor account, and the difference between the bucket ARN and its objects' ARN, which is the origin of half the AccessDenieds in the world.

You know the six types of policy and, above all, the evaluation logic: explicit deny above explicit allow, and both above the implicit deny that refuses everything by default. You know that within one account it is enough for one policy to grant, that across accounts you need both, and that KMS is the exception that always demands the resource policy — something that will matter a great deal one lesson from now.

You have understood why an EC2 instance or a Lambda must never carry access keys: roles and sts:AssumeRole hand out temporary credentials that renew themselves, are stored in no file and cannot leak into Git. And you have finally formalised the two roles we had been dragging along since module 2: rol-mercadofresco-tienda, with the policy pol-mercadofresco-tienda —reading productos/, encrypted writing into miniaturas/, metrics scoped to its own namespace and a Deny that requires TLS— and rol-lambda-miniaturas, with kms:ViaService so that its key only works through S3. You know that the instance has to be given an instance profile, not the role directly.

On the human side, MercadoFresco now has its groups: mercadofresco-administracion for Marta, mercadofresco-desarrollo for Luis and mercadofresco-analitica for Sara, the last of these with read-only access to mercadofresco-informes-analitica and confined to eu-west-1; all of them with mandatory MFA through the aws:MultiFactorAuthPresent condition and with the BoolIfExists that avoids a false sense of security. And you have the method that really does produce least privilege: start with no permissions, run, read the AccessDenied —which gives you the exact action, resource and reason— and add only what is missing, helped by the policy simulator, --dry-run, the credential report and IAM Access Analyzer, which will even write the policy from the real activity recorded in CloudTrail (05-03).

One loose end remains, and it is a big one. In the shop's policy we have required that every thumbnail be uploaded with s3:x-amz-server-side-encryption: aws:kms, and in the Lambda's policy we have granted kms:Decrypt and kms:GenerateDataKey on a key identified only by a UUID. But nobody has yet decided who administers that key, who is allowed to use it, how often it rotates or what exactly happens when S3 encrypts a 4 MB object. The key alias/mercadofresco-datos is still a name without a policy. In lesson 04-02, "AWS Key Management Service (KMS)", we will build it properly: we will see the envelope encryption that explains why your data never travels to KMS, the real difference between SSE-S3, SSE-KMS and SSE-C, the complete key policy for alias/mercadofresco-datos with Marta as administrator and rol-mercadofresco-tienda as its user, and we will finally encrypt the bucket mercadofresco-copias-basedatos, the EBS volumes and the instance mercadofresco-pedidos, which holds personal data of Spanish customers with the GDPR looking over our shoulder.

© Copyright 2026. All rights reserved