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
- Authentication and authorisation: two different questions
- The vocabulary of IAM
- Anatomy of an ARN
- Users, groups and the problem with access keys
- Roles: identities nobody owns
sts:AssumeRoleand the trust relationship- The six types of policy
- Anatomy of a JSON policy
- Wildcards, policy variables and conditions
- Policy evaluation logic
- Formalising
rol-mercadofresco-tienda - Formalising
rol-lambda-miniaturas - Instance profiles
- Least privilege in practice: reading the
AccessDenied - IAM Access Analyzer and policy generation
- The people of MercadoFresco: users and groups
- Mandatory MFA by condition
- Key rotation and the credential report
- IAM Identity Center and federation
- Diagnostic tools
- 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:
| 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/50dc6c495c0c9188Four 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. Writingarn:aws:s3:eu-west-1:111122223333:mercadofresco-catalogo-fotosis a classic mistake and the policy will simply never match. - The bucket and the objects are different resources.
arn:aws:s3:::mercadofresco-catalogo-fotosis fors3:ListBucket;arn:aws:s3:::mercadofresco-catalogo-fotos/*is fors3:GetObject. Confusing them produces the most commonAccessDeniedin the world. - IAM is global: empty region. And the AWS managed policies use
awsin 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 callsts:AssumeRoleon your behalf.Action: sts:AssumeRole: the special action that issues temporary credentials. There are variants:sts:AssumeRoleWithWebIdentity(Cognito, OIDC) andsts: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:
- Everything is denied by default (implicit deny).
- An explicit
Allowin any applicable policy permits it. - An explicit
Denyin 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
Denylost 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
Denyin 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:
- Read the photos in
mercadofresco-catalogo-fotos/productos/. - Write the generated thumbnails into
miniaturas/. - 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: onlys3:GetObject, and only under two prefixes. The shop cannot read anything outsideproductos/andminiaturas/, not even inside the same bucket. Nor can it delete anything or read old versions.ListarSoloLosPrefijosNecesarios:s3:ListBucketacts on the bucket, not on the objects, which is why the ARN has no/*. Thes3:prefixcondition stops anyone listing the whole bucket and discovering what other prefixes exist. It is a subtle but real information leak.EscribirMiniaturasCifradas:s3:PutObjectlimited tominiaturas/, and requiring the upload to include the KMS encryption header. If the code forgets to encrypt, the upload is rejected. This connects directly withalias/mercadofresco-datosin 04-02.PublicarMetricasDeNegocio:cloudwatch:PutMetricDatatakes no resource ARN, so you have to put"Resource": "*". But it is scoped withcloudwatch:namespace, so the shop can only write into its own namespace and cannot fake another component's metrics.ProhibirTraficoSinCifrar: a reinforcingDeny. Remember the rule: aDenyalways wins, even against theAllows 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:CreateLogGroupis 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:ViaServicelimits use of the key to requests that arrive through S3. If somebody steals the function's credentials, they cannot callkms:Decryptdirectly 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-devIf 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-tiendaLeast 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 actionThat 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-devAn 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-devThere 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-devSara'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-1is 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
Statementis 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. NotActionis correct here because it goes withDeny: "deny everything except what is needed to set up MFA".BoolIfExistsinstead ofBool. If the key does not exist in the context —something that happens on certain service calls—Boolwould make the condition fail to match and theDenywould not be applied.BoolIfExiststreats the absence asfalse, 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-devThe 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.csvThe 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 ssoandaws 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?":
{
"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-devTo 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 actionAnswer: (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:
- Group policy:
Allowofs3:GetObjectonarn:aws:s3:::mercadofresco-informes-analitica/*. - Inline user policy:
Allowofs3:*on*. - The mandatory MFA policy (the one from the corresponding section), and Sara has signed in with MFA.
- Bucket policy of
mercadofresco-copias-basedatos:Denyofs3:*to every principal whoseaws:PrincipalTag/Departamentois nottecnologia. Sara has the tagDepartamento=analitica.
Work out whether Sara can perform each of these three operations and why:
- (a)
s3:GetObjectonmercadofresco-informes-analitica/ventas/2026-07.csv - (b)
s3:GetObjectonmercadofresco-copias-basedatos/dump-2026-07-01.sql - (c)
s3:DeleteObjectonmercadofresco-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
GetObjectonexportaciones/*only. There is no access to the rest ofmercadofresco-copias-basedatos, which holds the full database backups. ListBucketgoes on the ARN without/*and is scoped bys3:prefix.- Writing is only
PutObjectonventas/*: 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 withalias/mercadofresco-datosyou would have to addkms:Decryptandkms:GenerateDataKeywithkms: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.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
