Skip to main content

security-best-practices


Security Best Practices

This guide documents the security controls, patterns, and tooling enforced across the oceansoft/terraform-aws/aws registry modules. It is written for platform engineers deploying these modules into production AWS Organizations environments and covers authentication, sensitive data management, state protection, regulatory compliance, and continuous scanning.

All examples use Terraform >= 1.11.0 and AWS Provider >= 6.28, < 7.0 per ADR-003.


1. Authentication Strategy

Never Use Long-Lived Access Keys

Static IAM access keys are the single most common source of credential leakage in Terraform workflows. This project prohibits their use entirely.

For CI/CD (GitHub Actions): Use OIDC federation to assume an IAM role directly from the GitHub Actions workflow. No long-lived values are stored in GitHub.

# oidc-provider.tf — GitHub Actions OIDC provider (deploy once per account)
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]

tags = {
CostCenter = "platform"
Project = "ci-cd"
Environment = "production"
ServiceName = "github-actions"
DataClassification = "internal"
}
}

resource "aws_iam_role" "github_actions_terraform" {
name = "github-actions-terraform"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github_actions.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Restrict to specific repository and branch
"token.actions.githubusercontent.com:sub" = "repo:nnthanh101/terraform-aws:ref:refs/heads/main"
}
}
}]
})

tags = {
CostCenter = "platform"
Project = "ci-cd"
Environment = "production"
ServiceName = "github-actions"
DataClassification = "internal"
}
}

For local development: Use AWS IAM Identity Center (SSO) with short-lived session tokens. Authenticate via aws sso login, never export static keys.

# Authenticate via SSO — tokens auto-rotate, no keys on disk
aws sso login --profile=Cloudandplatformteam-Admin-123456789012

# Run Terraform with the SSO profile
export AWS_PROFILE="Cloudandplatformteam-Admin-123456789012"
terraform plan

2. Provider Authentication — Role Assumption Pattern

When operating across multiple AWS accounts (management, security, workload), use the assume_role block in provider configuration rather than switching profiles. This creates an auditable chain of role assumptions in CloudTrail.

# providers.tf — Cross-account role assumption pattern
provider "aws" {
region = "ap-southeast-2"

# Identity Center lives in us-east-1 but we deploy from ap-southeast-2
assume_role {
role_arn = "arn:aws:iam::${var.management_account_id}:role/TerraformExecution"
session_name = "terraform-identity-center"
# Optional: further restrict permissions for this session
# policy = data.aws_iam_policy_document.session_policy.json
}

default_tags {
tags = {
CostCenter = var.cost_center
Project = var.project_name
Environment = var.environment
ServiceName = "sso"
DataClassification = var.data_classification
}
}
}

# Separate provider for us-east-1 (Identity Center region)
provider "aws" {
alias = "identity_center"
region = "us-east-1"

assume_role {
role_arn = "arn:aws:iam::${var.management_account_id}:role/TerraformExecution"
session_name = "terraform-identity-center-use1"
}
}

Session policies can further restrict an assumed role for specific operations. Use them when a single execution role serves multiple Terraform modules with different permission scopes.


3. Sensitive Data Management

Rule: Never Hardcode Sensitive Values in .tf Files

Terraform state files store resource attributes in plaintext. Any value that appears in a .tf file as a literal string will be visible in state. This project enforces the following hierarchy for storage of sensitive data:

Data TypeStorageTerraform Access Pattern
Database passwordsAWS Secrets Managerdata.aws_secretsmanager_secret_version
API keysAWS Secrets Managerdata.aws_secretsmanager_secret_version
Configuration valuesSSM Parameter Storedata.aws_ssm_parameter
Encryption keysAWS KMSdata.aws_kms_key / aws_kms_alias
OIDC thumbprintsComputed at apply timedata.tls_certificate
# sensitive-data.tf — Retrieve values at apply time, never store in code
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/rds/master-password"
}

resource "aws_db_instance" "main" {
# ...
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}

# SSM Parameter Store for non-sensitive configuration
data "aws_ssm_parameter" "vpc_id" {
name = "/infrastructure/vpc/primary-id"
}

Pre-commit enforcement: The gitleaks scan runs on every commit via pre-commit hooks. Any detected sensitive value blocks the commit.

# .pre-commit-config.yaml (excerpt)
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks

Sensitive Variables

Mark variables that may contain protected values with sensitive = true to prevent their values from appearing in plan output and logs:

variable "database_password" {
description = "RDS master password — retrieved from Secrets Manager"
type = string
sensitive = true
}

4. State File Security

Terraform state files contain the complete resource graph including sensitive attributes. This project uses S3 native locking per ADR-006.

Backend Configuration (Production)

