diff --git a/plugins/module_utils/cli_wrapper.py b/plugins/module_utils/cli_wrapper.py index 534ba548..78c6cbd8 100644 --- a/plugins/module_utils/cli_wrapper.py +++ b/plugins/module_utils/cli_wrapper.py @@ -60,11 +60,16 @@ def build_params(self, module_params_cliarg_map: Dict[str, str]) -> List[str]: if param_name not in module_params: raise CLIError(f"Could not build command parameters: " f"param '{param_name}' not in module argspec, this is most likely a bug") + if param_type == "bool": + if bool(module_params[param_name]) is False: + # some flags (such as --ssh in ca provisioner add/update are enabled by default), + # this allows the user to disable them if needed + args.append(f"{module_params_cliarg_map[param_name]}=false") + else: + args.append(module_params_cliarg_map[param_name]) elif not module_params[param_name]: - # param not set + # parameter is unset pass - elif param_type == "bool" and bool(module_params[param_name]): - args.append(module_params_cliarg_map[param_name]) elif param_type == "list": for item in cast(List, module_params[param_name]): args.extend([module_params_cliarg_map[param_name], str(item)]) diff --git a/plugins/modules/step_ca_provisioner.py b/plugins/modules/step_ca_provisioner.py index 542384e0..c2cb0352 100644 --- a/plugins/modules/step_ca_provisioner.py +++ b/plugins/modules/step_ca_provisioner.py @@ -11,12 +11,19 @@ version_added: '0.3.0' description: Use this module to create and remove provisioners from a Smallstep CA server. notes: - - Existing provisioners will B(not) be modified by default, use the I(update) flag to force provisioner updates + - This module does not restart step-ca after modifying provisioners, you must do so manually - Most of the options correspond to the command-line parameters for the C(step ca provisioner) command. See the L(documentation,https://smallstep.com/docs/step-cli/reference/ca/provisioner) for more information. - Any files used to create the provisioner (e.g. root certificate chains) must already be present on the remote host. - - Check mode is supported. + - Check mode is not supported, as this module cannot tell when a provisioner has changed without actually applying it. options: + access_mode: + description: Whether to modify the provisioners locally or via admin access + type: str + choices: + - local + - admin + default: local allow_renewal_after_expiry: description: Allow renewals for expired certificates generated by this provisioner. type: bool @@ -55,7 +62,7 @@ description: The Microsoft Azure tenant id used to validate the identity tokens. type: str ca_config: - description: The path to the certificate authority configuration file on the host if managing provisioners locally. + description: If I(mode=local), sets the path to the certificate authority configuration file on the host. type: path default: CI($STEPPATH)/config/ca.json ca_url: @@ -100,11 +107,17 @@ Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". type: str jwk_create: - description: Create the JWK key pair for the provisioner. + description: Whether to create the JWK key pair for the provisioner automatically. type: bool version_added: 0.20.0 aliases: - create + jwk_create_force: + description: Whether to recreate the JWK key pair on an existing provisioner + type: bool + default: false + aliases: + - create_force jwk_private_key: description: The file containing the JWK private key. type: path @@ -282,13 +295,10 @@ version_added: 0.20.0 state: description: > - Whether the provisioner should be present or absent. - Note that C(present) does not update existing provisioners. - C(updated) will attempt to update the provisioner regardless of whether it has changed or not. - Note that this will always report the task as changed. + Desired state of the provisioner. + C(present) will try to update an existing provisioner with the supplied parameters choices: - 'present' - - 'updated' - 'absent' default: 'present' type: str @@ -364,6 +374,7 @@ type: JWK jwk_create: yes x509_template: ./templates/example.tpl + notify: restart step-ca # <- include a handler to automatically restart step-ca whenever a provisioner changes - name: Create a JWK provisioner with duration claims maxhoesel.smallstep.step_ca_provisioner: @@ -373,6 +384,7 @@ x509_min_dur: 20m x509_default_dur: 20m x509_max_dur: 24h + notify: restart step-ca - name: Create a JWK provisioner with existing keys maxhoesel.smallstep.step_ca_provisioner: @@ -380,6 +392,7 @@ type: JWK public_key: jwk.pub private_key: jwk.priv + notify: restart step-ca - name: Create an OIDC provisioner maxhoesel.smallstep.step_ca_provisioner: @@ -388,12 +401,14 @@ client_id: 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com client_secret: udTrOT3gzrO7W9fDPgZQLfYJ configuration_endpoint: https://accounts.google.com/.well-known/openid-configuration + notify: restart step-ca - name: Create an X5C provisioner maxhoesel.smallstep.step_ca_provisioner: name: x5c type: X5C x5c_root: x5c_ca.crt + notify: restart step-ca - name: Create an ACME provisioner, forcing a CN and requiring EAB maxhoesel.smallstep.step_ca_provisioner: @@ -401,6 +416,7 @@ type: ACME force_cn: yes require_eab: yes + notify: restart step-ca - name: Crate an K8SSA provisioner maxhoesel.smallstep.step_ca_provisioner: @@ -408,11 +424,13 @@ type: K8SSA ssh: true public_key: key.pub + notify: restart step-ca - name: Create an SSHPOP provisioner maxhoesel.smallstep.step_ca_provisioner: name: sshpop type: SSHPOP + notify: restart step-ca - name: Create a SCEP provisioner maxhoesel.smallstep.step_ca_provisioner: @@ -420,6 +438,7 @@ type: SCEP scep_challenge: secret scep_encryption_algorithm_identifier: 2 + notify: restart step-ca - name: Create a complexAzure provisioner maxhoesel.smallstep.step_ca_provisioner: @@ -433,13 +452,15 @@ - dc760a01-2886-4a84-9abc-f3508e0f87d9 azure_object_ids: - f50926c7-abbf-4c28-87dc-9adc7eaf3ba7 + notify: restart step-ca """ import json import os -from typing import cast, Dict, Any +from typing import cast, Dict, Any, List, Optional from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.validation import check_required_if from ..module_utils.params.ca_admin import AdminParams from ..module_utils.cli_wrapper import CLIWrapper @@ -453,7 +474,7 @@ "root": "--root" } -CREATE_UPDATE_CLIARG_MAP = { +UPDATE_CLIARG_MAP = { "allow_renewal_after_expiry": "--allow-renewal-after-expiry", "aws_accounts": "--aws-account", "azure_audience": "--azure-audience", @@ -468,7 +489,6 @@ "gcp_projects": "--gcp-project", "gcp_service_accounts": "--gcp-service-account", "instance_age": "--instance-age", - "jwk_create": "--create", "jwk_private_key": "--private-key", "nebula_root": "--nebula-root", "oidc_admins": "--admin", @@ -503,12 +523,20 @@ "x5c_root": "--x5c-root", } +ADD_CLIARG_MAP = { + **UPDATE_CLIARG_MAP, + **{ + "type": "--type", + "jwk_create": "--create" + } +} + -def add_provisioner(name: str, provisioner_type: str, cli: CLIWrapper): +def add_provisioner(cli: CLIWrapper, name: str): cli_params = [ - "ca", "provisioner", "add", name, "--type", provisioner_type + "ca", "provisioner", "add", name ] + cli.build_params({ - **CREATE_UPDATE_CLIARG_MAP, + **ADD_CLIARG_MAP, **CONNECTION_CLIARG_MAP, **AdminParams.cliarg_map }) @@ -516,21 +544,26 @@ def add_provisioner(name: str, provisioner_type: str, cli: CLIWrapper): return -def update_provisioner(name: str, cli: CLIWrapper): - cli_params = [ - "ca", "provisioner", "update", name - ] + cli.build_params({ - **CREATE_UPDATE_CLIARG_MAP, +def update_provisioner(cli: CLIWrapper, prov_config: Dict[str, Any], jwk_create_force: bool): + """Update a given provisioner in-place and return whether the config has changed + """ + params = cli.build_params({ + **UPDATE_CLIARG_MAP, **CONNECTION_CLIARG_MAP, **AdminParams.cliarg_map }) - cli.run_command(cli_params) - return + if jwk_create_force: + params.append("--create") + + cli_params = [ + "ca", "provisioner", "update", prov_config["name"] + ] + params + return cli.run_command(cli_params) -def remove_provisioner(name: str, cli: CLIWrapper): +def remove_provisioner(cli: CLIWrapper, prov_config: Dict[str, Any]): cli_params = [ - "ca", "provisioner", "remove", name + "ca", "provisioner", "remove", prov_config["name"] ] + cli.build_params({ **CONNECTION_CLIARG_MAP, **AdminParams.cliarg_map @@ -538,6 +571,35 @@ def remove_provisioner(name: str, cli: CLIWrapper): cli.run_command(cli_params) +def get_provisioner_config(module: AnsibleModule, cli: CLIWrapper) -> Optional[Dict[str, Any]]: + access_mode = cast(str, module.params["access_mode"]) # type: ignore + name = cast(str, module.params["name"]) # type: ignore + ca_config = cast(str, module.params["ca_config"]) # type: ignore + + # Provisioner management can be done in one of two ways - direct writes (offline ca) or as an admin (online). + if access_mode == "admin": + # Since admin access can come from any host, + # we cannot rely on the config being on the same host and must use "ca provisioner list". + rc, provisioners_raw, stderr = cli.run_command( + ["ca", "provisioner", "list"] + + cli.build_params(CONNECTION_CLIARG_MAP), check_mode_safe=True, check=False + ) + if rc != 0: + module.fail_json(f"Cannot read provisioner list remotely (needed due to admin params): {stderr}") + else: + # Meanwhile, if we do not use admin access, + # we are on the same machine as the CA and can just read the config directly. + with open(ca_config, "rb") as f: + provisioners_raw = f.read() + + provisioners: List[Dict[str, Any]] = [] + try: + provisioners = json.loads(provisioners_raw).get("authority", {}).get("provisioners", []) + except (json.JSONDecodeError, OSError) as e: + module.fail_json(f"Error reading provisioner config: {e}") + return next((p for p in provisioners if p["name"] == name), None) + + def run_module(): argument_spec = dict( allow_renewal_after_expiry=dict(type="bool"), @@ -558,7 +620,9 @@ def run_module(): gcp_service_accounts=dict(type="list", elements="str", aliases=["gcp_service_account"]), instance_age=dict(type="str"), jwk_create=dict(type="bool", aliases=["create"]), + jwk_create_force=dict(type="bool", aliases=["create_force"], default=False), jwk_private_key=dict(type="path", aliases=["private_key"]), + access_mode=dict(type="str", choices=["admin", "local"], default="local"), name=dict(type="str", required=True), nebula_root=dict(type="path"), oidc_admins=dict(type="list", elements="str", aliases=["oidc_admin", "admin", "oidc_admin_email"]), @@ -586,7 +650,7 @@ def run_module(): ssh_user_default_dur=dict(type="str"), ssh_template=dict(type="path"), ssh_template_data=dict(type="path"), - state=dict(type="str", default="present", choices=["present", "updated", "absent"]), + state=dict(type="str", default="present", choices=["present", "absent"]), type=dict(type="str", choices=[ "JWK", "OIDC", "AWS", "GCP", "Azure", "ACME", "X5C", "K8SSA", "SSHPOP", "SCEP", "Nebula"]), x509_template=dict(type="path"), @@ -601,62 +665,33 @@ def run_module(): module = AnsibleModule(argument_spec={ **AdminParams.argument_spec, **argument_spec - }, supports_check_mode=True) + }, supports_check_mode=False) admin_params = AdminParams(module) - admin_params.check() module_params = cast(Dict, module.params) + check_required_if([ + ["state", "present", ["type"], True], + ["access_mode", "local", ["ca_config"], True], + ["access_mode", "admin", ["admin_cert"], True], + ], module_params) + admin_params.check() + + if module_params["access_mode"] == "local" and admin_params.is_defined(): + module.fail_json("admin parameters defined but 'access_mode' is 'local'") + cli = CLIWrapper(module, module_params["step_cli_executable"]) - state = cast(str, module_params["state"]) - p_type = cast(str, module_params["type"]) - - if state == "present" and not p_type: - module.fail_json("Provisioner type is required when state == present") - - rc, stdout = cli.run_command( - ["ca", "provisioner", "list"] + cli.build_params(CONNECTION_CLIARG_MAP), - check_mode_safe=True, check=False)[0:2] - # Offline provisioner management is possible even if the CA is down. - # ca provisioner list does depend on the CA being available however, so we need some backup strategies. - if rc == 0: - try: - provisioners = json.loads(stdout) - except (json.JSONDecodeError, OSError) as e: - module.fail_json(f"Error reading provisioner config: {e}") - elif admin_params.is_defined(): - # Admin credentials means that the provisioners are managed remotely and are stored in the DB. - # Combined with a connection failure, this means that we are unable to continue - module.fail_json( - "Could not contact CA to retrieve provisioners and cannot fallback to direct manipulation " - "as remote admin parameters are set. Aborting" - ) - else: - # Without admin, provisioners are always managed locally, so we can just read them as a fallback - with open(module_params["ca_config"], "rb") as f: - try: - provisioners = json.load(f).get("authority", {}).get("provisioners", []) - except (json.JSONDecodeError, OSError) as e: - module.fail_json(f"Error reading provisioner config: {e}") - - for p in provisioners: # type: ignore - if p["name"] == module_params["name"]: - if state == "present" and p["type"] == p_type: - result["msg"] = "Provisioner found in CA config - not modified" - elif state == "updated": - update_provisioner(module_params["name"], cli) - result["changed"] = True - elif state == "absent": - remove_provisioner(module_params["name"], cli) - result["changed"] = True - module.exit_json(**result) - - # No matching provisioner found - if state == "present": - add_provisioner(module_params["name"], module_params["type"], cli) + current_prov = get_provisioner_config(module, cli) + if current_prov is None: + if module_params["state"] == "present": + add_provisioner(cli, module_params["name"]) + elif module_params["state"] == "present": + update_provisioner(cli, current_prov, module_params["jwk_create_force"]) + elif module_params["state"] == "absent": + remove_provisioner(cli, current_prov) + + if get_provisioner_config(module, cli) != current_prov: result["changed"] = True - elif state == "updated": - module.fail_json(f"Provisioner {module_params['name']} not found but state is 'updated'") module.exit_json(**result) diff --git a/tests/integration/targets/step_ca_provisioner/tasks/main.yml b/tests/integration/targets/step_ca_provisioner/tasks/main.yml index 4f898908..1335f4f6 100644 --- a/tests/integration/targets/step_ca_provisioner/tasks/main.yml +++ b/tests/integration/targets/step_ca_provisioner/tasks/main.yml @@ -28,6 +28,7 @@ type: JWK password_file: "/tmp/tests_passfile" create: yes + x509_default_dur: 1h - name: tests-OIDC type: OIDC oidc_client_id: 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com @@ -55,33 +56,54 @@ - name: tests-k8s type: K8SSA k8s_pem_keys_file: "/tmp/tests_crt" + # Remove the online provisioners before restarting as they may prevent a successful startup + # functionality + - name: Remove online provisioners + maxhoesel.smallstep.step_ca_provisioner: + name: "{{ item.0 }}" + type: "{{ item.1 }}" + state: absent + step_cli_executable: "{{ cli_binary }}" + loop: + - ["tests-OIDC", "OIDC"] + - ["tests-Amazon", "AWS"] + - ["tests-Google", "GCP"] + + # the step-ca process in our custom Dockerfile is managed by systemd, but the container isn't set up by ansible-test + # in a way that we can use the systemd module to manage it. Fall back to signals for manipulation instead + - name: Get Server PID + shell: + cmd: | + set -o pipefail + pgrep -fa step-ca | grep -v step-ca.sh | cut -d ' ' -f 1 + executable: /bin/bash + changed_when: false + check_mode: false + register: _pid + - name: Reload Server # noqa no-changed-when + command: "kill -1 {{ _pid.stdout_lines[0] }}" + become: false + - name: Wait for server reload + ansible.builtin.pause: + seconds: 3 + - name: Check server health + command: "{{ cli_binary }} ca health" + changed_when: no + - name: Read initial file + ansible.builtin.command: "cat {{ ca_path }}/config/ca.json" + changed_when: false + check_mode: false + register: initial_config + - name: Test creation idempotency - maxhoesel.smallstep.step_ca_provisioner: "{{ item | combine({'step_cli_executable': cli_binary })}}" + maxhoesel.smallstep.step_ca_provisioner: "{{ item | combine({'step_cli_executable': cli_binary}) }}" loop: - name: tests-JWK type: JWK + # doesn't do anything without create_force create: yes password_file: "/tmp/tests_passfile" - - name: tests-OIDC - type: OIDC - oidc_client_id: 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com - oidc_configuration_endpoint: https://accounts.google.com/.well-known/openid-configuration - oidc_admin_email: - - mariano@smallstep.com - - max@smallstep.com - - name: tests-Amazon - type: AWS - aws_account: 123456789 - instance_age: 1h - - name: tests-Google - type: GCP - gcp_service_account: - - 1234567890-compute@developer.gserviceaccount.com - - 9876543210-compute@developer.gserviceaccount.com - gcp_project: - - identity - - accounting - name: tests-ACME type: ACME - name: tests-x5c @@ -91,53 +113,62 @@ type: K8SSA k8s_pem_keys_file: "/tmp/tests_crt" register: second_run - - - name: Verify that nothing changed on the second run + - name: Verify that nothing changed on the idempotency run assert: that: not second_run.changed + - name: Read config file + ansible.builtin.command: "cat {{ ca_path }}/config/ca.json" + changed_when: false + check_mode: false + register: idempotent_config + - name: Verify that idempotency works + ansible.builtin.assert: + that: (initial_config.stdout | from_json).authority.provisioners == (idempotent_config.stdout | from_json).authority.provisioners + + - name: Test creation idempotency with no additional parameters + maxhoesel.smallstep.step_ca_provisioner: "{{ item | combine({'step_cli_executable': cli_binary}) }}" + loop: + - name: tests-JWK + type: JWK + - name: tests-ACME + type: ACME + - name: tests-x5c + type: X5C + - name: tests-k8s + type: K8SSA + register: second_run_no_params + - name: Verify that nothing changed on the idempotency run + assert: + that: not second_run_no_params.changed + - name: Read config file + ansible.builtin.command: "cat {{ ca_path }}/config/ca.json" + changed_when: false + check_mode: false + register: idempotent_config_no_params + - name: Verify that idempotency works + ansible.builtin.assert: + that: (initial_config.stdout | from_json).authority.provisioners == (idempotent_config_no_params.stdout | from_json).authority.provisioners - name: Test updating provisioners maxhoesel.smallstep.step_ca_provisioner: - name: tests-OIDC - type: OIDC - oidc_client_id: 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com - oidc_configuration_endpoint: https://accounts.google.com/.well-known/openid-configuration - oidc_admin_email: - - mariano@smallstep.com - - max@smallstep.com - - new@admin.com - state: "updated" + name: tests-ACME + type: ACME + require_eab: True + force_cn: True step_cli_executable: "{{ cli_binary }}" - register: update_test - + register: update_run + - name: Verify that update changed something + ansible.builtin.assert: + that: update_run.changed + - name: Read config file + ansible.builtin.command: "cat {{ ca_path }}/config/ca.json" + changed_when: false + check_mode: false + register: updated_config - name: Verify that provisioner got updated ansible.builtin.assert: - that: - update_test.changed + that: (updated_config.stdout | from_json).authority.provisioners != (initial_config.stdout | from_json).authority.provisioners - # Remove the online provisioners before restarting as they may imapct server - # functionality - - name: Remove online provisioners - maxhoesel.smallstep.step_ca_provisioner: - name: "{{ item.0 }}" - type: "{{ item.1 }}" - state: absent - step_cli_executable: "{{ cli_binary }}" - loop: - - ["tests-OIDC", "OIDC"] - - ["tests-Amazon", "AWS"] - - ["tests-Google", "GCP"] - - - name: Get Server PID - shell: pgrep -fa step-ca | grep -v step-ca.sh | cut -d ' ' -f 1 - register: _pid - - name: Reload Server - command: "kill -1 {{ _pid.stdout_lines[0] }}" - become: false - - - name: Check server health - command: "{{ cli_binary }} ca health" - changed_when: no - name: Remove test provisioners maxhoesel.smallstep.step_ca_provisioner: @@ -149,14 +180,26 @@ - "tests-ACME" - "tests-x5c" - "tests-k8s" - - name: Get step-ca config command: "cat {{ ca_path }}/config/ca.json" + changed_when: false + check_mode: false register: step_ca_config - name: Verify that all provisioners are absent assert: that: - (step_ca_config.stdout | from_json).authority.provisioners is not defined + + + - name: Reload Server # noqa no-changed-when + command: "kill -1 {{ _pid.stdout_lines[0] }}" + become: false + - name: Wait for server reload + ansible.builtin.pause: + seconds: 3 + - name: Check server health + command: "{{ cli_binary }} ca health" + changed_when: no become: yes become_user: "{{ ca_user }}" environment: