From 042445f9c8127e6ea76bf6c4af05cee512eb03c1 Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Thu, 23 Jul 2026 15:24:26 -0400 Subject: [PATCH 1/3] add rvn-aws-iam-policy --- README.md | 1 + security/iam_policy/.terraform.lock.hcl | 26 ++ security/iam_policy/README.md | 99 +++++++ security/iam_policy/data.tf | 30 ++ security/iam_policy/iam_policy.tf | 14 + security/iam_policy/locals.tf | 17 ++ security/iam_policy/outputs.tf | 34 +++ .../rvn-aws-iam-policy-definition.yml | 277 ++++++++++++++++++ security/iam_policy/tests/basic.tftest.hcl | 220 ++++++++++++++ security/iam_policy/variables.tf | 92 ++++++ security/iam_policy/versions.tf | 12 + 11 files changed, 822 insertions(+) create mode 100644 security/iam_policy/.terraform.lock.hcl create mode 100644 security/iam_policy/README.md create mode 100644 security/iam_policy/data.tf create mode 100644 security/iam_policy/iam_policy.tf create mode 100644 security/iam_policy/locals.tf create mode 100644 security/iam_policy/outputs.tf create mode 100644 security/iam_policy/rvn-aws-iam-policy-definition.yml create mode 100644 security/iam_policy/tests/basic.tftest.hcl create mode 100644 security/iam_policy/variables.tf create mode 100644 security/iam_policy/versions.tf diff --git a/README.md b/README.md index e1491d4..604f5f5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ This repository contains reusable infrastructure modules designed for enterprise | `networking/` | `vpc` | AWS VPC with adaptive public and private subnets | v1.0.0 | | `security/` | `acm_certificate` | AWS ACM public certificates with ordered domains, DNS validation, optional Route53, and optional wait | v1.0.0 | | `security/` | `iam` | AWS IAM roles and policies | v1.0.0 | +| `security/` | `iam_policy` | Reusable customer-managed AWS IAM policies | v1.0.0 | | `security/` | `kms` | AWS KMS keys (symmetric or asymmetric: signing, encryption, MAC, key agreement) | v1.0.0 | | `security/` | `secrets-manager` | AWS Secrets Manager secrets | Planned | | `stack/` | `terraform` | Ravion Terraform/OpenTofu stack workflows with git triggers and managed state (includes `rvn-stack` module definition) | v1.2.3 | diff --git a/security/iam_policy/.terraform.lock.hcl b/security/iam_policy/.terraform.lock.hcl new file mode 100644 index 0000000..defc1a0 --- /dev/null +++ b/security/iam_policy/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.56.0" + constraints = ">= 6.0.0" + hashes = [ + "h1:34wj37Q0GYVYOpPZzJmWe7Cn0oLRLs0ayufm/D8sJow=", + "h1:OzyJEpEhKzbd6MrlOuXl0044PaHtN1yZt8pwmvvd36o=", + "zh:0df287675010e67011cd830a69fb8c81eb81b8cc82ec21f806bf5b70ffacb94c", + "zh:1b0a431e4a51be43b77dc5f790906567a0a6a9e683b442be0cd2758f1e1d5637", + "zh:1c58a0335198d558cb2ea83a42ef043b89f817c5e67a54a3746d3a47344ba602", + "zh:1ce5a623d2a2d9c006e0009fa67a9d486984abd3084648fcdd7997b7fc022233", + "zh:2380bc878d1127d75155df23f8fad4b64fe21db4856070dc084fcde80b856ca9", + "zh:51d77a6f1847fbf97874c976cf134b105abce8fce0beba38fc169ab5cfb7de66", + "zh:6364cac70a860c107b4a019aeb8c8afd9cb7e70bcd5a25ae62b74e27eaadeac2", + "zh:68e8d389804f6fc40fe5071093c52d41b82a18436be01ff75eba719d5c43286a", + "zh:82d4b24e9eb2e01568e0c0bc43c85686093d3c6f1a97461374c3009333f0522c", + "zh:b5845bd95ba758f6d411683bf14b5ee9208f31872d416f1aa82975e2ac31d696", + "zh:c228a7e9fab6319af525d78052f542504a042356ba1e71cd3c5f218c8b0fd7c4", + "zh:c5dd55276e518c6ab215e298091577ceb618ca405603885bb5e153b95d2e7d8c", + "zh:c6ceeb277f1897a0f813b913bf270db409f4fc3ec95d24238e5f5e71709edf7e", + "zh:f1e42b4c42faebd28f6040201760bfdd1e3391d858abd7586c7e513433e8a73d", + "zh:fff049bcd38e4cd82d526670e81aceb5f30d211c8b494221773737cade6e27c2", + ] +} diff --git a/security/iam_policy/README.md b/security/iam_policy/README.md new file mode 100644 index 0000000..32f1200 --- /dev/null +++ b/security/iam_policy/README.md @@ -0,0 +1,99 @@ +# AWS IAM Policy Module + +Creates one reusable customer-managed AWS IAM policy from structured statements or a complete JSON policy document. + +## Usage + +```hcl +module "application_policy" { + source = "git::https://github.com/flightcontrolhq/modules.git//security/iam_policy?ref=v1.0.0" + + name = "application-s3-read" + description = "Allows the application to read objects from its bucket." + + policy_statements = [{ + sid = "ReadApplicationObjects" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::application-bucket/*"] + conditions = [{ + test = "StringEquals" + variable = "aws:ResourceAccount" + values = ["123456789012"] + }] + }] + + tags = { + Environment = "production" + } +} +``` + +Use `policy_json` when a complete policy document is more practical. When set, it overrides `policy_statements`. + +```hcl +module "custom_policy" { + source = "git::https://github.com/flightcontrolhq/modules.git//security/iam_policy?ref=v1.0.0" + + name = "custom-policy" + policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "arn:aws:logs:us-east-1:123456789012:log-group:/aws/example:*" + }] + }) +} +``` + +## Structured Statements + +Each structured statement must set exactly one of `actions` or `not_actions` and exactly one of `resources` or `not_resources`. `NotAction` and `NotResource` can create broad matches, especially with an `Allow` effect, so prefer positive action and resource lists unless an inverse statement is necessary. + +```hcl +policy_statements = [{ + sid = "DenyOutsideApprovedResources" + effect = "Deny" + actions = ["s3:*"] + not_resources = [ + "arn:aws:s3:::approved-bucket", + "arn:aws:s3:::approved-bucket/*", + ] +}] +``` + +## Requirements + +| Name | Version | +|------|---------| +| opentofu/terraform | >= 1.10.0 | +| aws | >= 6.0 | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|----------| +| name | Customer-managed IAM policy name. | `string` | n/a | yes | +| description | Policy description. AWS does not allow updates after creation. | `string` | `"Managed by Terraform"` | no | +| path | IAM path for the policy. | `string` | `"/"` | no | +| policy_statements | Structured IAM statements. Ignored when `policy_json` is set. | `list(object)` | `[]` | no | +| policy_json | Complete IAM policy JSON document. | `string` | `null` | no | +| region | AWS provider region. IAM resources are global. | `string` | `null` | no | +| tags | Tags to assign to the policy. | `map(string)` | `{}` | no | + +At least one structured statement or a raw JSON policy document is required. + +## Outputs + +| Name | Description | +|------|-------------| +| policy_arn | ARN of the customer-managed IAM policy. | +| policy_id | ID of the customer-managed IAM policy. | +| policy_name | Name of the customer-managed IAM policy. | +| policy_path | Path of the customer-managed IAM policy. | +| attachment_count | Number of entities attached to the policy. | +| aws_account_id | AWS account ID where the policy is created. | +| region | AWS provider region used by the module. | + +Attach `policy_arn` to roles through the `security/iam` module's `managed_policy_arns` input, or use it as that module's `permission_boundary_arn`. diff --git a/security/iam_policy/data.tf b/security/iam_policy/data.tf new file mode 100644 index 0000000..e86a802 --- /dev/null +++ b/security/iam_policy/data.tf @@ -0,0 +1,30 @@ +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +data "aws_iam_policy_document" "structured" { + count = var.policy_json == null && length(var.policy_statements) > 0 ? 1 : 0 + + dynamic "statement" { + for_each = var.policy_statements + + content { + sid = statement.value.sid + effect = statement.value.effect + actions = statement.value.actions + not_actions = statement.value.not_actions + resources = statement.value.resources + not_resources = statement.value.not_resources + + dynamic "condition" { + for_each = statement.value.conditions + + content { + test = condition.value.test + variable = condition.value.variable + values = condition.value.values + } + } + } + } +} diff --git a/security/iam_policy/iam_policy.tf b/security/iam_policy/iam_policy.tf new file mode 100644 index 0000000..dadb32e --- /dev/null +++ b/security/iam_policy/iam_policy.tf @@ -0,0 +1,14 @@ +resource "aws_iam_policy" "this" { + name = var.name + description = var.description + path = var.path + policy = local.policy_document + tags = local.tags + + lifecycle { + precondition { + condition = var.policy_json != null || length(var.policy_statements) > 0 + error_message = "Provide at least one policy_statement or a policy_json document." + } + } +} diff --git a/security/iam_policy/locals.tf b/security/iam_policy/locals.tf new file mode 100644 index 0000000..78e6898 --- /dev/null +++ b/security/iam_policy/locals.tf @@ -0,0 +1,17 @@ +locals { + region = coalesce(var.region, data.aws_region.current.region) + + default_tags = { + ManagedBy = "terraform" + Module = "security/iam_policy" + } + + tags = merge(local.default_tags, var.tags) + + policy_document = var.policy_json != null ? var.policy_json : ( + length(var.policy_statements) > 0 ? data.aws_iam_policy_document.structured[0].json : jsonencode({ + Version = "2012-10-17" + Statement = [] + }) + ) +} diff --git a/security/iam_policy/outputs.tf b/security/iam_policy/outputs.tf new file mode 100644 index 0000000..00a745f --- /dev/null +++ b/security/iam_policy/outputs.tf @@ -0,0 +1,34 @@ +output "policy_arn" { + description = "The ARN of the customer-managed IAM policy." + value = aws_iam_policy.this.arn +} + +output "policy_id" { + description = "The ID of the customer-managed IAM policy." + value = aws_iam_policy.this.id +} + +output "policy_name" { + description = "The name of the customer-managed IAM policy." + value = aws_iam_policy.this.name +} + +output "policy_path" { + description = "The path of the customer-managed IAM policy." + value = aws_iam_policy.this.path +} + +output "attachment_count" { + description = "The number of entities attached to the customer-managed IAM policy." + value = aws_iam_policy.this.attachment_count +} + +output "aws_account_id" { + description = "The AWS account ID where the IAM policy is created." + value = data.aws_caller_identity.current.account_id +} + +output "region" { + description = "The AWS region used to configure the provider." + value = local.region +} diff --git a/security/iam_policy/rvn-aws-iam-policy-definition.yml b/security/iam_policy/rvn-aws-iam-policy-definition.yml new file mode 100644 index 0000000..d8e3de4 --- /dev/null +++ b/security/iam_policy/rvn-aws-iam-policy-definition.yml @@ -0,0 +1,277 @@ +definition: + type: rvn-aws-iam-policy + name: AWS IAM Policy + description: Creates one reusable customer-managed AWS IAM policy from structured statements or a complete JSON policy document. +release: + version: 0.1.0 + description: Initial module definition with structured Action, NotAction, Resource, and NotResource statements. +module: + inputs: + - $include: ../../partials/inputs/aws-account.yml + - $include: ../../partials/inputs/aws-region.yml + - id: section_policy + label: IAM policy + type: section + - default: <>-<>-<> + description: Name of the customer-managed IAM policy. Policy names must be unique within their path in the AWS account. + id: name + immutable: true + label: Policy name + patterns: + - message: "1-128 letters, numbers, plus, equals, comma, period, at, underscore, or hyphen characters." + pattern: ^[A-Za-z0-9+=,.@_-]{1,128}$ + required: true + type: string + - default: Managed by Terraform + description: Description stored on the IAM policy. AWS does not allow this value to be changed after creation. + id: description + label: Description + patterns: + - message: 1000 characters or fewer. + pattern: ^.{0,1000}$ + type: string + - collapsible: true + default: / + description: IAM path for organizing the policy. Most policies should use the root path. + id: path + immutable: true + label: Path + patterns: + - message: Start and end with a slash, for example / or /application/. + pattern: ^/$|^/.*/$ + - message: Asterisks are not allowed in IAM paths. + pattern: ^[^*]*$ + - message: 512 characters or fewer. + pattern: ^.{1,512}$ + required: true + type: string + - id: section_document + label: Policy document + type: section + - default: structured + description: Build the policy with structured statements or provide a complete JSON document. + id: policy_document_mode + label: Policy document format + required: true + type: string + values: + - label: Structured statements + value: structured + - label: JSON document + value: json + - default: [] + description: IAM permission statements included in the customer-managed policy. + id: policy_statements + item_inputs: + - description: Optional IAM statement ID. + id: sid + label: SID + required: false + type: string + - default: Allow + description: Whether this statement allows or denies the listed actions. + id: effect + label: Effect + required: true + type: string + values: + - label: Allow + value: Allow + - label: Deny + value: Deny + - default: actions + description: Match the listed actions or every applicable action except those listed. + id: action_match_mode + label: Action matching + required: true + type: string + values: + - label: Listed actions + value: actions + - label: All except listed actions + value: not_actions + - add_button_label: Add action + description: IAM actions granted or denied by this statement. + id: actions + label: Actions + placeholder: s3:GetObject + required: true + show_when: + action_match_mode: actions + type: string_array + - add_button_label: Add excluded action + description: IAM actions excluded from this statement's match. NotAction can create broad permissions and should be used carefully. + id: not_actions + label: Excluded actions (NotAction) + placeholder: iam:* + required: true + show_when: + action_match_mode: not_actions + type: string_array + - default: resources + description: Match the listed resources or every applicable resource except those listed. + id: resource_match_mode + label: Resource matching + required: true + type: string + values: + - label: Listed resources + value: resources + - label: All except listed resources + value: not_resources + - add_button_label: Add resource + description: Resource ARNs covered by this statement. Use wildcards only when the AWS service requires them. + id: resources + label: Resources + placeholder: arn:aws:s3:::my-bucket/* + required: true + show_when: + resource_match_mode: resources + type: string_array + - add_button_label: Add excluded resource + description: Resource ARNs excluded from this statement's match. NotResource can create broad permissions and should be used carefully. + id: not_resources + label: Excluded resources (NotResource) + placeholder: arn:aws:s3:::my-bucket/protected/* + required: true + show_when: + resource_match_mode: not_resources + type: string_array + - default: [] + description: Optional IAM conditions that further restrict this statement. + id: conditions + item_inputs: + - $template: ../../partials/templates/iam-condition-operator-input.yml + with: + default: StringEquals + - description: IAM context key evaluated by the condition. + id: variable + label: Condition key + placeholder: aws:ResourceAccount + required: true + type: string + - add_button_label: Add condition value + description: Allowed or matched values for the condition key. + id: values + label: Condition values + placeholder: "123456789012" + required: true + type: string_array + item_label: Condition + label: Conditions + required: false + type: object_array + item_label: Policy statement + label: Policy statements + required: true + show_when: + policy_document_mode: structured + type: object_array + - collapsible: true + description: Complete IAM policy JSON document. This is used instead of structured statements. + id: policy_json + label: Policy JSON + patterns: + - message: Include a JSON object with Version and Statement properties. + pattern: ^\s*\{[\s\S]*"Version"[\s\S]*"Statement"[\s\S]*\}\s*$ + placeholder: |- + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-bucket/*"] + } + ] + } + required: true + show_when: + policy_document_mode: json + type: text + - $include: ../../partials/inputs/misc-section.yml + - $include: ../../partials/inputs/tags.yml + - $include: ../../partials/inputs/terraform-settings.yml + - $merge: + - ../../partials/inputs/execution-environment.yml + description: Override the execution environment for Terraform runners. Must use the same AWS account as selected above. + stack: + $template: ../../partials/templates/opentofu-stack.yml + with: + base_path: security/iam_policy + terraform_variables: + ...overrides: << module.input.advanced_terraform_variables >> + description: << module.input.description >> + name: << module.input.name >> + path: << module.input.path >> + policy_json: << module.input.policy_json >> + policy_statements: << module.input.policy_statements || [] >> + region: << module.input.aws_region >> + tags: + $include: ../../partials/stack/ravion-tags.yml + readme: | + Creates one reusable customer-managed AWS IAM policy from structured statements or a complete JSON policy document. + + ## Overview + + Use this module when permissions need to be managed independently from an IAM role. The policy ARN can be attached to one or more roles, reused across workloads, or supplied as a permissions boundary. + + Customer-managed policies are versioned by AWS when their document changes. Keep statements narrowly scoped to the actions and resource ARNs each workload needs. + + ## Use cases + + | Scenario | Benefit | + | --- | --- | + | Shared workload permissions | Reuse one policy across several IAM roles without duplicating inline policy documents. | + | Permissions boundary | Set the maximum permissions that another IAM role can receive. | + | Independently managed access | Update permissions without combining policy ownership with trust relationships or instance profiles. | + | Complex policy document | Use complete JSON when a policy cannot be represented by the structured statement form. | + + ## Policy documents + + Structured statements are the default. Each statement requires an effect, either Action or NotAction values, and either Resource or NotResource values. Optional conditions can restrict access by IAM context keys such as account, source ARN, principal tags, or resource tags. + + NotAction and NotResource invert what a statement matches. They can simplify explicit deny policies, but combining either with Allow can grant broader access than expected. Prefer listed actions and resources unless an inverse statement is necessary. + + JSON document accepts a complete IAM policy document. Use this mode for uncommon IAM elements or documents produced by another trusted policy-authoring workflow. The JSON document replaces structured statements rather than merging with them. + + IAM policies can have broad security impact. Avoid wildcard actions and resources unless the AWS service requires them, and add conditions where they reduce unintended access. + + ## Using the policy + + This module creates the policy but does not attach it. Attach the Policy ARN output through the AWS IAM Role module's Managed policy ARNs setting, or use it as that module's Permission boundary ARN. + + Keeping policy creation and role creation separate allows a policy to be shared, audited, and updated without duplicating role trust configuration. + + ## Configuration + + | Setting | Required | Default | Notes | + | --- | --- | --- | --- | + | AWS account | Yes | None | AWS account where the customer-managed policy is created. | + | Region | Yes | None | Runner region for the stack. IAM resources are global, but Terraform still runs with an AWS region. | + | Policy name | Yes | Project, environment, and module given IDs | Must be unique within the selected path in the AWS account. | + | Description | No | Managed by Terraform | AWS does not allow a managed policy description to be edited after creation. | + | Path | No | / | IAM path used to organize the policy. | + | Policy document format | Yes | Structured statements | Chooses between the statement editor and complete JSON. | + | Policy statements | In structured mode | Empty list | At least one statement is required when structured mode is selected. | + | Policy JSON | In JSON mode | None | Complete policy document used instead of structured statements. | + | Tags | No | Standard Ravion tags | Additional tags merged with Ravion ownership and identity tags. | + | Terraform execution environment | No | None | Optional runner environment override. | + | Advanced Terraform variables | No | Empty object | Escape hatch for one-off Terraform variable overrides. | + + ## Design decisions + + The module manages one policy and no attachments. IAM roles, users, and groups have separate lifecycles, while a customer-managed policy may be shared by several consumers. + + Structured statements require an explicit Action or NotAction choice and an explicit Resource or NotResource choice. The module does not add wildcard permissions automatically. + + Policy name and path are immutable in Ravion because they define the managed policy's identity. Description remains editable, but AWS may replace the policy when it changes. + + Ravion adds standard tags for ownership and traceability. User-provided Tags are merged on top for team, cost, or environment metadata. + + ## Learn more + + - [IAM managed policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html) + - [IAM JSON policy elements](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html) + - [IAM permissions boundaries](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) + - [Source module](https://github.com/flightcontrolhq/modules/tree/$local.module_tag/security/iam_policy) diff --git a/security/iam_policy/tests/basic.tftest.hcl b/security/iam_policy/tests/basic.tftest.hcl new file mode 100644 index 0000000..82e8658 --- /dev/null +++ b/security/iam_policy/tests/basic.tftest.hcl @@ -0,0 +1,220 @@ +mock_provider "aws" { + override_resource { + target = aws_iam_policy.this + values = { + arn = "arn:aws:iam::123456789012:policy/test-policy" + attachment_count = 0 + } + } + + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } + + override_data { + target = data.aws_region.current + values = { + region = "us-east-1" + } + } + + override_data { + target = data.aws_iam_policy_document.structured + values = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"ReadObjects\",\"Effect\":\"Allow\",\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::example-bucket/*\"}]}" + } + } +} + +variables { + name = "test-policy" + policy_statements = [{ + sid = "ReadObjects" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::example-bucket/*"] + conditions = [{ + test = "StringEquals" + variable = "aws:ResourceAccount" + values = ["123456789012"] + }] + }] +} + +run "structured_policy_defaults" { + command = plan + + assert { + condition = aws_iam_policy.this.name == "test-policy" + error_message = "The policy name should use the provided name." + } + + assert { + condition = aws_iam_policy.this.path == "/" + error_message = "The policy path should default to root." + } + + assert { + condition = aws_iam_policy.this.tags["ManagedBy"] == "terraform" + error_message = "The ManagedBy tag should be present." + } + + assert { + condition = aws_iam_policy.this.tags["Module"] == "security/iam_policy" + error_message = "The Module tag should identify security/iam_policy." + } +} + +run "raw_json_overrides_structured_statements" { + command = plan + + variables { + policy_json = <<-JSON + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": "s3:DeleteObject", + "Resource": "arn:aws:s3:::example-bucket/*" + }] + } + JSON + } + + assert { + condition = jsondecode(aws_iam_policy.this.policy).Statement[0].Effect == "Deny" + error_message = "Raw policy JSON should override structured statements." + } +} + +run "user_tags_override_defaults" { + command = plan + + variables { + tags = { + ManagedBy = "ravion" + Environment = "production" + } + } + + assert { + condition = aws_iam_policy.this.tags["ManagedBy"] == "ravion" + error_message = "User tags should override default tag values." + } + + assert { + condition = aws_iam_policy.this.tags["Environment"] == "production" + error_message = "User tags should be merged with default tags." + } +} + +run "missing_policy_document_rejected" { + command = plan + + variables { + policy_statements = [] + } + + expect_failures = [ + aws_iam_policy.this, + ] +} + +run "invalid_name_rejected" { + command = plan + + variables { + name = "invalid policy name" + } + + expect_failures = [ + var.name, + ] +} + +run "invalid_effect_rejected" { + command = plan + + variables { + policy_statements = [{ + effect = "Permit" + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::example-bucket/*"] + }] + } + + expect_failures = [ + var.policy_statements, + ] +} + +run "not_action_and_not_resource_supported" { + command = plan + + variables { + policy_statements = [{ + effect = "Deny" + action_match_mode = "not_actions" + not_actions = ["iam:GetUser"] + resource_match_mode = "not_resources" + not_resources = ["arn:aws:iam::123456789012:user/break-glass"] + }] + } + + assert { + condition = var.policy_statements[0].not_actions == ["iam:GetUser"] + error_message = "Structured statements should accept NotAction values." + } + + assert { + condition = var.policy_statements[0].not_resources == ["arn:aws:iam::123456789012:user/break-glass"] + error_message = "Structured statements should accept NotResource values." + } +} + +run "action_and_not_action_rejected" { + command = plan + + variables { + policy_statements = [{ + actions = ["s3:GetObject"] + not_actions = ["s3:DeleteObject"] + resources = ["arn:aws:s3:::example-bucket/*"] + }] + } + + expect_failures = [ + var.policy_statements, + ] +} + +run "resource_and_not_resource_rejected" { + command = plan + + variables { + policy_statements = [{ + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::example-bucket/*"] + not_resources = ["arn:aws:s3:::example-bucket/private/*"] + }] + } + + expect_failures = [ + var.policy_statements, + ] +} + +run "wildcard_path_rejected" { + command = plan + + variables { + path = "/application*/" + } + + expect_failures = [ + var.path, + ] +} diff --git a/security/iam_policy/variables.tf b/security/iam_policy/variables.tf new file mode 100644 index 0000000..f5fbefe --- /dev/null +++ b/security/iam_policy/variables.tf @@ -0,0 +1,92 @@ +################################################################################ +# General +################################################################################ + +variable "name" { + type = string + description = "The name of the customer-managed IAM policy." + + validation { + condition = can(regex("^[A-Za-z0-9+=,.@_-]{1,128}$", var.name)) + error_message = "The name must contain 1-128 letters, numbers, plus, equals, comma, period, at, underscore, or hyphen characters." + } +} + +variable "description" { + type = string + description = "The description of the customer-managed IAM policy. AWS does not allow this description to be changed after creation." + default = "Managed by Terraform" + + validation { + condition = length(var.description) <= 1000 + error_message = "The description must be 1000 characters or fewer." + } +} + +variable "path" { + type = string + description = "The path for the customer-managed IAM policy." + default = "/" + + validation { + condition = length(var.path) <= 512 && !strcontains(var.path, "*") && (var.path == "/" || can(regex("^/.*/$", var.path))) + error_message = "The path must be 512 characters or fewer, start and end with '/', and not contain '*'." + } +} + +variable "tags" { + type = map(string) + description = "A map of tags to assign to the IAM policy." + default = {} +} + +################################################################################ +# Policy Document +################################################################################ + +variable "policy_statements" { + type = list(object({ + sid = optional(string) + effect = optional(string, "Allow") + actions = optional(list(string), []) + not_actions = optional(list(string), []) + resources = optional(list(string), []) + not_resources = optional(list(string), []) + conditions = optional(list(object({ + test = string + variable = string + values = list(string) + })), []) + })) + description = "Structured IAM policy statements. Ignored when policy_json is provided." + default = [] + + validation { + condition = alltrue([ + for statement in var.policy_statements : ( + contains(["Allow", "Deny"], statement.effect) && + ((length(statement.actions) > 0) != (length(statement.not_actions) > 0)) && + ((length(statement.resources) > 0) != (length(statement.not_resources) > 0)) && + alltrue([for condition in statement.conditions : length(condition.values) > 0]) + ) + ]) + error_message = "Each policy statement must use Allow or Deny, set exactly one of actions or not_actions, set exactly one of resources or not_resources, and include at least one value per condition." + } +} + +variable "policy_json" { + type = string + description = "A complete IAM policy JSON document. When provided, this overrides policy_statements." + default = null + + validation { + condition = var.policy_json == null || (can(jsondecode(var.policy_json)) && can(jsondecode(var.policy_json).Statement)) + error_message = "The policy_json must be valid JSON with a Statement property." + } +} + +variable "region" { + type = string + description = "AWS region used to configure the provider. IAM resources are global. When null, the provider's configured region is used." + default = null +} diff --git a/security/iam_policy/versions.tf b/security/iam_policy/versions.tf new file mode 100644 index 0000000..f2ea0cd --- /dev/null +++ b/security/iam_policy/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.10.0" + + cloud {} + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} From 474fbecf5805507157118d795b215be388abae86 Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Thu, 23 Jul 2026 15:36:18 -0400 Subject: [PATCH 2/3] updates --- security/iam_policy/README.md | 2 +- .../rvn-aws-iam-policy-definition.yml | 39 ++++++++++--------- security/iam_policy/tests/basic.tftest.hcl | 9 ++++- security/iam_policy/variables.tf | 4 +- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/security/iam_policy/README.md b/security/iam_policy/README.md index 32f1200..27b7232 100644 --- a/security/iam_policy/README.md +++ b/security/iam_policy/README.md @@ -75,7 +75,7 @@ policy_statements = [{ | Name | Description | Type | Default | Required | |------|-------------|------|---------|----------| | name | Customer-managed IAM policy name. | `string` | n/a | yes | -| description | Policy description. AWS does not allow updates after creation. | `string` | `"Managed by Terraform"` | no | +| description | Policy description. AWS does not allow updates after creation. | `string` | `null` | no | | path | IAM path for the policy. | `string` | `"/"` | no | | policy_statements | Structured IAM statements. Ignored when `policy_json` is set. | `list(object)` | `[]` | no | | policy_json | Complete IAM policy JSON document. | `string` | `null` | no | diff --git a/security/iam_policy/rvn-aws-iam-policy-definition.yml b/security/iam_policy/rvn-aws-iam-policy-definition.yml index d8e3de4..a5b1972 100644 --- a/security/iam_policy/rvn-aws-iam-policy-definition.yml +++ b/security/iam_policy/rvn-aws-iam-policy-definition.yml @@ -4,7 +4,7 @@ definition: description: Creates one reusable customer-managed AWS IAM policy from structured statements or a complete JSON policy document. release: version: 0.1.0 - description: Initial module definition with structured Action, NotAction, Resource, and NotResource statements. + description: Initial module definition with reliable structured policy statement titles and detailed guidance. module: inputs: - $include: ../../partials/inputs/aws-account.yml @@ -12,7 +12,7 @@ module: - id: section_policy label: IAM policy type: section - - default: <>-<>-<> + - default: <> description: Name of the customer-managed IAM policy. Policy names must be unique within their path in the AWS account. id: name immutable: true @@ -22,8 +22,7 @@ module: pattern: ^[A-Za-z0-9+=,.@_-]{1,128}$ required: true type: string - - default: Managed by Terraform - description: Description stored on the IAM policy. AWS does not allow this value to be changed after creation. + - description: Optional description stored on the IAM policy. AWS does not allow this value to be changed after creation. id: description label: Description patterns: @@ -60,7 +59,7 @@ module: - label: JSON document value: json - default: [] - description: IAM permission statements included in the customer-managed policy. + description: Define permissions this policy grants or denies. Each statement matches Action or NotAction values against Resource or NotResource ARNs and can add conditions to narrow access. Prefer explicit actions and resource ARNs over wildcards. id: policy_statements item_inputs: - description: Optional IAM statement ID. @@ -79,7 +78,7 @@ module: value: Allow - label: Deny value: Deny - - default: actions + - default: Action description: Match the listed actions or every applicable action except those listed. id: action_match_mode label: Action matching @@ -87,9 +86,9 @@ module: type: string values: - label: Listed actions - value: actions + value: Action - label: All except listed actions - value: not_actions + value: NotAction - add_button_label: Add action description: IAM actions granted or denied by this statement. id: actions @@ -97,7 +96,7 @@ module: placeholder: s3:GetObject required: true show_when: - action_match_mode: actions + action_match_mode: Action type: string_array - add_button_label: Add excluded action description: IAM actions excluded from this statement's match. NotAction can create broad permissions and should be used carefully. @@ -106,9 +105,9 @@ module: placeholder: iam:* required: true show_when: - action_match_mode: not_actions + action_match_mode: NotAction type: string_array - - default: resources + - default: Resource description: Match the listed resources or every applicable resource except those listed. id: resource_match_mode label: Resource matching @@ -116,9 +115,9 @@ module: type: string values: - label: Listed resources - value: resources + value: Resource - label: All except listed resources - value: not_resources + value: NotResource - add_button_label: Add resource description: Resource ARNs covered by this statement. Use wildcards only when the AWS service requires them. id: resources @@ -126,7 +125,7 @@ module: placeholder: arn:aws:s3:::my-bucket/* required: true show_when: - resource_match_mode: resources + resource_match_mode: Resource type: string_array - add_button_label: Add excluded resource description: Resource ARNs excluded from this statement's match. NotResource can create broad permissions and should be used carefully. @@ -135,7 +134,7 @@ module: placeholder: arn:aws:s3:::my-bucket/protected/* required: true show_when: - resource_match_mode: not_resources + resource_match_mode: NotResource type: string_array - default: [] description: Optional IAM conditions that further restrict this statement. @@ -162,13 +161,15 @@ module: required: false type: object_array item_label: Policy statement + item_title: + template: "{effect}: {action_match_mode} on {resource_match_mode}" + fallback: Policy statement label: Policy statements required: true show_when: policy_document_mode: structured type: object_array - - collapsible: true - description: Complete IAM policy JSON document. This is used instead of structured statements. + - description: Complete IAM policy JSON document. This is used instead of structured statements. id: policy_json label: Policy JSON patterns: @@ -249,8 +250,8 @@ module: | --- | --- | --- | --- | | AWS account | Yes | None | AWS account where the customer-managed policy is created. | | Region | Yes | None | Runner region for the stack. IAM resources are global, but Terraform still runs with an AWS region. | - | Policy name | Yes | Project, environment, and module given IDs | Must be unique within the selected path in the AWS account. | - | Description | No | Managed by Terraform | AWS does not allow a managed policy description to be edited after creation. | + | Policy name | Yes | Module given ID | Must be unique within the selected path in the AWS account. | + | Description | No | None | AWS does not allow a managed policy description to be edited after creation. | | Path | No | / | IAM path used to organize the policy. | | Policy document format | Yes | Structured statements | Chooses between the statement editor and complete JSON. | | Policy statements | In structured mode | Empty list | At least one statement is required when structured mode is selected. | diff --git a/security/iam_policy/tests/basic.tftest.hcl b/security/iam_policy/tests/basic.tftest.hcl index 82e8658..31a3829 100644 --- a/security/iam_policy/tests/basic.tftest.hcl +++ b/security/iam_policy/tests/basic.tftest.hcl @@ -57,6 +57,11 @@ run "structured_policy_defaults" { error_message = "The policy path should default to root." } + assert { + condition = aws_iam_policy.this.description == null + error_message = "The policy description should default to null." + } + assert { condition = aws_iam_policy.this.tags["ManagedBy"] == "terraform" error_message = "The ManagedBy tag should be present." @@ -157,9 +162,9 @@ run "not_action_and_not_resource_supported" { variables { policy_statements = [{ effect = "Deny" - action_match_mode = "not_actions" + action_match_mode = "NotAction" not_actions = ["iam:GetUser"] - resource_match_mode = "not_resources" + resource_match_mode = "NotResource" not_resources = ["arn:aws:iam::123456789012:user/break-glass"] }] } diff --git a/security/iam_policy/variables.tf b/security/iam_policy/variables.tf index f5fbefe..bb57ed5 100644 --- a/security/iam_policy/variables.tf +++ b/security/iam_policy/variables.tf @@ -15,10 +15,10 @@ variable "name" { variable "description" { type = string description = "The description of the customer-managed IAM policy. AWS does not allow this description to be changed after creation." - default = "Managed by Terraform" + default = null validation { - condition = length(var.description) <= 1000 + condition = var.description == null || length(var.description) <= 1000 error_message = "The description must be 1000 characters or fewer." } } From 40a29a498efb0ad11788a6e1ca2a466b974d585c Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Thu, 23 Jul 2026 17:13:49 -0400 Subject: [PATCH 3/3] code review --- security/iam_policy/locals.tf | 7 +------ security/iam_policy/rvn-aws-iam-policy-definition.yml | 5 +++-- security/iam_policy/tests/basic.tftest.hcl | 8 +++----- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/security/iam_policy/locals.tf b/security/iam_policy/locals.tf index 78e6898..2a93c40 100644 --- a/security/iam_policy/locals.tf +++ b/security/iam_policy/locals.tf @@ -8,10 +8,5 @@ locals { tags = merge(local.default_tags, var.tags) - policy_document = var.policy_json != null ? var.policy_json : ( - length(var.policy_statements) > 0 ? data.aws_iam_policy_document.structured[0].json : jsonencode({ - Version = "2012-10-17" - Statement = [] - }) - ) + policy_document = var.policy_json != null ? var.policy_json : one(data.aws_iam_policy_document.structured[*].json) } diff --git a/security/iam_policy/rvn-aws-iam-policy-definition.yml b/security/iam_policy/rvn-aws-iam-policy-definition.yml index a5b1972..1d6081c 100644 --- a/security/iam_policy/rvn-aws-iam-policy-definition.yml +++ b/security/iam_policy/rvn-aws-iam-policy-definition.yml @@ -4,7 +4,7 @@ definition: description: Creates one reusable customer-managed AWS IAM policy from structured statements or a complete JSON policy document. release: version: 0.1.0 - description: Initial module definition with reliable structured policy statement titles and detailed guidance. + description: Initial module definition with immutable policy identity fields and structured statement support. module: inputs: - $include: ../../partials/inputs/aws-account.yml @@ -24,6 +24,7 @@ module: type: string - description: Optional description stored on the IAM policy. AWS does not allow this value to be changed after creation. id: description + immutable: true label: Description patterns: - message: 1000 characters or fewer. @@ -266,7 +267,7 @@ module: Structured statements require an explicit Action or NotAction choice and an explicit Resource or NotResource choice. The module does not add wildcard permissions automatically. - Policy name and path are immutable in Ravion because they define the managed policy's identity. Description remains editable, but AWS may replace the policy when it changes. + Policy name, description, and path are immutable in Ravion because AWS replaces the managed policy when any of these values change. Replacement would sever attachments managed outside this module. Ravion adds standard tags for ownership and traceability. User-provided Tags are merged on top for team, cost, or environment metadata. diff --git a/security/iam_policy/tests/basic.tftest.hcl b/security/iam_policy/tests/basic.tftest.hcl index 31a3829..10ed10c 100644 --- a/security/iam_policy/tests/basic.tftest.hcl +++ b/security/iam_policy/tests/basic.tftest.hcl @@ -161,11 +161,9 @@ run "not_action_and_not_resource_supported" { variables { policy_statements = [{ - effect = "Deny" - action_match_mode = "NotAction" - not_actions = ["iam:GetUser"] - resource_match_mode = "NotResource" - not_resources = ["arn:aws:iam::123456789012:user/break-glass"] + effect = "Deny" + not_actions = ["iam:GetUser"] + not_resources = ["arn:aws:iam::123456789012:user/break-glass"] }] }