# versions.tf — S3 native locking, no DynamoDB (ADR-006)
terraform {
required_version = ">= 1.11.0"

backend "s3" {
bucket = "nnthanh101-terraform-state"
key = "terraform-aws/identity-center/terraform.tfstate"
region = "ap-southeast-2"
encrypt = true # SSE-S3 encryption at rest
use_lockfile = true # S3 native locking — NO DynamoDB needed
}
}

State Bucket Hardening

The S3 bucket that holds Terraform state must be locked down with the following controls:

ControlSettingPurpose
EncryptionSSE-S3 or SSE-KMSProtect state data at rest
VersioningEnabledRecover from state corruption
Public accessBlock all public accessPrevent accidental exposure
Bucket policyRestrict to Terraform execution rolesLeast-privilege access
Object LockGovernance mode (optional)Prevent accidental deletion
LoggingS3 access logging to separate bucketAudit trail for state access

Minimal IAM policy for state access (no DynamoDB permissions needed):

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::nnthanh101-terraform-state",
"arn:aws:s3:::nnthanh101-terraform-state/*"
]
}
]
}

Why no DynamoDB? Terraform v1.11.0+ uses S3 Conditional Writes (If-None-Match header) for atomic lock file creation. This eliminates an entire service dependency, reduces the IAM surface, and removes DynamoDB throttling as a failure mode. See ADR-006 for the full decision record and migration path.


5. APRA CPS 234 Compliance

This project includes custom Checkov checks in .checkov/custom_checks/check_apra_cps234.py that enforce Australian Prudential Regulation Authority (APRA) CPS 234 requirements for information security. These checks run automatically via task build:lint.

Custom APRA Checks

Check IDCPS 234 ReferenceWhat It Validates
CKV_APRA_001Para 15 — Data ClassificationAll IAM resources must have a DataClassification tag with a valid value (public, internal, confidential, restricted)
CKV_APRA_002Para 36 — Least PrivilegeNo AdministratorAccess AWS managed policy attached (unless break-glass per ADR-020)
CKV_APRA_003Para 37 — Session DurationSSO session duration must not exceed 8 hours
CKV_APRA_004Para 36 — Separation of DutiesAdministrative permission sets (name contains "admin") must have session duration of 1 hour or less
CKV_APRA_005Para 37 — Permissions BoundaryHigh-privilege permission sets must have a permissions boundary attached

FOCUS 1.2+ FinOps Tag Check

In addition to the APRA checks, check_focus_tags.py validates the 4 FOCUS cost allocation tags:

Check IDWhat It Validates
CKV_CUSTOM_FOCUS_001All taggable resources include CostCenter, Project, Environment, ServiceName

Mandatory Tags

Every taggable resource must include the 5 canonical tags. The module's default_tags variable ensures baseline coverage, and per-resource tags can override individual keys:

module "aws-iam-identity-center" {
source = "oceansoft/iam-identity-center/aws"

# 5 canonical tags: Enterprise + FOCUS 1.2+ + APRA CPS 234
default_tags = {
CostCenter = "platform"
Project = "landing-zone"
Environment = "production"
ServiceName = "sso"
DataClassification = "confidential" # CKV_APRA_001
}

permission_sets = {
LZReadOnlyAccess = {
description = "Read-only access for all accounts"
session_duration = "PT8H" # CKV_APRA_003: <= 8H
aws_managed_policies = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
tags = { Tier = "read-only" }
}
}
}

The DataClassification tag values map directly to the project's data governance tiers:

ClassificationExamplesSecurity Requirements
publicMarketing materials, published documentationNo encryption required
internalProject plans, internal emailsEncryption at rest, SSO authentication
confidentialCustomer data, financial recordsEncryption in transit and at rest, RBAC, audit logging
restrictedPHI, PII, payment card dataEnd-to-end encryption, strict RBAC, immutable audit logs

Break-Glass Pattern

The only sanctioned exception to CKV_APRA_002 (no AdministratorAccess) is the break-glass emergency access pattern documented in ADR-020. Use checkov:skip with the ADR reference:

permission_sets = {
# checkov:skip=CKV_APRA_002:ADR-020 break-glass pattern
# checkov:skip=CKV_APRA_005:ADR-020 break-glass; boundary deferred
LZAdministratorAccess = {
description = "Break-glass only (ADR-020)"
session_duration = "PT1H" # CKV_APRA_004: admin <= 1H
aws_managed_policies = ["arn:aws:iam::aws:policy/AdministratorAccess"]
}
}

6. Security Scanning

This project integrates multiple scanning tools at different stages of the development lifecycle. All scans run inside the nnthanh101/terraform:2.6.0 container to ensure reproducible results.

Scanning Matrix

