-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
step_ca_provisioner: make idempotent, remove 'updated' state
This commit allows `step_ca_provisioner` to determine whether a given provisioner has been changed, be that through creation or an update. We do this by comparing the provisioner config before and after running the `create/update` command. This has the side-effect of making this module check-mode-incompatible, since there is no way to tell whether a provisioner has changed without actually applying it. This commit also fixes some behavior around the --create parameter to ensure idempotency, as well as some other minor details.
- Loading branch information
Showing
3 changed files
with
220 additions
and
137 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,13 +384,15 @@ | |
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: | ||
name: [email protected] | ||
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,38 +401,44 @@ | |
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: | ||
name: acme | ||
type: ACME | ||
force_cn: yes | ||
require_eab: yes | ||
notify: restart step-ca | ||
- name: Crate an K8SSA provisioner | ||
maxhoesel.smallstep.step_ca_provisioner: | ||
name: kube | ||
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: | ||
name: scep_provisioner | ||
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,41 +523,83 @@ | |
"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 | ||
}) | ||
cli.run_command(cli_params) | ||
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 | ||
}) | ||
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) | ||
|
||
|
||
|
Oops, something went wrong.