Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

step_ca_provisioner: make idempotent, remove 'updated' state #355

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions plugins/module_utils/cli_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
185 changes: 110 additions & 75 deletions plugins/modules/step_ca_provisioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"),
Expand All @@ -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"]),
Expand Down Expand Up @@ -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"),
Expand All @@ -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)


Expand Down
Loading