AWS Secrets Manager Terraform: Least-Privilege Access
Pangram verdict · v3.3
We believe that this entire text is AI.
AI likelihood · overall
AIArticle text · 1,495 words · 1 segments analyzed
You’ve just been handed access to an AWS account, and the identity you’re given — a role, not a user — already has broad permissions baked in. You didn’t create it. You can’t edit it. You definitely can’t run iam:CreatePolicy to carve out something narrower. And yet the task in front of you is straightforward on paper: store this database password in AWS Secrets Manager, provision it with Terraform, and make sure only the application can read it. If your instinct is “that’s impossible without controlling IAM,” you’ve been thinking about least privilege in only one dimension. This situation is more common than it looks: AWS Academy Learner Labs hand every student a pre-baked LabRole with a wide identity policy and no iam:CreateRole permission. Cross-account setups often hand you a role assumed from another account that you don’t own. Vendor-managed execution environments — a SaaS platform’s “bring your own AWS account” integration, an SCP-locked landing zone where a central platform team owns every IAM policy — put you in exactly the same spot: you have an identity to work with, and you have zero ability to narrow what that identity is allowed to do at the IAM layer. This post walks through the pattern that solves that problem for AWS Secrets Manager specifically: instead of fighting the identity-based policy you can’t touch, you attach a resource-based policy directly to the secret. AWS evaluates both layers, and a resource policy scoped tightly enough compensates for an identity policy that’s far too broad. We’ll build this end-to-end with Terraform — creating the secret, writing its value safely, and layering on a resource policy that restricts access to exactly what’s needed — and verify it actually works with both a positive and a negative test. One thing this post deliberately does not cover: secret rotation. Creating a secret and locking down who can read it is a big enough topic on its own, and rotation with Lambda functions deserves its own post — that’s coming next. The dual-layer access model: why a resource policy works when the identity policy doesn’t Every authorization decision in AWS Secrets Manager (and IAM generally) is the result of evaluating two independent policy types for the same request: Identity-based policy — attached to the principal making the call (a user, role, or federated identity). This is the “what can this identity do” policy. In our scenario, this is LabRole — broad, pre-created, and out of reach. Resource-based policy — attached to the resource being accessed. For Secrets Manager, this is the aws_secretsmanager_secret_policy resource. This is the “who can touch this specific secret” policy, and it’s the one lever we actually control. AWS evaluates both layers for every request, and the combination logic is simple once you internalize it: If either layer contains an explicit Deny that matches the request, the request is denied. Full stop — nothing else matters. If neither layer denies, the request is allowed only if at least one layer contains an explicit Allow that matches. Everything not explicitly allowed is implicitly denied. Here’s the part that makes this pattern possible: when a resource-based policy exists on a secret, AWS treats it as authoritative for that resource. A principal that has broad secretsmanager:* permissions in its identity policy will still be denied if the secret’s resource policy doesn’t grant it access. Conversely, a narrow resource policy attached to the secret can grant access to a principal whose identity policy alone wouldn’t be a problem — but more importantly for us, it can also restrict what an overly broad identity policy would otherwise allow, by scoping the resource policy’s Allow to specific principals, specific actions, and specific conditions. In plain terms: LabRole being allowed to call secretsmanager:GetSecretValue on * at the identity layer doesn’t matter if the secret’s resource policy doesn’t also say “yes, and this principal specifically, under these conditions.” You’re not narrowing LabRole. You’re building a gate around the secret that LabRole has to pass through regardless of how permissive its own policy is. Request: LabRole calls secretsmanager:GetSecretValue on secret X │ ┌───────────┴────────────┐ │ │ Identity Policy Resource Policy (LabRole — broad, (attached to secret X — not editable) the lever we control) │ │ │ Allow (implicit, │ Allow, scoped to LabRole ARN │ broad wildcard) │ + VersionStage = AWSCURRENT │ │ └───────────┬────────────┘ │ Both layers must permit, and an explicit Deny in either layer wins outright │ ┌──────┴──────┐ │ Decision │ └─────────────┘ This is the mechanic behind compensating controls in cloud security: when you can’t fix the root cause, you add a second, independent layer that constrains the blast radius. It’s not a workaround specific to Academy — it’s the correct pattern any time you inherit an identity you can’t shape. Prerequisites and a self-check before you start You’ll need: Terraform >= 1.5 (examples use the mainline secret_string pattern; if you’re on Terraform >= 1.11 I’ll also show the write-only alternative) AWS provider hashicorp/aws >= 5.0 AWS CLI v2, configured with credentials for your lab session (Academy sessions typically expire after ~4 hours and require re-copying temporary credentials) An active AWS Academy Learner Lab session, or any AWS environment where you’re working with a pre-created role you cannot modify Academy’s exact allow-list of permitted services isn’t published anywhere official, and it varies by course and lab template. Don’t assume — verify. Before writing a single line of Terraform, confirm two things: who you are, and whether Secrets Manager is reachable at all in this session. # Confirm the identity your session is actually using aws sts get-caller-identity # Confirm Secrets Manager is reachable and permitted in this lab aws secretsmanager list-secrets --region us-east-1 The first command should return an ARN that includes assumed-role/LabRole (or voclabs, depending on the course template). The second should return an empty SecretList: [] on a fresh account, not an AccessDeniedException. If the second command fails, stop here — this particular lab template doesn’t expose Secrets Manager, and no amount of Terraform will fix that. All examples below target us-east-1, the typical Academy default region. It’s fully configurable via the aws_region variable — just be aware that Academy labs sometimes restrict which regions are usable, so check before switching. Step-by-step implementation Provider and variables Nothing exotic in the provider block, but pin the version — resource policy behavior and block_public_policy support landed in specific provider releases, and you don’t want a terraform init on a different machine silently picking up a version that doesn’t support a field you’re relying on. terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } required_version = ">= 1.5" } provider "aws" { region = var.aws_region } variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "db_password" { description = "Secret value for the database password. Set via TF_VAR_db_password, never committed." type = string sensitive = true } The db_password variable is never given a default. You export it as an environment variable before running Terraform, which keeps it out of your .tf files, your shell history (if you use a leading space, most shells won’t log it), and version control entirely: export TF_VAR_db_password="$(openssl rand -base64 24)" terraform apply Resolving the LabRole ARN dynamically We need the full ARN of LabRole to reference it in the resource policy, and that ARN includes the account ID — which you shouldn’t hardcode, both because it changes every time Academy resets your lab and because hardcoding account IDs in Terraform is a portability smell regardless of context. The aws_caller_identity data source gives us the account ID of whatever credentials Terraform is currently using: data "aws_caller_identity" "current" {} locals { lab_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/LabRole" } If your environment uses a differently named pre-created role — a cross-account assumed role, a vendor-managed execution role — swap LabRole for that role’s name. Everything downstream stays the same; only this one string changes. Creating the secret container resource "aws_secretsmanager_secret" "db_password" { name = "app/production/db-password" description = "Database admin password for the production app tier" recovery_window_in_days = 0 tags = { Environment = "production" ManagedBy = "terraform" } } Two details worth stopping on: recovery_window_in_days = 0 deletes the secret immediately (no 7–30 day soft-delete window) when Terraform destroys it. In a normal AWS account you’d think twice about this — soft-delete is a safety net against accidental deletion. But in an Academy lab, sessions reset roughly every four hours, and if you re-apply the same Terraform in a fresh session after a prior session left a secret in the pending-deletion state, aws_secretsmanager_secret creation fails with a name collision — Secrets Manager won’t let you create a new secret with a name that’s still reserved by a soft-deleted one. Setting the recovery window to zero means terraform destroy actually frees the name immediately, which matters a lot when you’re iterating across multiple short-lived sessions. In a real production account with a stable lifecycle, you’d likely want the default recovery window instead.