diff --git a/.github/labels.yaml b/.github/labels.yaml
new file mode 100644
index 0000000..e961fe2
--- /dev/null
+++ b/.github/labels.yaml
@@ -0,0 +1,28 @@
+---
+- name: breaking
+ color: "b60205"
+ description: This change is not backwards compatible
+- name: bug
+ color: "d93f0b"
+ description: Something isn't working
+- name: documentation
+ color: "0075ca"
+ description: Improvements or additions to documentation
+- name: enhancement
+ color: "0e8a16"
+ description: New feature or request
+- name: feature
+ color: "0e8a16"
+ description: New feature or request
+- name: fix
+ color: "d93f0b"
+ description: Something isn't working
+- name: misc
+ color: "#6B93D3"
+ description: Miscellaneous task not covered by something else
+- name: no-changelog
+ color: "cccccc"
+ description: No entry should be added to the release notes and changelog
+- name: security
+ color: "5319e7"
+ description: Solving a security issue
diff --git a/.github/release-drafter-config.yaml b/.github/release-drafter-config.yaml
new file mode 100644
index 0000000..3d4047a
--- /dev/null
+++ b/.github/release-drafter-config.yaml
@@ -0,0 +1,86 @@
+name-template: 'v$RESOLVED_VERSION'
+tag-template: 'v$RESOLVED_VERSION'
+version-template: '$MAJOR.$MINOR.$PATCH'
+change-title-escapes: '\<*_&'
+
+categories:
+ - title: '๐ Features'
+ labels:
+ - 'breaking'
+ - 'enhancement'
+ - 'feature'
+ - title: '๐ Bug Fixes'
+ labels:
+ - 'bug'
+ - 'fix'
+ - 'security'
+ - title: '๐ Documentation'
+ labels:
+ - 'documentation'
+ - title: '๐งบ Miscellaneous'
+ labels:
+ - 'misc'
+
+version-resolver:
+ major:
+ labels:
+ - 'breaking'
+ minor:
+ labels:
+ - 'enhancement'
+ - 'feature'
+ patch:
+ labels:
+ - 'bug'
+ - 'documentation'
+ - 'fix'
+ - 'security'
+ default: 'minor'
+
+autolabeler:
+ - label: 'documentation'
+ body:
+ - '/documentation/'
+ branch:
+ - '/docs\/.+/'
+ title:
+ - '/documentation/i'
+ - '/docs/i'
+ - label: 'bug'
+ body:
+ - '/bug/'
+ branch:
+ - '/bug\/.+/'
+ - '/fix\/.+/'
+ title:
+ - '/bug/i'
+ - '/fix/i'
+ - label: 'feature'
+ branch:
+ - '/feature\/.+/'
+ - '/enhancement\/.+/'
+ title:
+ - '/feature/i'
+ - '/feat/i'
+ - '/enhancement/i'
+ - label: 'breaking'
+ body:
+ - '/breaking/'
+ branch:
+ - '/breaking\/.+/'
+ title:
+ - '/breaking/i'
+ - '/major/i'
+
+exclude-contributors:
+ - 'github-actions[bot]'
+
+exclude-labels:
+ - 'no-changelog'
+
+template: |
+ # What's Changed
+
+ $CHANGES
+
+ **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
diff --git a/.github/workflows/label-synchronization.yaml b/.github/workflows/label-synchronization.yaml
new file mode 100644
index 0000000..0c241b8
--- /dev/null
+++ b/.github/workflows/label-synchronization.yaml
@@ -0,0 +1,29 @@
+name: label-synchronization
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - master
+ paths:
+ - .github/labels.yaml
+ - .github/workflows/label-sync.yaml
+
+permissions:
+ # write permission is required to edit issue labels
+ issues: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Synchronize labels
+ uses: crazy-max/ghaction-github-labeler@v5
+ with:
+ dry-run: false
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ skip-delete: false
+ yaml-file: .github/labels.yaml
diff --git a/.github/workflows/pr-validation.yaml b/.github/workflows/pr-validation.yaml
new file mode 100644
index 0000000..dbbdeae
--- /dev/null
+++ b/.github/workflows/pr-validation.yaml
@@ -0,0 +1,104 @@
+name: "pr-validation"
+
+on:
+ pull_request:
+
+permissions:
+ checks: write
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ autolabeler:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: release-drafter/release-drafter@v6
+ with:
+ config-name: release-drafter-config.yaml
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ title-checker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@v5
+ id: lint_pr_title
+ with:
+ types: |
+ breaking
+ bug
+ docs
+ documentation
+ enhancement
+ feat
+ feature
+ fix
+ misc
+ security
+ requireScope: false
+ ignoreLabels: |
+ skip-changelog
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: marocchino/sticky-pull-request-comment@v2
+ # When the previous steps fails, the workflow would stop. By adding this
+ # condition you can continue the execution with the populated error message.
+ if: always() && (steps.lint_pr_title.outputs.error_message != null)
+ with:
+ header: pr-title-lint-error
+ message: |
+ Hey there and thank you for opening this pull request! ๐๐ผ
+
+ We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
+
+ Examples for valid PR titles:
+ feat(ui): Add button component.
+ fix: Correct typo.
+ _type(scope): subject._
+
+ Adding a scope is optional
+
+ Details:
+ ```
+ ${{ steps.lint_pr_title.outputs.error_message }}
+ ```
+
+ # Delete a previous comment when the issue has been resolved
+ - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: pr-title-lint-error
+ delete: true
+
+ label-checker:
+ needs: autolabeler
+ runs-on: ubuntu-latest
+ steps:
+ - uses: danielchabr/pr-labels-checker@v3.3
+ id: lint_pr_labels
+ with:
+ hasSome: breaking,bug,documentation,enhancement,feature,fix,misc,security
+ githubToken: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: marocchino/sticky-pull-request-comment@v2
+ # When the previous steps fails, the workflow would stop. By adding this
+ # condition you can continue the execution with the populated error message.
+ if: always() && (steps.lint_pr_labels.outputs.passed == false)
+ with:
+ header: pr-labels-lint-error
+ message: |
+ Hey there and thank you for opening this pull request! ๐๐ผ
+
+ The PR needs to have at least one of the following labels: breaking, bug, documentation, enhancement, feature, fix, misc, security.
+
+ # Delete a previous comment when the issue has been resolved
+ - if: ${{ steps.lint_pr_labels.outputs.passed != false }}
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: pr-labels-lint-error
+ delete: true
diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml
new file mode 100644
index 0000000..b5e0cc3
--- /dev/null
+++ b/.github/workflows/release-drafter.yaml
@@ -0,0 +1,29 @@
+name: "release-drafter"
+
+on:
+ push:
+ branches:
+ - main
+ - master
+ paths-ignore:
+ - .github/**
+ - .pre-commit-config.yaml
+ - CHANGELOG.md
+ - CONTRIBUTING.md
+ - LICENSE
+
+permissions:
+ # write permission is required to create a github release
+ contents: write
+
+jobs:
+ draft:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: release-drafter/release-drafter@v6
+ with:
+ publish: false
+ prerelease: false
+ config-name: release-drafter-config.yaml
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/terraform-validation.yaml b/.github/workflows/terraform-validation.yaml
new file mode 100644
index 0000000..dc704dd
--- /dev/null
+++ b/.github/workflows/terraform-validation.yaml
@@ -0,0 +1,163 @@
+name: "terraform"
+
+on:
+ pull_request:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TF_IN_AUTOMATION: 1
+
+jobs:
+ fmt-lint-validate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v2
+
+ - name: Setup Terraform Linters
+ uses: terraform-linters/setup-tflint@v4
+ with:
+ github_token: ${{ github.token }}
+
+ - name: Terraform Format
+ id: fmt
+ run: terraform fmt -check -recursive
+
+ - name: Terraform Lint
+ id: lint
+ run: |
+ echo "Checking ."
+ tflint --format compact
+
+ for d in examples/*/; do
+ echo "Checking ${d} ..."
+ tflint --chdir=$d --format compact
+ done
+
+ - name: Terraform Validate
+ id: validate
+ if: ${{ !vars.SKIP_TERRAFORM_VALIDATE }}
+ run: |
+ for d in examples/*/; do
+ echo "Checking ${d} ..."
+ terraform -chdir=$d init
+ terraform -chdir=$d validate -no-color
+ done
+ env:
+ AWS_DEFAULT_REGION: eu-west-1
+
+ - uses: actions/github-script@v6
+ if: github.event_name == 'pull_request' || always()
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ // 1. Retrieve existing bot comments for the PR
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ })
+ const botComment = comments.find(comment => {
+ return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
+ })
+
+ // 2. Prepare format of the comment
+ const output = `#### Terraform Format and Style ๐\`${{ steps.fmt.outcome }}\`
+ #### Terraform Initialization โ๏ธ\`${{ steps.init.outcome }}\`
+ #### Terraform Lint ๐\`${{ steps.lint.outcome }}\`
+ #### Terraform Validation ๐ค\`${{ steps.validate.outcome }}\`
+ Validation Output
+
+ \`\`\`\n
+ ${{ steps.validate.outputs.stdout }}
+ \`\`\`
+
+
The following arguments are supported:
- `name` - (Required) The name of the Key Vault.
- `tenant_id` - (Required) The Azure Active Directory tenant ID that should be used for authenticating requests to the Key Vault.
- `resource_group_name` - (Optional) The name of the resource group in which to create the Key Vault. If not provided, the resource group of the calling module will be used.
- `location` - (Optional) The location of the Key Vault. If not provided, the location of the calling module will be used.
- `enabled_for_disk_encryption` - (Optional) Specifies whether Azure Disk Encryption is permitted to retrieve secrets from the vault and unwrap keys.
- `enabled_for_deployment` - (Optional) Specifies whether Azure Resource Manager is permitted to retrieve secrets from the vault.
- `enabled_for_template_deployment` - (Optional) Specifies whether Azure Resource Manager is permitted to retrieve secrets from the vault.
- `enable_rbac_authorization` - (Optional) Specifies whether Azure RBAC is permitted to retrieve secrets from the vault.
- `purge_protection` - (Optional) Specifies whether protection against purge is enabled for this Key Vault.
- `soft_delete_retention_days` - (Optional) The number of days that items should be retained for once soft deleted.
- `sku` - (Optional) The SKU of the Key Vault.
- `ip_rules` - (Optional) List of IP addresses that are permitted to access the key vault.
- `subnet_id` - (Optional) List of subnet IDs that are permitted to access the key vault.
- `network_bypass` - (Optional) Specifies which traffic can bypass the network rules.
- `cmkrsa_keyname` - (Optional) The name of the customer managed key with RSA algorithm to create.
- `cmkec_keyname` - (Optional) The name of the customer managed key with EC algorithm to create.
- `cmk_keys_create` - (Optional) Specifies whether to create custom managed keys.
Example Inputs:
hcl|
key_vault = {
name = "my-key-vault"
tenant_id = "00000000-0000-0000-0000-000000000000"
enabled_for_disk_encryption = true
enabled_for_deployment = true
enabled_for_template_deployment = true
enable_rbac_authorization = true
purge_protection = true
soft_delete_retention_days = 30
sku = "standard"
cmkrsa_keyname = "cmkrsa"
cmkec_keyname = "cmkec"
cmk_keys_create = true
object({| n/a | yes | +| [tags](#input\_tags) | A mapping of tags to assign to the resources. | `map(string)` | n/a | yes | +| [key\_vault\_key](#input\_key\_vault\_key) | This map describes the configuration for Azure Key Vault keys.
name = string
tenant_id = string
resource_group_name = optional(string, null)
location = optional(string, null)
enabled_for_disk_encryption = optional(bool, false)
enabled_for_deployment = optional(bool, false)
enabled_for_template_deployment = optional(bool, false)
enable_rbac_authorization = optional(bool, true)
purge_protection = optional(bool, true)
soft_delete_retention_days = optional(number, 30)
sku = optional(string, "standard")
ip_rules = optional(list(string), [])
subnet_id = optional(list(string), [])
network_bypass = optional(string, "None")
cmkrsa_keyname = optional(string, "cmkrsa")
cmkec_keyname = optional(string, "cmkec")
cmk_keys_create = optional(bool, false)
cmk_rotation_period = optional(string, "P90D")
tags = optional(map(string), {})
})
hcl|
key_vault_key = {
key_name = {
type = "RSA"
size = 4096
opts = ["encrypt", "decrypt", "sign", "verify", "wrapKey", "unwrapKey"]
}
key_ec = {
type = "EC"
curve = "P-256"
opts = ["sign", "verify"]
}
}
map(object({| `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [key\_vault\_cmkrsa\_id](#output\_key\_vault\_cmkrsa\_id) | CMK RSA Key ID | +| [key\_vault\_cmkrsa\_keyname](#output\_key\_vault\_cmkrsa\_keyname) | CMK RSA Key Name | +| [key\_vault\_id](#output\_key\_vault\_id) | n/a | +| [key\_vault\_name](#output\_key\_vault\_name) | n/a | +| [key\_vault\_uri](#output\_key\_vault\_uri) | n/a | + + +## License + +**Copyright:** Schuberg Philis + +```text +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..2fb412b --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,34 @@ +# https://taskfile.dev + +version: "3" + +env: + TF_IN_AUTOMATION: 1 + +tasks: + default: + cmds: + - cmd: task --list + ignore_error: true + silent: true + + clean: + desc: Clean lock files and cache directories + cmds: + - rm -rf .terraform.lock.hcl .terraform + - rm -rf **/.terraform.lock.hcl **/.terraform + silent: true + + test: + desc: Run Terraform tests + cmds: + - terraform init + - terraform test + silent: true + + verbose-test: + desc: Run verbose Terraform tests + cmds: + - terraform init + - terraform test -verbose + silent: true diff --git a/examples/basic/main.tf b/examples/basic/main.tf new file mode 100644 index 0000000..4b49e5b --- /dev/null +++ b/examples/basic/main.tf @@ -0,0 +1,34 @@ +terraform { + required_version = ">= 1.7" +} + +module "azure_core" { + source = "../.." + + resource_group = { + name = "my-resource-group" + location = "East US" + } + + key_vault = { + name = "my-key-vault" + tenant_id = "your-tenant-id" + enabled_for_disk_encryption = true + enabled_for_deployment = false + enabled_for_template_deployment = false + enable_rbac_authorization = true + purge_protection = true + soft_delete_retention_days = 30 + sku = "standard" + ip_rules = [] + subnet_id = [] + network_bypass = "AzureServices" + cmkrsa_keyname = "cmkrsa" + cmkec_keyname = "cmkec" + cmk_keys_create = true + } + + tags = { + Environment = "Production" + } +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..bc65727 --- /dev/null +++ b/main.tf @@ -0,0 +1,96 @@ +data "azurerm_client_config" "current" {} + +resource "azurerm_key_vault" "this" { + resource_group_name = var.key_vault.resource_group_name + location = var.key_vault.location + name = var.key_vault.name + tenant_id = var.key_vault.tenant_id + sku_name = var.key_vault.sku + enabled_for_disk_encryption = var.key_vault.enabled_for_disk_encryption + enabled_for_deployment = var.key_vault.enabled_for_deployment + enabled_for_template_deployment = var.key_vault.enabled_for_template_deployment + enable_rbac_authorization = var.key_vault.enable_rbac_authorization + purge_protection_enabled = var.key_vault.purge_protection + soft_delete_retention_days = var.key_vault.soft_delete_retention_days + + network_acls { + default_action = length(var.key_vault.ip_rules) == 0 && length(var.key_vault.subnet_id) == 0 ? "Allow" : "Deny" + ip_rules = var.key_vault.ip_rules + virtual_network_subnet_ids = var.key_vault.subnet_id + bypass = var.key_vault.network_bypass + } + + tags = merge( + try(var.tags), + try(var.key_vault.tags), + tomap({ + "Resource Type" = "Key vault" + }) + ) +} + +resource "azurerm_role_assignment" "this" { + scope = azurerm_key_vault.this.id + role_definition_name = "Key Vault Administrator" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "azurerm_key_vault_key" "cmkrsa" { + count = var.key_vault.cmk_keys_create ? 1 : 0 + + name = var.key_vault.cmkrsa_keyname + key_vault_id = azurerm_key_vault.this.id + key_type = "RSA" + key_size = 4096 + key_opts = [ + "unwrapKey", + "wrapKey" + ] + + rotation_policy { + automatic { + time_after_creation = var.key_vault.cmk_rotation_period + } + } + + depends_on = [ + azurerm_role_assignment.this + ] +} + +resource "azurerm_key_vault_key" "this" { + for_each = var.key_vault_key != null ? var.key_vault_key : {} + + key_opts = each.value.opts + key_type = each.value.type + key_vault_id = azurerm_key_vault.this.id + name = each.value.name == null ? each.key : each.value.name + curve = each.value.curve + expiration_date = each.value.expiration_date + key_size = each.value.size + not_before_date = each.value.not_before_date + + dynamic "rotation_policy" { + for_each = each.value.rotation_policy != null ? [each.value.rotation_policy] : [] + content { + expire_after = rotation_policy.value.expire_after + notify_before_expiry = rotation_policy.value.notify_before_expiry + + automatic { + time_before_expiry = rotation_policy.value.automatic.time_before_expiry + } + } + } + + tags = merge( + try(var.tags), + try(each.value.tags), + tomap({ + "Resource Type" = "Key vault key" + }) + ) + + depends_on = [ + azurerm_role_assignment.this + ] +} \ No newline at end of file diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..55654eb --- /dev/null +++ b/outputs.tf @@ -0,0 +1,21 @@ +output "key_vault_id" { + value = azurerm_key_vault.this.id +} + +output "key_vault_name" { + value = azurerm_key_vault.this.name +} + +output "key_vault_uri" { + value = azurerm_key_vault.this.vault_uri +} + +output "key_vault_cmkrsa_keyname" { + value = one(azurerm_key_vault_key.cmkrsa[*].name) + description = "CMK RSA Key Name" +} + +output "key_vault_cmkrsa_id" { + value = one(azurerm_key_vault_key.cmkrsa[*].id) + description = "CMK RSA Key ID" +} diff --git a/terraform.tf b/terraform.tf new file mode 100644 index 0000000..ca9101b --- /dev/null +++ b/terraform.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4" + } + } +} diff --git a/tests/basic.tftest.hcl b/tests/basic.tftest.hcl new file mode 100644 index 0000000..db981a4 --- /dev/null +++ b/tests/basic.tftest.hcl @@ -0,0 +1,47 @@ +run "basic" { + variables { + resource_group = { + name = "my-resource-group" + location = "East US" + } + + key_vault = { + name = "kv001" + tenant_id = "your-tenant-id" + enabled_for_disk_encryption = false + enabled_for_deployment = false + enabled_for_template_deployment = false + enable_rbac_authorization = true + purge_protection = true + soft_delete_retention_days = 30 + sku = "standard" + ip_rules = [] + subnet_id = [] + network_bypass = "None" + cmkrsa_keyname = "cmkrsa" + cmkec_keyname = "cmkec" + cmk_keys_create = true + } + + tags = { + Environment = "Production" + } + } + + module { + source = "./" + } + + command = plan + + assert { + condition = output.key_vault_name == "kv001" + error_message = "Unexpected output.key_vault_name value" + } + + assert { + condition = output.cmk_ec_keyname == "cmkec" + error_message = "Unexpected output.cmk_ec_keyname value" + } + +} \ No newline at end of file diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..88ef172 --- /dev/null +++ b/variables.tf @@ -0,0 +1,118 @@ +variable "key_vault" { + type = object({ + name = string + tenant_id = string + resource_group_name = optional(string, null) + location = optional(string, null) + enabled_for_disk_encryption = optional(bool, false) + enabled_for_deployment = optional(bool, false) + enabled_for_template_deployment = optional(bool, false) + enable_rbac_authorization = optional(bool, true) + purge_protection = optional(bool, true) + soft_delete_retention_days = optional(number, 30) + sku = optional(string, "standard") + ip_rules = optional(list(string), []) + subnet_id = optional(list(string), []) + network_bypass = optional(string, "None") + cmkrsa_keyname = optional(string, "cmkrsa") + cmkec_keyname = optional(string, "cmkec") + cmk_keys_create = optional(bool, false) + cmk_rotation_period = optional(string, "P90D") + tags = optional(map(string), {}) + }) + nullable = false + description = <
name = optional(string, null)
curve = optional(string, null)
size = optional(number, null)
type = optional(string, null)
opts = optional(list(string), null)
expiration_date = optional(string, null)
not_before_date = optional(string, null)
rotation_policy = optional(object({
automatic = optional(object({
time_after_creation = optional(string, null)
time_before_expiry = optional(string, null)
}), null)
expire_after = optional(string, null)
notify_before_expiry = optional(string, null)
}), null)
tags = optional(map(string), {})
}))