diff --git a/.github/workflows/enos-tests.yaml b/.github/workflows/enos-tests.yaml new file mode 100644 index 0000000..4892ef5 --- /dev/null +++ b/.github/workflows/enos-tests.yaml @@ -0,0 +1,58 @@ +--- +name: test + +on: + pull_request: + +concurrency: + group: ${{ github.head_ref || github.run_id }}-test + cancel-in-progress: true + +jobs: + build: + name: build + runs-on: ubuntu-latest + outputs: + linux-amd64-artifact: ${{ steps.outputs.outputs.linux-amd64-artifact }} + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: go.mod + - id: build + run: | + GOOS=linux GOARCH=amd64 make dev + - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: vault-plugin-secrets-openldap_linux_amd64 + path: bin/vault-plugin-secrets-openldap + if-no-files-found: error + retention-days: 1 + - id: outputs + run: | + echo "linux-amd64-artifact=vault-plugin-secrets-openldap_linux_amd64" | tee -a "$GITHUB_OUTPUT" + + scenarios: + name: enos scenario + needs: build + uses: ./.github/workflows/run-sample.yml + secrets: inherit + with: + sample-name: build_ent_linux_amd64_deb + download: ${{ needs.build.outputs.linux-amd64-artifact }} + max: 1 + + completed-successfully: + if: always() + runs-on: ubuntu-latest + needs: + - build + - scenarios + steps: + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/run-sample.yml b/.github/workflows/run-sample.yml new file mode 100644 index 0000000..43a056b --- /dev/null +++ b/.github/workflows/run-sample.yml @@ -0,0 +1,69 @@ +--- +name: run-samples + +on: + workflow_dispatch: + inputs: + max: + description: The maximum number of scenarios to sample + type: number + default: 8 + min: + description: The minimum number of scenarios to sample + type: number + default: 1 + sample-name: + description: The sample name + required: true + type: string + workflow_call: + inputs: + download: + description: The name of the artifact to download + type: string + required: true + max: + description: The maximum number of scenarios to sample + type: number + default: 8 + min: + description: The minimum number of scenarios to sample + type: number + default: 1 + sample-name: + description: The sample name + required: true + type: string + +jobs: + sample: + name: sample observe + runs-on: ubuntu-latest + outputs: + sample: ${{ steps.metadata.outputs.sample }} + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: hashicorp/action-setup-enos@v1 + with: + github-token: ${{ secrets.ELEVATED_GITHUB_TOKEN }} + - id: metadata + run: | + sample_seed=$(date +%s%N) + sample=$(enos scenario sample observe ${{ inputs.sample-name }} --chdir ./enos --min ${{ inputs.min }} --max ${{ inputs.max }} --seed "${sample_seed}" --format json | jq -c ".observation.elements") + { + echo "sample=${sample}" + echo "sample-seed=${sample_seed}" # This isn't used outside of here but is nice to know for duplicating observations + } | tee -a "$GITHUB_OUTPUT" + + run: + needs: sample + name: run ${{ matrix.scenario.id.filter }} + strategy: + fail-fast: false # don't fail as that can skip required cleanup steps for jobs + matrix: + include: ${{ fromJSON(needs.sample.outputs.sample) }} + uses: ./.github/workflows/run-scenario.yml + secrets: inherit + with: + scenario-filter: ${{ matrix.scenario.id.filter }} + download: ${{ inputs.download }} \ No newline at end of file diff --git a/.github/workflows/run-scenario.yml b/.github/workflows/run-scenario.yml new file mode 100644 index 0000000..466a247 --- /dev/null +++ b/.github/workflows/run-scenario.yml @@ -0,0 +1,127 @@ +--- +name: run-scenario + +on: + workflow_dispatch: + inputs: + scenario-filter: + description: The filter of the scenario to run + required: true + type: string + workflow_call: + inputs: + download: + type: string + required: false + scenario-filter: + required: true + type: string + +jobs: + run: + name: ${{ inputs.scenario-filter }} + runs-on: ubuntu-latest + env: + ENOS_DEBUG_DATA_ROOT_DIR: /tmp/enos/logs + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + # The github actions service user creds for this account managed in hashicorp/enos-ci + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: "us-east-1" + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + role-skip-session-tagging: true + - uses: hashicorp/setup-terraform@v3 + with: + # the terraform wrapper will break terraform execution in enos because + # it changes the output to text when we expect it to be JSON. + terraform_wrapper: false + - uses: hashicorp/action-setup-enos@v1 + with: + github-token: + ${{ secrets.ELEVATED_GITHUB_TOKEN }} + - name: Set up support files + run: | + mkdir -p enos/support + mkdir -p enos/support/vault-plugins + echo "${{ secrets.ENOS_CI_SSH_KEY }}" > enos/support/enos-ci-ssh-key.pem + echo "${{ secrets.VAULT_LICENSE }}" > enos/support/vault.hclic + chmod 600 enos/support/enos-ci-ssh-key.pem + chmod 600 enos/support/vault.hclic + - name: Download plugin artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + path: dist + name: ${{ inputs.download }} + - run: chmod +x ./dist/* + - name: Export OpenLDAP plugin vars + run: | + # Configure input environment variables. + { + echo "GITHUB_TOKEN=${ { steps.secrets.outputs.github-token } }" + echo "ENOS_DEBUG_DATA_ROOT_DIR=/tmp/enos-debug-data" + echo "ENOS_VAR_artifactory_token=${ { steps.secrets.outputs.artifactory-token } }" + echo "ENOS_VAR_artifactory_host=https://artifactory.hashicorp.engineering/artifactory" + echo "ENOS_VAR_artifactory_repo=hashicorp-crt-stable-local*" + echo "ENOS_VAR_aws_region="us-east-1" + echo "ENOS_VAR_aws_ssh_keypair_name=${ { inputs.ssh-key-name } }" + echo "ENOS_VAR_aws_ssh_private_key_path=./support/private_key.pem" + echo "ENOS_VAR_project_name=vault-openldap-se-enos-integration" + echo "ENOS_VAR_backend_license_path=./support/vault.hclic" + echo "ENOS_VAR_distro_version_amzn=2" + echo "ENOS_VAR_distro_version_ubuntu=22.04" + echo "ENOS_VAR_tags={\"Project Name\":\"Vault\",\"Something Cool\":\"Value\"}" + echo "ENOS_VAR_terraform_plugin_cache_dir=./support/terraform-plugin-cache" + echo "ENOS_VAR_vault_artifact_path=./support/downloads/${ { inputs.build-artifact-name } }" + echo "ENOS_VAR_vault_artifact_type=bundle" + echo "ENOS_VAR_vault_build_date=${ { needs.metadata.outputs.build-date } }" + echo "ENOS_VAR_vault_license_path=./support/vault.hclic" + echo "ENOS_VAR_vault_product_version=${ { needs.metadata.outputs.vault-version } }" + echo "ENOS_VAR_vault_revision=${ { inputs.vault-revision } }" + echo "ENOS_VAR_vault_enable_audit_devices=true" + echo "ENOS_VAR_vault_install_dir=/opt/vault/bin" + echo "ENOS_VAR_vault_instance_type=t3.small" + echo "ENOS_VAR_vault_log_level=trace" + echo "ENOS_VAR_verify_aws_secrets_engine=false" + echo "ENOS_VAR_verify_log_secrets=true" + + # Default LDAP settings from enos.vars.hcl + echo "ENOS_VAR_plugin_name=vault-plugin-secrets-openldap" + echo "ENOS_VAR_plugin_dest_dir=$(pwd)/enos/support/vault-plugins" + echo "ENOS_VAR_ldap_artifact_path=$(pwd)/dist/${{ inputs.download }}" + echo "ENOS_VAR_makefile_dir=$(pwd)" + echo "ENOS_VAR_plugin_dir_vault=/etc/vault/plugins" + echo "ENOS_VAR_plugin_mount_path=local-secrets-ldap" + echo "ENOS_VAR_ldap_bind_dn=cn=admin,dc=example,dc=com" + echo "ENOS_VAR_ldap_bind_pass=adminpassword" + echo "ENOS_VAR_ldap_user_dn=ou=users,dc=example,dc=com" + echo "ENOS_VAR_ldap_schema=openldap" + echo "ENOS_VAR_ldap_tag=${ { inputs.ldap-tag } }" + echo "ENOS_VAR_ldap_revision=${ { inputs.ldap-revision } }" + echo "ENOS_VAR_ldap_artifactory_repo=hashicorp-vault-ecosystem-staging-local" + echo "ENOS_VAR_ldap_plugin_version=${ { inputs.ldap-plugin-version } }" + echo "ENOS_VAR_ldap_base_dn=dc=example,dc=com" + echo "ENOS_VAR_ldap_user_role_name=mary" + echo "ENOS_VAR_ldap_username=mary.smith" + echo "ENOS_VAR_ldap_user_old_password=defaultpassword" + echo "ENOS_VAR_ldap_dynamic_user_role_name=adam" + echo "ENOS_VAR_dynamic_role_ldif_templates_path=/tmp" + echo "ENOS_VAR_library_set_name=dev-team" + echo "ENOS_VAR_service_account_names=[ \"staticuser\",\"bob.johnson\",\"mary.smith\" ]" + echo "ENOS_VAR_ldap_rotation_period=10" + echo "ENOS_VAR_ldap_rotation_window=3600" + } | tee -a "$GITHUB_ENV" + - run: enos scenario run ${{ inputs.scenario-filter }} + working-directory: enos + - if: ${{ always() }} + run: enos scenario destroy ${{ inputs.scenario-filter }} + working-directory: enos + - name: Remove enos runtime directories + if: ${{ always() }} + run: | + rm -rf /tmp/enos* + rm -rf enos/support + rm -rf enos/.enos + rm -rf "$HOME/.terraform.d" \ No newline at end of file diff --git a/enos/enos-descriptions.hcl b/enos/enos-descriptions.hcl index e1597de..e99ed00 100644 --- a/enos/enos-descriptions.hcl +++ b/enos/enos-descriptions.hcl @@ -109,6 +109,10 @@ globals { Build, register, and enable the Vault plugin. EOF + ldap_config_root_rotation = <<-EOF + Test the LDAP secrets engine's config endpoint root rotation functionality. + EOF + unseal_vault = <<-EOF Unseal the Vault cluster using the configured seal mechanism. EOF diff --git a/enos/enos-globals.hcl b/enos/enos-globals.hcl index 0504052..656e04d 100644 --- a/enos/enos-globals.hcl +++ b/enos/enos-globals.hcl @@ -2,12 +2,13 @@ // SPDX-License-Identifier: MPL-2.0 globals { - archs = ["amd64", "arm64"] - artifact_sources = ["local", "crt", "artifactory"] - ldap_artifact_sources = ["local", "releases", "artifactory"] - artifact_types = ["bundle", "package"] - backends = ["raft"] - backend_tag_key = "VaultStorage" + archs = ["amd64", "arm64"] + artifact_sources = ["local", "crt", "artifactory"] + ldap_artifact_sources = ["local", "releases", "artifactory"] + ldap_config_root_rotation_methods = ["period", "schedule", "manual"] + artifact_types = ["bundle", "package"] + backends = ["raft"] + backend_tag_key = "VaultStorage" build_tags = { "ce" = ["ui"] "ent" = ["ui", "enterprise", "ent"] @@ -16,26 +17,13 @@ globals { "ent.hsm.fips1403" = ["ui", "enterprise", "cgo", "hsm", "fips", "fips_140_3", "ent.hsm.fips1403"] } config_modes = ["env", "file"] - distros = ["amzn", "leap", "rhel", "sles", "ubuntu"] + distros = ["amzn", "ubuntu"] // Different distros may require different packages, or use different aliases for the same package distro_packages = { amzn = { "2" = ["nc"] "2023" = ["nc"] } - leap = { - "15.6" = ["netcat", "openssl"] - } - rhel = { - "8.10" = ["nc"] - "9.5" = ["nc"] - } - sles = { - // When installing Vault RPM packages on a SLES AMI, the openssl package provided - // isn't named "openssl, which rpm doesn't know how to handle. Therefore we add the - // "correctly" named one in our package installation before installing Vault. - "15.6" = ["netcat-openbsd", "openssl"] - } ubuntu = { "20.04" = ["netcat"] "22.04" = ["netcat"] @@ -44,9 +32,6 @@ globals { } distro_version = { amzn = var.distro_version_amzn - leap = var.distro_version_leap - rhel = var.distro_version_rhel - sles = var.distro_version_sles ubuntu = var.distro_version_ubuntu } editions = ["ce", "ent", "ent.fips1403", "ent.hsm", "ent.hsm.fips1403"] @@ -54,9 +39,6 @@ globals { ip_versions = ["4", "6"] package_manager = { "amzn" = "yum" - "leap" = "zypper" - "rhel" = "yum" - "sles" = "zypper" "ubuntu" = "apt" } packages = ["jq"] diff --git a/enos/enos-modules.hcl b/enos/enos-modules.hcl index 868cf9d..50e923d 100644 --- a/enos/enos-modules.hcl +++ b/enos/enos-modules.hcl @@ -90,6 +90,18 @@ module "restart_vault" { vault_install_dir = var.vault_install_dir } +module "root_rotation_period" { + source = "./modules/root_rotation_period" +} + +module "root_rotation_schedule" { + source = "./modules/root_rotation_schedule" +} + +module "root_rotation_manual" { + source = "./modules/root_rotation_manual" +} + module "seal_awskms" { source = "git::https://github.com/hashicorp/vault.git//enos/modules/seal_awskms?ref=${var.vault_repo_ref}" diff --git a/enos/enos-scenario-openldap-leader-change.hcl b/enos/enos-scenario-openldap-leader-change.hcl index e1c32b3..706e361 100644 --- a/enos/enos-scenario-openldap-leader-change.hcl +++ b/enos/enos-scenario-openldap-leader-change.hcl @@ -260,7 +260,7 @@ scenario "openldap_leader_change" { description = global.description.wait_for_cluster_to_have_leader module = module.vault_wait_for_leader depends_on = [step.create_vault_cluster, - step.bootstrap_vault_cluster_targets] + step.bootstrap_vault_cluster_targets] providers = { enos = local.enos_provider[matrix.distro] @@ -597,7 +597,7 @@ scenario "openldap_leader_change" { step "vault_leader_step_down" { description = global.description.vault_leader_step_down module = module.vault_step_down - depends_on = [ + depends_on = [ step.get_vault_cluster_ips, step.test_static_role_crud_api, step.test_dynamic_role_crud_api, diff --git a/enos/enos-scenario-openldap-smoke.hcl b/enos/enos-scenario-openldap-smoke.hcl index eaaee2f..92e4d48 100644 --- a/enos/enos-scenario-openldap-smoke.hcl +++ b/enos/enos-scenario-openldap-smoke.hcl @@ -37,16 +37,17 @@ scenario "openldap_smoke" { EOF matrix { - arch = global.archs - artifact_source = global.artifact_sources - ldap_artifact_source = global.ldap_artifact_sources - artifact_type = global.artifact_types - backend = global.backends - config_mode = global.config_modes - distro = global.distros - edition = global.editions - ip_version = global.ip_versions - seal = global.seals + arch = global.archs + artifact_source = global.artifact_sources + ldap_artifact_source = global.ldap_artifact_sources + artifact_type = global.artifact_types + backend = global.backends + config_mode = global.config_modes + distro = global.distros + edition = global.editions + ip_version = global.ip_versions + seal = global.seals + ldap_config_root_rotation_method = global.ldap_config_root_rotation_methods // Our local builder always creates bundles exclude { @@ -66,6 +67,12 @@ scenario "openldap_smoke" { seal = ["pkcs11"] distro = ["leap", "sles"] } + + // rotation manager capabilities not supported in Vault community edition + exclude { + edition = ["ce"] + ldap_config_root_rotation_method = ["period", "schedule"] + } } terraform_cli = terraform_cli.default @@ -490,10 +497,30 @@ scenario "openldap_smoke" { } } + step "test_ldap_config_root_rotation" { + description = global.description.ldap_config_root_rotation + module = "root_rotation_${matrix.ldap_config_root_rotation_method}" + depends_on = [step.configure_plugin] + + providers = { + enos = local.enos_provider[matrix.distro] + } + + variables { + vault_leader_ip = step.get_vault_cluster_ips.leader_host.public_ip + vault_addr = step.create_vault_cluster.api_addr_localhost + vault_root_token = step.create_vault_cluster.root_token + plugin_mount_path = var.plugin_mount_path + + rotation_period = matrix.ldap_config_root_rotation_method == "period" ? var.ldap_rotation_period : null + rotation_window = matrix.ldap_config_root_rotation_method == "schedule" ? var.ldap_rotation_window : null + } + } + step "test_static_role_crud_api" { description = global.description.static_role_crud_api module = module.static_role_crud_api - depends_on = [step.configure_plugin] + depends_on = [step.test_ldap_config_root_rotation] providers = { enos = local.enos_provider[matrix.distro] @@ -517,7 +544,7 @@ scenario "openldap_smoke" { step "test_dynamic_role_crud_api" { description = global.description.dynamic_role_crud_api module = module.dynamic_role_crud_api - depends_on = [step.configure_plugin] + depends_on = [step.test_ldap_config_root_rotation] providers = { enos = local.enos_provider[matrix.distro] @@ -542,7 +569,7 @@ scenario "openldap_smoke" { description = global.description.library_crud_api module = module.library_crud_api depends_on = [ - step.configure_plugin, + step.test_ldap_config_root_rotation, step.test_static_role_crud_api ] diff --git a/enos/enos-variables.hcl b/enos/enos-variables.hcl index a178293..ba54bfa 100644 --- a/enos/enos-variables.hcl +++ b/enos/enos-variables.hcl @@ -91,6 +91,12 @@ variable "ldap_bind_pass" { default = null } +variable "ldap_disable_automated_rotation" { + type = bool + default = false + description = "Enterprise: cancel upcoming rotations until unset" +} + variable "ldap_dynamic_user_role_name" { description = "The name of the LDAP dynamic user role to create" type = string @@ -109,6 +115,18 @@ variable "ldap_revision" { default = null } +variable "ldap_rotation_period" { + type = number + default = 0 + description = "Enterprise: time in seconds before rotating the LDAP secret engine root credential. 0 disables rotation" +} + +variable "ldap_rotation_window" { + type = number + default = 0 + description = "Enterprise: max time in seconds to complete scheduled rotation" +} + variable "ldap_schema" { description = "LDAP schema type" type = string diff --git a/enos/modules/dynamic_role_crud_api/scripts/dynamic-role.sh b/enos/modules/dynamic_role_crud_api/scripts/dynamic-role.sh index b2c326a..fa6d58d 100755 --- a/enos/modules/dynamic_role_crud_api/scripts/dynamic-role.sh +++ b/enos/modules/dynamic_role_crud_api/scripts/dynamic-role.sh @@ -29,9 +29,6 @@ fail() { export VAULT_ADDR export VAULT_TOKEN -echo "==> Rotating root credentials" -vault write -f "${PLUGIN_PATH}/rotate-root" - ROLE_PATH="${PLUGIN_PATH}/role/${ROLE_NAME}" echo "==> Creating dynamic role: ${ROLE_NAME}" diff --git a/enos/modules/library_crud_api/scripts/library.sh b/enos/modules/library_crud_api/scripts/library.sh index 42915ec..f4f8144 100644 --- a/enos/modules/library_crud_api/scripts/library.sh +++ b/enos/modules/library_crud_api/scripts/library.sh @@ -33,10 +33,6 @@ if [[ ${#SA_LIST[@]} -lt 1 ]]; then fail "SERVICE_ACCOUNT_NAMES must contain at least one account" fi -# Rotate root credentials -echo "==> Rotating root credentials" -vault write -f "${PLUGIN_PATH}/rotate-root" - # Create library set echo "==> Creating library set ${LIBRARY_SET_NAME}" vault write "${LIB_PATH}" \ diff --git a/enos/modules/root_rotation_manual/main.tf b/enos/modules/root_rotation_manual/main.tf new file mode 100644 index 0000000..bbac5b0 --- /dev/null +++ b/enos/modules/root_rotation_manual/main.tf @@ -0,0 +1,30 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + enos = { + source = "registry.terraform.io/hashicorp-forge/enos" + } + } +} + +variable "rotation_period" { default = null } +variable "rotation_window" { default = null } + +resource "enos_remote_exec" "root_rotation_manual_test" { + scripts = [abspath("${path.module}/scripts/test-root-rotation-manual.sh")] + + environment = { + VAULT_ADDR = var.vault_addr + VAULT_TOKEN = var.vault_root_token + PLUGIN_PATH = var.plugin_mount_path + } + + transport = { + ssh = { + host = var.vault_leader_ip + } + } +} + diff --git a/enos/modules/root_rotation_manual/scripts/test-root-rotation-manual.sh b/enos/modules/root_rotation_manual/scripts/test-root-rotation-manual.sh new file mode 100755 index 0000000..e1c30e3 --- /dev/null +++ b/enos/modules/root_rotation_manual/scripts/test-root-rotation-manual.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +fail() { + echo "$1" 1>&2 + exit 1 +} + +PLUGIN_PATH=local-secrets-ldap + +# Required env vars: PLUGIN_PATH +if [[ -z "${PLUGIN_PATH:-}" ]]; then + fail "PLUGIN_PATH env variable has not been set" +fi + +# Configure plugin for manual rotation +vault write -format=json "${PLUGIN_PATH}/config" \ + disable_automated_rotation=true \ + rotation_period=0 \ + rotation_schedule="" \ + rotation_window=0 >/dev/null + +# Read disable_automated_rotation from config +disable_automated_rotation=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.disable_automated_rotation') + +# Validate disable_automated_rotation +if [[ "$disable_automated_rotation" != "true" ]]; then + fail "[ERROR] Expected rotation_schedule=true, got $disable_automated_rotation" +fi + +# Read pre-rotation timestamp +before=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') + +# Trigger manual rotation +vault write -format=json -f "${PLUGIN_PATH}/rotate-root" >/dev/null + +# Read post-rotation timestamp after a brief pause +after=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') + +if [[ "$after" == "$before" ]]; then + fail "[ERROR] Manual rotation failed: timestamp did not change (before=$before, after=$after)" +fi + +echo "[OK] Manual rotation succeeded: timestamp updated (before=$before, after=$after)" diff --git a/enos/modules/root_rotation_manual/variables.tf b/enos/modules/root_rotation_manual/variables.tf new file mode 100644 index 0000000..0cf21c6 --- /dev/null +++ b/enos/modules/root_rotation_manual/variables.tf @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +variable "vault_addr" { + type = string + description = "Vault API address" +} + +variable "vault_root_token" { + type = string + description = "Vault cluster root token" +} + +variable "vault_leader_ip" { + type = string + description = "SSH host/IP of Vault leader for remote exec" +} + +variable "plugin_mount_path" { + type = string + description = "Mount path of the LDAP plugin in Vault" +} diff --git a/enos/modules/root_rotation_period/main.tf b/enos/modules/root_rotation_period/main.tf new file mode 100644 index 0000000..1dbfd65 --- /dev/null +++ b/enos/modules/root_rotation_period/main.tf @@ -0,0 +1,27 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + enos = { + source = "registry.terraform.io/hashicorp-forge/enos" + } + } +} + +variable "rotation_window" { default = null } + +resource "enos_remote_exec" "root_rotation_period_test" { + scripts = [abspath("${path.module}/scripts/test-root-rotation-period.sh")] + environment = { + VAULT_ADDR = var.vault_addr + VAULT_TOKEN = var.vault_root_token + PLUGIN_PATH = var.plugin_mount_path + ROTATION_PERIOD = var.rotation_period + } + transport = { + ssh = { + host = var.vault_leader_ip + } + } +} diff --git a/enos/modules/root_rotation_period/scripts/test-root-rotation-period.sh b/enos/modules/root_rotation_period/scripts/test-root-rotation-period.sh new file mode 100755 index 0000000..78c0005 --- /dev/null +++ b/enos/modules/root_rotation_period/scripts/test-root-rotation-period.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +fail() { + echo "$1" 1>&2 + exit 1 +} + +# Required env vars: PLUGIN_PATH, ROTATION_PERIOD +if [[ -z "${PLUGIN_PATH:-}" ]]; then fail "PLUGIN_PATH not set"; fi +if [[ -z "${ROTATION_PERIOD:-}" ]]; then fail "ROTATION_PERIOD not set"; fi + +# Configure plugin for rotation period +vault write -format=json "${PLUGIN_PATH}/config" \ + disable_automated_rotation=false \ + rotation_period="${ROTATION_PERIOD}" \ + rotation_schedule="" \ + rotation_window=0 >/dev/null + +# Add cross-platform parse_epoch helper +parse_epoch() { + python3 -c " +import sys, datetime, re +ts = sys.argv[1] +if ts == 'null': + print(0) + sys.exit(0) +# Remove Z and handle nanoseconds +if ts.endswith('Z'): + ts = ts[:-1] +match = re.match(r'(.*\.\d{6})\d*(.*)', ts) +if match: + ts = match.group(1) + match.group(2) +dt = datetime.datetime.fromisoformat(ts) +print(int(dt.timestamp())) +" "$1" +} + +# Read rotation_period from config +rotation_period=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.rotation_period') + +# Validate rotation_period +if [[ "$rotation_period" != "$ROTATION_PERIOD" ]]; then + fail "[ERROR] Expected rotation_period=$ROTATION_PERIOD, got $rotation_period" +fi + +# Read timestamp before rotation +before=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') + +# Convert to epoch +before_epoch=$(parse_epoch "$before") + +# Wait for rotation_period + 1 seconds +echo "==> Sleeping for $((ROTATION_PERIOD + 1)) seconds for automated rotation" +sleep $((ROTATION_PERIOD + 1)) + +# Read timestamp after rotation +after=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') + +after_epoch=$(parse_epoch "$after") + +# Assert a rotation occurred +if [[ "$before" == "null" ]]; then + echo "[INFO] No previous rotation timestamp found (before=null), first rotation expected." +fi +if [[ "$after" == "null" ]]; then + fail "[ERROR] No rotation occurred, after=null" +fi + +# Compute difference +diff=$((after_epoch - before_epoch)) +if [[ "$diff" -lt "$ROTATION_PERIOD" ]]; then + fail "[ERROR] Automated rotation did not occur: delta $diff < $ROTATION_PERIOD" +fi + +#final check: + +echo "[OK] Automated rotation succeeded: delta $diff >= $ROTATION_PERIOD" diff --git a/enos/modules/root_rotation_period/variables.tf b/enos/modules/root_rotation_period/variables.tf new file mode 100644 index 0000000..3434a88 --- /dev/null +++ b/enos/modules/root_rotation_period/variables.tf @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +variable "vault_addr" { + type = string + description = "Vault API address" +} + +variable "vault_root_token" { + type = string + description = "Vault cluster root token" +} + +variable "vault_leader_ip" { + type = string + description = "SSH host/IP of Vault leader for remote exec" +} + +variable "plugin_mount_path" { + type = string + description = "Mount path of the LDAP plugin in Vault" +} + +variable "rotation_period" { + type = number + description = "Automated rotation period in seconds for the LDAP root credentials" +} diff --git a/enos/modules/root_rotation_schedule/main.tf b/enos/modules/root_rotation_schedule/main.tf new file mode 100644 index 0000000..c4e7ad4 --- /dev/null +++ b/enos/modules/root_rotation_schedule/main.tf @@ -0,0 +1,27 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + enos = { + source = "registry.terraform.io/hashicorp-forge/enos" + } + } +} + +variable "rotation_period" { default = null } + +resource "enos_remote_exec" "root_rotation_schedule_test" { + scripts = [abspath("${path.module}/scripts/test-root-rotation-schedule.sh")] + environment = { + VAULT_ADDR = var.vault_addr + VAULT_TOKEN = var.vault_root_token + PLUGIN_PATH = var.plugin_mount_path + ROTATION_WINDOW = var.rotation_window + } + transport = { + ssh = { + host = var.vault_leader_ip + } + } +} diff --git a/enos/modules/root_rotation_schedule/scripts/test-root-rotation-schedule.sh b/enos/modules/root_rotation_schedule/scripts/test-root-rotation-schedule.sh new file mode 100755 index 0000000..122b022 --- /dev/null +++ b/enos/modules/root_rotation_schedule/scripts/test-root-rotation-schedule.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +fail() { + echo "$1" 1>&2 + exit 1 +} + +# Required env vars: PLUGIN_PATH, ROTATION_WINDOW +if [[ -z "${PLUGIN_PATH:-}" ]]; then fail "PLUGIN_PATH not set"; fi +if [[ -z "${ROTATION_WINDOW:-}" ]]; then fail "ROTATION_WINDOW not set"; fi + +# Compute cron schedule one minute from now +schedule=$(python3 - < Using cron schedule: $schedule" + +# Configure plugin for schedule-based rotation +vault write -format=json "${PLUGIN_PATH}/config" \ + disable_automated_rotation=false \ + rotation_schedule="$schedule" \ + rotation_window="${ROTATION_WINDOW}" \ + rotation_period=0 >/dev/null + +# Read rotation_schedule from config +rotation_schedule=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.rotation_schedule') + +# Validate rotation_schedule +if [[ "$rotation_schedule" != "$schedule" ]]; then + fail "[ERROR] Expected rotation_schedule=$schedule, got $rotation_schedule" +fi + +# Read rotation_window from config +rotation_window=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.rotation_window') + +# Validate rotation_window +if [[ "$rotation_window" != "$ROTATION_WINDOW" ]]; then + fail "[ERROR] Expected rotation_period=$ROTATION_WINDOW, got $rotation_window" +fi + +# Cross-platform parse_epoch helper +parse_epoch() { + python3 -c " +import sys, datetime, re +ts = sys.argv[1] +if ts == 'null': + print(0) + sys.exit(0) +# Remove Z and handle nanoseconds +if ts.endswith('Z'): + ts = ts[:-1] +match = re.match(r'(.*\.\d{6})\d*(.*)', ts) +if match: + ts = match.group(1) + match.group(2) +dt = datetime.datetime.fromisoformat(ts) +print(int(dt.timestamp())) +" "$1" +} + +# Read timestamp before window expiration +before=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') +before_epoch=$(parse_epoch "$before") + +sleep 61 # Wait for the cron job to trigger + +# Read timestamp after window expiration +after=$(vault read -format=json "${PLUGIN_PATH}/config" | jq -r '.data.last_bind_password_rotation') +after_epoch=$(parse_epoch "$after") + +# Assert a rotation occurred +if [[ "$before" == "null" ]]; then + echo "[INFO] No previous rotation timestamp found (before=null), first rotation expected." +fi +if [[ "$after" == "null" ]]; then + fail "[ERROR] No rotation occurred, after=null" +fi + +diff=$((after_epoch - before_epoch)) +if [[ "$diff" -eq 0 ]]; then + fail "[ERROR] No rotation occurred at $after" +fi + +echo "[OK] Rotation occurred at $after" diff --git a/enos/modules/root_rotation_schedule/variables.tf b/enos/modules/root_rotation_schedule/variables.tf new file mode 100644 index 0000000..033f7de --- /dev/null +++ b/enos/modules/root_rotation_schedule/variables.tf @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +variable "vault_addr" { + type = string + description = "Vault API address" +} + +variable "vault_root_token" { + type = string + description = "Vault cluster root token" +} + +variable "vault_leader_ip" { + type = string + description = "SSH host/IP of Vault leader for remote exec" +} + +variable "plugin_mount_path" { + type = string + description = "Mount path of the LDAP plugin in Vault" +} + +variable "rotation_window" { + type = number + description = "Maximum time in seconds allowed to complete a scheduled rotation" + default = 3600 + + validation { + condition = var.rotation_window >= 3600 + error_message = "rotation_window must be at least 3600 seconds (1 hour)." + } +} \ No newline at end of file diff --git a/enos/modules/static_role_crud_api/scripts/static-role.sh b/enos/modules/static_role_crud_api/scripts/static-role.sh index 0e15567..9c92d7e 100644 --- a/enos/modules/static_role_crud_api/scripts/static-role.sh +++ b/enos/modules/static_role_crud_api/scripts/static-role.sh @@ -39,9 +39,6 @@ CRED_PATH="${PLUGIN_PATH}/static-cred/${ROLE_NAME}" echo "==> LDAP_HOST: ${LDAP_HOST}" echo "==> LDAP_PORT: ${LDAP_PORT}" -echo "==> Rotating root credentials" -vault write -f "${PLUGIN_PATH}/rotate-root" - echo "==> Creating static role ${ROLE_NAME}" vault write "${ROLE_PATH}" \ dn="${LDAP_DN}" \