ToolScopeTaskfile CommandRuns OnBlocking
CheckovSAST + custom APRA/FOCUS checkstask build:lintEvery commit (CI)Critical/High
TrivyMisconfiguration + CVE scantask security:trivyPre-deployCritical
tflintTerraform linting + provider rulestask build:lintEvery commit (CI)All findings
gitleaksDetection of sensitive values in sourcePre-commit hookEvery commit (local)Any finding
terraform validateHCL syntax + provider schematask build:validateEvery commit (CI)All errors

Running Scans Locally

# Quick CI: format check + validate + lint (Checkov + tflint) + legal headers
task ci:quick

# Full security scan including Trivy misconfiguration analysis
task security:trivy

# Full CI pipeline: build + test + governance + security
task ci:full

Checkov Custom Checks

The custom checks in .checkov/custom_checks/ extend Checkov's built-in ruleset with project-specific requirements:

  • check_apra_cps234.py — 5 checks (CKV_APRA_001 through CKV_APRA_005) covering data classification tagging, least privilege, session duration limits, separation of duties, and permissions boundaries. See Section 5 above.
  • check_focus_tags.py — 1 check (CKV_CUSTOM_FOCUS_001) validating that all taggable resources include the 4 FOCUS 1.2+ cost allocation tags: CostCenter, Project, Environment, ServiceName.

Plan-Time Validation (Terraform Check Blocks)

In addition to static analysis, the iam-identity-center module includes Terraform check blocks that validate permission set configurations at plan time. These are defined in locals.tf and surface issues in YAML-sourced configurations that bypass the variables.tf validation block:

  • yaml_session_duration_format — Validates ISO 8601 duration format (PT<n>H or PT<n>M) for all permission sets, including those loaded from YAML.
  • yaml_required_tag_keys — Validates CostCenter and DataClassification tag presence on all permission sets after the YAML merge.

7. IAM Least Privilege

Permission Set Hierarchy

The recommended pattern for multi-account Landing Zones is a tiered permission hierarchy with descending privilege and ascending session duration:

TierPermission SetSession DurationUse Case
AdminLZAdministratorAccessPT1H (CKV_APRA_004)Break-glass only (ADR-020)
Power UserLZPowerUserAccessPT4HWorkload deployment, no IAM mutation
Read OnlyLZReadOnlyAccessPT8H (CKV_APRA_003 max)Compliance review, monitoring
Security AuditLZSecurityAuditAccessPT8HCloudTrail, GuardDuty, Config, SecurityHub

The inverse relationship between privilege level and session duration is a core APRA CPS 234 control: higher-privilege sessions expire faster, reducing the blast radius of compromised tokens.

Permissions Boundaries

High-privilege permission sets should include a permissions boundary to cap the maximum effective permissions, even if the attached policies grant broader access. CKV_APRA_005 enforces this for any permission set whose name contains administratoraccess or poweruseraccess.

permission_sets = {
LZPowerUserAccess = {
description = "Power user access — deploy workloads, no IAM mutation"
session_duration = "PT4H"
aws_managed_policies = ["arn:aws:iam::aws:policy/PowerUserAccess"]

# Permissions boundary: caps effective permissions
permissions_boundary = {
managed_policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}

tags = { Tier = "power-user" }
}
}

Attribute-Based Access Control (ABAC)

The iam-identity-center module supports ABAC via sso_instance_access_control_attributes. This enables resource-level access control based on user attributes (e.g., Environment, CostCenter) without creating separate permission sets for each scope:

sso_instance_access_control_attributes = [
{
attribute_name = "Environment"
source = ["$${path:enterprise.Environment}"]
},
{
attribute_name = "CostCenter"
source = ["$${path:enterprise.CostCenter}"]
},
]

With ABAC configured, IAM policies can use aws:PrincipalTag/Environment conditions to restrict access to resources tagged with the same environment as the user's SSO session.


Security Checklist

Before deploying any module from this registry to production, verify the following:

  • No long-lived access keys: CI/CD uses OIDC federation; developers use SSO (aws sso login)
  • Provider uses assume_role: Cross-account access via role assumption, not shared access keys
  • No hardcoded sensitive values: All protected data sourced from Secrets Manager or SSM Parameter Store
  • State encrypted: S3 backend with encrypt = true and use_lockfile = true (ADR-006)
  • State bucket hardened: Versioning enabled, public access blocked, bucket policy restricted
  • 5 canonical tags present: CostCenter, Project, Environment, ServiceName, DataClassification
  • APRA checks pass: task build:lint reports 0 APRA/FOCUS check failures
  • Trivy scan clean: task security:trivy reports 0 critical/high findings
  • Session durations compliant: Admin <= 1H, general <= 8H, power user <= 4H
  • Break-glass documented: checkov:skip annotations reference ADR-020 with justification
  • Sensitive value detection enabled: gitleaks pre-commit hook installed and active