diff --git a/README.md b/README.md index 6faabc96..e753448b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ ansible-galaxy collection install -vv -r meta/collection-requirements.yml ## Variables +Type, required-field, and choice validation is enforced by +`meta/argument_specs.yml`. Role tasks enforce cross-field validation. + | Parameter | Description | Type | Required | Default | |-------------------------|----------------------------------------------------------------------------------------------------------------|:----:|:--------:|-------------------| | certificate_wait | If the task should wait for the certificate to be issued. | bool | no | yes | diff --git a/meta/argument_specs.yml b/meta/argument_specs.yml new file mode 100644 index 00000000..adcbcf72 --- /dev/null +++ b/meta/argument_specs.yml @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: MIT +--- +argument_specs: + main: + short_description: The certificate role. + description: > + The certificate role allows you to issue and manage TLS/SSL + certificates using various CA providers such as self-sign and IPA. + + This role will install necessary packages, configure the certificate + provider, and issue or renew certificates as specified in + `certificate_requests`. It can also manage system trust store + entries via `certificate_trust`. + options: + certificate_requests: + type: list + elements: dict + default: [] + description: > + A list of certificate request specifications. Each item describes + a certificate to be issued or renewed. + options: + name: + type: str + required: true + description: > + The name of the certificate. A full path can be used to + choose the directory where files will be stored. + ca: + type: str + required: true + description: > + The CA that will issue the certificate (e.g. self-sign, ipa). + dns: + type: raw + description: > + A domain name or list of domain names to include in the + certificate Subject Alternative Name (SAN). + email: + type: raw + description: > + An email address or list of email addresses to include in the + certificate Subject Alternative Name (SAN). + ip: + type: raw + description: > + An IP address or list of IP addresses to include in the + certificate Subject Alternative Name (SAN). + auto_renew: + type: bool + default: true + description: > + Whether the certificate should be renewed automatically + before it expires. + owner: + type: str + description: > + The user name or user id for the certificate and key files. + group: + type: str + description: > + The group name or group id for the certificate and key files. + mode: + type: raw + description: > + The file system permissions for the certificate and key + files. Accepts a string (e.g. '0644') or an integer. + key_size: + type: int + description: > + The key size in bits. + common_name: + type: str + description: > + The Common Name requested for the certificate subject. + country: + type: str + description: > + The country code requested for the certificate subject. + state: + type: str + description: > + The state requested for the certificate subject. + locality: + type: str + description: > + The locality requested for the certificate subject. + organization: + type: str + description: > + The organization requested for the certificate subject. + organizational_unit: + type: str + description: > + The organizational unit requested for the certificate subject. + contact_email: + type: str + description: > + The contact email requested for the certificate subject. + key_usage: + type: list + elements: str + choices: + - digitalSignature + - nonRepudiation + - keyEncipherment + - dataEncipherment + - keyAgreement + - keyCertSign + - cRLSign + - encipherOnly + - decipherOnly + default: + - digitalSignature + - keyEncipherment + description: > + The allowed Key Usage extensions for the certificate. + extended_key_usage: + type: list + elements: str + default: + - id-kp-serverAuth + - id-kp-clientAuth + description: > + The Extended Key Usage attributes for the certificate. + run_before: + type: str + description: > + A command to run before saving the certificate. + run_after: + type: str + description: > + A command to run after saving the certificate. + principal: + type: raw + description: > + A Kerberos principal or list of Kerberos principals. + provider: + type: str + default: certmonger + description: > + The underlying method used to request and manage the + certificate. + issuer: + type: str + description: > + The issuer certificate nickname or template name. + certificate_wait: + type: bool + default: true + description: > + Whether the task should wait for the certificate to be issued. + certificate_trust: + type: list + elements: dict + default: [] + description: > + A list of certificates to install into or remove from the system + trust store. + options: + name: + type: str + required: true + description: > + The base file name of the trust anchor. + content: + type: str + description: > + The inline PEM content of the certificate. + src: + type: str + description: > + The path of a certificate file to copy to the trust store. + remote_src: + type: bool + description: > + If true, `src` is a path on the managed host rather than + the controller. + url: + type: str + description: > + The URL to download the certificate from. + state: + type: str + choices: + - present + - absent + default: present + description: > + Whether the trust anchor should be present or absent. diff --git a/tasks/assert_role_vars.yml b/tasks/assert_role_vars.yml new file mode 100644 index 00000000..00b40426 --- /dev/null +++ b/tasks/assert_role_vars.yml @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT +--- +- name: Assert dns is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.dns is string + or (item.dns is sequence and item.dns is not mapping + and item.dns | reject('string') | list | length == 0) + fail_msg: >- + certificate_requests[{{ idx }}].dns must be a string or list of + strings, got {{ item.dns | type_debug }} + loop: "{{ certificate_requests }}" + loop_control: + index_var: idx + label: "{{ item.name | d('unnamed') }}" + when: item.dns is defined + +- name: Assert email is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.email is string + or (item.email is sequence and item.email is not mapping + and item.email | reject('string') | list | length == 0) + fail_msg: >- + certificate_requests[{{ idx }}].email must be a string or list of + strings, got {{ item.email | type_debug }} + loop: "{{ certificate_requests }}" + loop_control: + index_var: idx + label: "{{ item.name | d('unnamed') }}" + when: item.email is defined + +- name: Assert ip is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.ip is string + or (item.ip is sequence and item.ip is not mapping + and item.ip | reject('string') | list | length == 0) + fail_msg: >- + certificate_requests[{{ idx }}].ip must be a string or list of + strings, got {{ item.ip | type_debug }} + loop: "{{ certificate_requests }}" + loop_control: + index_var: idx + label: "{{ item.name | d('unnamed') }}" + when: item.ip is defined + +- name: Assert principal is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.principal is string + or (item.principal is sequence and item.principal is not mapping + and item.principal | reject('string') | list | length == 0) + fail_msg: >- + certificate_requests[{{ idx }}].principal must be a string or list of + strings, got {{ item.principal | type_debug }} + loop: "{{ certificate_requests }}" + loop_control: + index_var: idx + label: "{{ item.name | d('unnamed') }}" + when: item.principal is defined + +- name: Assert mode is a string or integer + ansible.builtin.assert: + that: + - (item.mode | type_debug) in ['str', 'int', 'unicode'] + fail_msg: >- + certificate_requests[{{ idx }}].mode must be a string or integer, + got {{ item.mode | type_debug }} + loop: "{{ certificate_requests }}" + loop_control: + index_var: idx + label: "{{ item.name | d('unnamed') }}" + when: item.mode is defined diff --git a/tasks/main.yml b/tasks/main.yml index b1853818..9406f8be 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -2,6 +2,10 @@ - name: Set version specific variables include_tasks: tasks/set_vars.yml +- name: Validate certificate_requests fields + ansible.builtin.include_tasks: tasks/assert_role_vars.yml + when: certificate_requests | length > 0 + - name: Set/reset certificate_is_changed set_fact: certificate_is_changed: false diff --git a/tests/tests_invalid_input.yml b/tests/tests_invalid_input.yml new file mode 100644 index 00000000..1aa1d7a9 --- /dev/null +++ b/tests/tests_invalid_input.yml @@ -0,0 +1,400 @@ +# SPDX-License-Identifier: MIT +--- +- name: Verify invalid parameters are rejected + hosts: all + tasks: + - name: Run invalid input tests + block: + # --- Valid invocation to confirm argument_specs accept defaults --- + + - name: Run role with valid defaults + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + + - name: Mark valid defaults succeeded + ansible.builtin.set_fact: + __valid_defaults_succeeded: true + + - name: Assert role completed with valid defaults + ansible.builtin.assert: + that: + - __valid_defaults_succeeded | default(false) + fail_msg: >- + Role should succeed with default parameters + + # --- argument_specs validation --- + # Role argument validation requires Ansible 2.10+; skipped on older versions. + + - name: Run argument specs validation tests + when: ansible_version.full is version('2.11', '>=') + block: + - name: Argument specs reject missing name in certificate_requests + block: + - name: Run role with certificate_requests missing name + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - ca: self-sign + dns: www.example.com + rescue: + - name: Mark missing name rejected + ansible.builtin.set_fact: + __invalid_input_missing_name_failed: true + when: >- + 'missing required arguments' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert missing name in certificate_requests failed validation + ansible.builtin.assert: + that: + - __invalid_input_missing_name_failed | default(false) + fail_msg: >- + meta/argument_specs should reject certificate_requests entries + without the required name field + + - name: Argument specs reject missing ca in certificate_requests + block: + - name: Run role with certificate_requests missing ca + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + dns: www.example.com + rescue: + - name: Mark missing ca rejected + ansible.builtin.set_fact: + __invalid_input_missing_ca_failed: true + when: >- + 'missing required arguments' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert missing ca in certificate_requests failed validation + ansible.builtin.assert: + that: + - __invalid_input_missing_ca_failed | default(false) + fail_msg: >- + meta/argument_specs should reject certificate_requests entries + without the required ca field + + - name: Argument specs reject invalid key_size type + block: + - name: Run role with key_size as string + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + key_size: not_a_number + rescue: + - name: Mark invalid key_size rejected + ansible.builtin.set_fact: + __invalid_input_key_size_type_failed: true + when: >- + 'key_size' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid key_size type failed validation + ansible.builtin.assert: + that: + - __invalid_input_key_size_type_failed | default(false) + fail_msg: >- + meta/argument_specs should reject key_size values that + are not integers + + - name: Argument specs reject invalid auto_renew type + block: + - name: Run role with auto_renew as string + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + auto_renew: not_a_bool + rescue: + - name: Mark invalid auto_renew rejected + ansible.builtin.set_fact: + __invalid_input_auto_renew_type_failed: true + when: >- + 'auto_renew' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid auto_renew type failed validation + ansible.builtin.assert: + that: + - __invalid_input_auto_renew_type_failed | default(false) + fail_msg: >- + meta/argument_specs should reject auto_renew values that + are not booleans + + - name: Argument specs reject invalid certificate_wait type + block: + - name: Run role with certificate_wait as string + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_wait: not_a_bool + rescue: + - name: Mark invalid certificate_wait rejected + ansible.builtin.set_fact: + __invalid_input_wait_type_failed: true + when: >- + 'certificate_wait' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid certificate_wait type failed validation + ansible.builtin.assert: + that: + - __invalid_input_wait_type_failed | default(false) + fail_msg: >- + meta/argument_specs should reject certificate_wait values + that are not booleans + + - name: Argument specs reject missing name in certificate_trust + block: + - name: Run role with certificate_trust missing name + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_trust: + - content: not-a-certificate + state: present + rescue: + - name: Mark missing trust name rejected + ansible.builtin.set_fact: + __invalid_input_trust_missing_name_failed: true + when: >- + 'missing required arguments' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert missing name in certificate_trust failed validation + ansible.builtin.assert: + that: + - __invalid_input_trust_missing_name_failed | default(false) + fail_msg: >- + meta/argument_specs should reject certificate_trust entries + without the required name field + + - name: Argument specs reject invalid key_usage choice + block: + - name: Run role with invalid key_usage value + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + key_usage: + - digitalSignature + - invalidUsage + rescue: + - name: Mark invalid key_usage rejected + ansible.builtin.set_fact: + __invalid_input_key_usage_choice_failed: true + when: >- + 'key_usage' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid key_usage choice failed validation + ansible.builtin.assert: + that: + - __invalid_input_key_usage_choice_failed | default(false) + fail_msg: >- + meta/argument_specs should reject key_usage values + not in the allowed choices list + + - name: Argument specs reject invalid state in certificate_trust + block: + - name: Run role with invalid trust state + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_trust: + - name: mycert + content: not-a-certificate + state: invalid_state + rescue: + - name: Mark invalid trust state rejected + ansible.builtin.set_fact: + __invalid_input_trust_state_failed: true + when: >- + 'invalid_state' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid state in certificate_trust failed validation + ansible.builtin.assert: + that: + - __invalid_input_trust_state_failed | default(false) + fail_msg: >- + meta/argument_specs should reject certificate_trust state + values outside the allowed set (present, absent) + + # --- certificate_trust cross-field validation (assert in tasks, not argument_specs) --- + + - name: Assert rejects trust entry with no source when state is present + block: + - name: Run role with trust entry missing content, src, and url + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_trust: + - name: mycert + state: present + rescue: + - name: Mark missing trust source rejected + ansible.builtin.set_fact: + __invalid_input_trust_no_source_failed: true + when: >- + 'exactly one' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert trust entry with no source failed validation + ansible.builtin.assert: + that: + - __invalid_input_trust_no_source_failed | default(false) + fail_msg: >- + certificate_trust should reject entries with state present + that do not specify exactly one of content, src, or url + + - name: Assert rejects trust entry with multiple sources + block: + - name: Run role with trust entry having both content and src + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_trust: + - name: mycert + content: not-a-certificate + src: /tmp/cert.pem + state: present + rescue: + - name: Mark multiple trust sources rejected + ansible.builtin.set_fact: + __invalid_input_trust_multiple_sources_failed: true + when: >- + 'exactly one' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert trust entry with multiple sources failed validation + ansible.builtin.assert: + that: + - __invalid_input_trust_multiple_sources_failed | default(false) + fail_msg: >- + certificate_trust should reject entries with state present + that specify more than one of content, src, or url + + # --- assert_role_vars validation (raw type fields) --- + + - name: Assert rejects dns as integer + block: + - name: Run role with dns as integer + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + dns: 12345 + rescue: + - name: Mark invalid dns type rejected + ansible.builtin.set_fact: + __invalid_input_dns_type_failed: true + when: >- + 'dns must be a string' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert dns as integer failed validation + ansible.builtin.assert: + that: + - __invalid_input_dns_type_failed | default(false) + fail_msg: >- + assert_role_vars should reject dns values that are not + a string or list + + - name: Assert rejects dns list with non-string element + block: + - name: Run role with dns list containing integer + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + dns: + - www.example.com + - 12345 + rescue: + - name: Mark dns list element rejected + ansible.builtin.set_fact: + __invalid_input_dns_element_failed: true + when: >- + 'dns must be a string' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert dns list with non-string element failed validation + ansible.builtin.assert: + that: + - __invalid_input_dns_element_failed | default(false) + fail_msg: >- + assert_role_vars should reject dns lists containing + non-string elements + + - name: Assert rejects ip as boolean + block: + - name: Run role with ip as boolean + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + ip: true + rescue: + - name: Mark invalid ip type rejected + ansible.builtin.set_fact: + __invalid_input_ip_type_failed: true + when: >- + 'ip must be a string' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert ip as boolean failed validation + ansible.builtin.assert: + that: + - __invalid_input_ip_type_failed | default(false) + fail_msg: >- + assert_role_vars should reject ip values that are not + a string or list + + - name: Assert rejects mode as boolean + block: + - name: Run role with mode as boolean + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_requests: + - name: mycert + ca: self-sign + mode: true + rescue: + - name: Mark invalid mode type rejected + ansible.builtin.set_fact: + __invalid_input_mode_type_failed: true + when: >- + 'mode must be a string' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert mode as boolean failed validation + ansible.builtin.assert: + that: + - __invalid_input_mode_type_failed | default(false) + fail_msg: >- + assert_role_vars should reject mode values that are not + a string or integer + + always: + - name: Clear test facts + ansible.builtin.set_fact: + __valid_defaults_succeeded: + __invalid_input_missing_name_failed: + __invalid_input_missing_ca_failed: + __invalid_input_key_size_type_failed: + __invalid_input_auto_renew_type_failed: + __invalid_input_wait_type_failed: + __invalid_input_trust_missing_name_failed: + __invalid_input_key_usage_choice_failed: + __invalid_input_trust_state_failed: + __invalid_input_trust_no_source_failed: + __invalid_input_trust_multiple_sources_failed: + __invalid_input_dns_type_failed: + __invalid_input_dns_element_failed: + __invalid_input_ip_type_failed: + __invalid_input_mode_type_failed: + tags: tests::cleanup