diff --git a/README-cert.md b/README-cert.md new file mode 100644 index 0000000000000000000000000000000000000000..ad2ee75c197fb840e775aaacfaf61833b98c2840 --- /dev/null +++ b/README-cert.md @@ -0,0 +1,175 @@ +Cert module +============ + +Description +----------- + +The cert module makes it possible to request, revoke and retrieve SSL certificates for hosts, services and users. + +Features +-------- + +* Certificate request +* Certificate hold/release +* Certificate revocation +* Certificate retrieval + + +Supported FreeIPA Versions +-------------------------- + +FreeIPA versions 4.4.0 and up are supported by the ipacert module. + + +Requirements +------------ + +**Controller** +* Ansible version: 2.8+ +* Some tool to generate a certificate signing request (CSR) might be needed, like `openssl`. + +**Node** +* Supported FreeIPA version (see above) + + +Usage +===== + +Example inventory file + +```ini +[ipaserver] +ipaserver.test.local +``` + +Example playbook to request a new certificate for a service: + +```yaml +--- +- name: Certificate request + hosts: ipaserver + + tasks: + - name: Request a certificate for a web server + ipacert: + ipaadmin_password: SomeADMINpassword + state: requested + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs + HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA + 5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU + SYaXm/gF8cDYjQI= + -----END CERTIFICATE REQUEST----- + principal: HTTP/www.example.com + register: cert +``` + +Example playbook to revoke an existing certificate: + +```yaml +--- +- name: Revoke certificate + hosts: ipaserver + + tasks: + - name Revoke a certificate + ipacert: + ipaadmin_password: SomeADMINpassword + serial_number: 123456789 + state: revoked +``` + +Example to hold a certificate (alias for revoking a certificate with reason `certificateHold (6)`): + +```yaml +--- +- name: Hold a certificate + hosts: ipaserver + + tasks: + - name: Hold certificate + ipacert: + ipaadmin_password: SomeADMINpassword + serial_number: 0xAB1234 + state: held +``` + +Example playbook to release hold of certificate (may be used with any revoked certificates, despite of the rovoke reason): + +```yaml +--- +- name: Release hold + hosts: ipaserver + + tasks: + - name: Take a revoked certificate off hold + ipacert: + ipaadmin_password: SomeADMINpassword + serial_number: 0xAB1234 + state: released +``` + +Example playbook to retrieve a certificate and save it to a file in the target node: + +```yaml +--- +- name: Retriev certificate + hosts: ipaserver + + tasks: + - name: Retrieve a certificate and save it to file 'cert.pem' + ipacert: + ipaadmin_password: SomeADMINpassword + certificate_out: cert.pem + state: retrieved +``` + + +ipacert +------- + +Variable | Description | Required +-------- | ----------- | -------- +`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no +`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no +`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no +`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to yes. (bool) | no +`csr` | X509 certificate signing request, in PEM format. | yes, if `state: requested` +`principal` | Host/service/user principal for the certificate. | yes, if `state: requested` +`add` \| `add_principal` | Automatically add the principal if it doesn't exist (service principals only). (bool) | no +`profile_id` \| `profile` | Certificate Profile to use | no +`ca` | Name of the issuing certificate authority. | no +`chain` | Include certificate chain in output. (bool) | no +`serial_number` | Certificate serial number. (int) | yes, if `state` is `retrieved`, `held`, `released` or `revoked`. +`revocation_reason` \| `reason` | Reason for revoking the certificate. Use one of the reason strings, or the corresponding value: "unspecified" (0), "keyCompromise" (1), "cACompromise" (2), "affiliationChanged" (3), "superseded" (4), "cessationOfOperation" (5), "certificateHold" (6), "removeFromCRL" (8), "privilegeWithdrawn" (9), "aACompromise" (10) | yes, if `state: revoked` +`certificate_out` | Write certificate (chain if `chain` is set) to this file, on the target node. | no +`state` | The state to ensure. It can be one of `requested`, `held`, `released`, `revoked`, or `retrieved`. `held` is the same as revoke with reason "certificateHold" (6). `released` is the same as `cert-revoke-hold` on IPA CLI, releasing the hold status of a certificate. | yes + + +Return Values +============= + +Values are returned only if `state` is `requested` or `retrieved` and if `certificate_out` is not defined. + +Variable | Description | Returned When +-------- | ----------- | ------------- +`certificate` | Certificate fields and data. (dict) <br>Options: | if `state` is `requested` or `retrieved` and if `certificate_out` is not defined + | `certificate` - Issued X509 certificate in PEM encoding. Will include certificate chain if `chain: true`. (list) | always + | `san_dnsname` - X509 Subject Alternative Name. | When DNSNames are present in the Subject Alternative Name extension of the issued certificate. + | `issuer` - X509 distinguished name of issuer. | always + | `subject` - X509 distinguished name of certificate subject. | always + | `serial_number` - Serial number of the issued certificate. (int) | always + | `revoked` - Revoked status of the certificate. (bool) | if certificate was revoked + | `owner_user` - The username that owns the certificate. | if `state: retrieved` and certificate is owned by a user + | `owner_host` - The host that owns the certificate. | if `state: retrieved` and certificate is owned by a host + | `owner_service` - The service that owns the certificate. | if `state: retrieved` and certificate is owned by a service + | `valid_not_before` - Time when issued certificate becomes valid, in GeneralizedTime format (YYYYMMDDHHMMSSZ) | always + | `valid_not_after` - Time when issued certificate ceases to be valid, in GeneralizedTime format (YYYYMMDDHHMMSSZ) | always + + +Authors +======= + +Sam Morris +Rafael Jeffman diff --git a/README.md b/README.md index 34d8e69d980d2ad45819733797bc81c1a22d4b89..6bc009ebe410a5b8d0ab201139d1925b9a478662 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Features * Modules for automount key management * Modules for automount location management * Modules for automount map management +* Modules for certificate management * Modules for config management * Modules for delegation management * Modules for dns config management @@ -436,6 +437,7 @@ Modules in plugin/modules * [ipaautomountkey](README-automountkey.md) * [ipaautomountlocation](README-automountlocation.md) * [ipaautomountmap](README-automountmap.md) +* [ipacert](README-cert.md) * [ipaconfig](README-config.md) * [ipadelegation](README-delegation.md) * [ipadnsconfig](README-dnsconfig.md) diff --git a/playbooks/cert/cert-hold.yml b/playbooks/cert/cert-hold.yml new file mode 100644 index 0000000000000000000000000000000000000000..bd9ab85ab9ce25e62ec635d0bfb0e5138814d9b4 --- /dev/null +++ b/playbooks/cert/cert-hold.yml @@ -0,0 +1,14 @@ +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Temporarily hold a certificate + ipacert: + serial_number: 12345 + state: held diff --git a/playbooks/cert/cert-release.yml b/playbooks/cert/cert-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..3dec4ca3029bc80ee8ee598e1d6467922e412eca --- /dev/null +++ b/playbooks/cert/cert-release.yml @@ -0,0 +1,15 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Release a certificate hold + ipacert: + serial_number: 12345 + state: released diff --git a/playbooks/cert/cert-request-host.yml b/playbooks/cert/cert-request-host.yml new file mode 100644 index 0000000000000000000000000000000000000000..5f704cce70e0d6261c601b50220dd3982e96b667 --- /dev/null +++ b/playbooks/cert/cert-request-host.yml @@ -0,0 +1,26 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Request a certificate for a host + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIIBWjCBxAIBADAbMRkwFwYDVQQDDBBob3N0LmV4YW1wbGUuY29tMIGfMA0GCSqG + SIb3DQEBAQUAA4GNADCBiQKBgQCzR3Vd4Cwl0uVgwB3+wxz+4JldFk3x526bPeuK + g8EEc+rEHILzJWeXC8ywCYPOgK9n7hrdMfVQiIx3yHYrY+0IYuLehWow4o1iJEf5 + urPNAP9K9C4Y7MMXzzoQmoWR3IFQQpOYwvWOtiZfvrhmtflnYEGLE2tgz53gOQHD + NnbCCwIDAQABoAAwDQYJKoZIhvcNAQELBQADgYEAgF+6YC39WhnvmFgNz7pjAh5E + 2ea3CgG+zrzAyiSBGG6WpXEjqMRnAQxciQNGxQacxjwWrscZidZzqg8URJPugewq + tslYB1+RkZn+9UWtfnWvz89+xnOgco7JlytnbH10Nfxt5fXXx13rY0tl54jBtk2W + 422eYZ12wb4gjNcQy3A= + -----END CERTIFICATE REQUEST----- + principal: host/host.example.com + state: requested diff --git a/playbooks/cert/cert-request-service.yml b/playbooks/cert/cert-request-service.yml new file mode 100644 index 0000000000000000000000000000000000000000..340f7bb1d176564b78bdb635b37c74aeb589fe24 --- /dev/null +++ b/playbooks/cert/cert-request-service.yml @@ -0,0 +1,23 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Request a certificate for a service + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs + HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA + 5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU + SYaXm/gF8cDYjQI= + -----END CERTIFICATE REQUEST----- + principal: HTTP/www.example.com + add: true + state: requested diff --git a/playbooks/cert/cert-request-user.yml b/playbooks/cert/cert-request-user.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d99d9f9bc9092a1ab028058252a4965186767ed --- /dev/null +++ b/playbooks/cert/cert-request-user.yml @@ -0,0 +1,27 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Request a certificate for a user with a specific profile + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIIBejCB5AIBADAQMQ4wDAYDVQQDDAVwaW5reTCBnzANBgkqhkiG9w0BAQEFAAOB + jQAwgYkCgYEA7uChccy1Is1FTM0SF23WPYW472E3ozeLh2kzhKR9Ni6FLmeEGgu7 + /hicR1VwvXHYkNwI1tpW9LqxRVvgr6vheqHySljrBcoRfshfYvKejp03l2327Bfq + BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr + MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq + hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j + AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx + 5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w== + -----END CERTIFICATE REQUEST----- + principal: pinky + profile: IECUserRoles + state: requested diff --git a/playbooks/cert/cert-retrieve.yml b/playbooks/cert/cert-retrieve.yml new file mode 100644 index 0000000000000000000000000000000000000000..62a4c5effb36342360350a2a9e35e30766c205e3 --- /dev/null +++ b/playbooks/cert/cert-retrieve.yml @@ -0,0 +1,16 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Retrieve a certificate + ipacert: + serial_number: 12345 + state: retrieved + register: cert_retrieved diff --git a/playbooks/cert/cert-revoke.yml b/playbooks/cert/cert-revoke.yml new file mode 100644 index 0000000000000000000000000000000000000000..7bdb2df9984bd049a100bd8e5195068d30c19640 --- /dev/null +++ b/playbooks/cert/cert-revoke.yml @@ -0,0 +1,18 @@ +--- +- name: Certificate manage example + hosts: ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_context: client + + tasks: + - name: Permanently revoke a certificate issued by a lightweight sub-CA + ipacert: + serial_number: 12345 + ca: vpn-ca + # reason: keyCompromise (1) + reason: 1 + state: revoked diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index c5f8d7f287f5aa32ba0c994eb831d1f2a9dfe6f4..6bae7cc3bf923ab3e5808a66e9f207d571e994f5 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -29,7 +29,8 @@ __all__ = ["gssapi", "netaddr", "api", "ipalib_errors", "Env", "DEFAULT_CONFIG", "LDAP_GENERALIZED_TIME_FORMAT", "kinit_password", "kinit_keytab", "run", "DN", "VERSION", "paths", "tasks", "get_credentials_if_valid", "Encoding", - "load_pem_x509_certificate", "DNSName", "getargspec"] + "DNSName", "getargspec", "certificate_loader", + "write_certificate_list"] import os # ansible-freeipa requires locale to be C, IPA requires utf-8. @@ -106,6 +107,7 @@ try: except ImportError: from ipalib.x509 import load_certificate certificate_loader = load_certificate + from ipalib.x509 import write_certificate_list # Try to import is_ipa_configured or use a fallback implementation. try: diff --git a/plugins/modules/ipacert.py b/plugins/modules/ipacert.py new file mode 100644 index 0000000000000000000000000000000000000000..c88d4d1e476822ef6ad1b11f5013fe352cf55226 --- /dev/null +++ b/plugins/modules/ipacert.py @@ -0,0 +1,571 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Sam Morris <sam@robots.org.uk> +# Rafael Guterres Jeffman <rjeffman@redhat.com> +# +# Copyright (C) 2021 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +ANSIBLE_METADATA = { + "metadata_version": "1.0", + "supported_by": "community", + "status": ["preview"], +} + +DOCUMENTATION = """ +--- +module: ipacert +short description: Manage FreeIPA certificates +description: Manage FreeIPA certificates +extends_documentation_fragment: + - ipamodule_base_docs +options: + csr: + description: | + X509 certificate signing request, in RFC 7468 PEM encoding. + Only available if `state: requested`, required if `csr_file` is not + provided. + type: str + csr_file: + description: | + Path to file with X509 certificate signing request, in RFC 7468 PEM + encoding. Only available if `state: requested`, required if `csr_file` + is not provided. + type: str + principal: + description: | + Host/service/user principal for the certificate. + Required if `state: requested`. Only available if `state: requested`. + type: str + add: + description: | + Automatically add the principal if it doesn't exist (service + principals only). Only available if `state: requested`. + type: bool + aliases: ["add_principal"] + required: false + ca: + description: Name of the issuing certificate authority. + type: str + required: false + serial_number: + description: | + Certificate serial number. Cannot be used with `state: requested`. + Required for all states, except `requested`. + type: int + profile: + description: Certificate Profile to use. + type: str + aliases: ["profile_id"] + required: false + revocation_reason: + description: | + Reason for revoking the certificate. Use one of the reason strings, + or the corresponding value: "unspecified" (0), "keyCompromise" (1), + "cACompromise" (2), "affiliationChanged" (3), "superseded" (4), + "cessationOfOperation" (5), "certificateHold" (6), "removeFromCRL" (8), + "privilegeWithdrawn" (9), "aACompromise" (10). + Use only if `state: revoked`. Required if `state: revoked`. + type: raw + aliases: ['reason'] + certificate_out: + description: | + Write certificate (chain if `chain` is set) to this file, on the target + node.. Use only when `state` is `requested` or `retrieved`. + type: str + required: false + state: + description: | + The state to ensure. `held` is the same as revoke with reason + "certificateHold" (6). `released` is the same as `cert-revoke-hold` + on IPA CLI, releasing the hold status of a certificate. + choices: ["requested", "held", "released", "revoked", "retrieved"] + required: true + type: str +author: +authors: + - Sam Morris (@yrro) + - Rafael Guterres Jeffman (@rjeffman) +""" + +EXAMPLES = """ +- name: Request a certificate for a web server + ipacert: + ipaadmin_password: SomeADMINpassword + state: requested + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs + HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA + 5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU + SYaXm/gF8cDYjQI= + -----END CERTIFICATE REQUEST----- + principal: HTTP/www.example.com + register: cert + +- name: Request certificate for a user, with an appropriate profile. + ipacert: + ipaadmin_password: SomeADMINpassword + csr: | + -----BEGIN CERTIFICATE REQUEST----- + MIIBejCB5AIBADAQMQ4wDAYDVQQDDAVwaW5reTCBnzANBgkqhkiG9w0BAQEFAAOB + jQAwgYkCgYEA7uChccy1Is1FTM0SF23WPYW472E3ozeLh2kzhKR9Ni6FLmeEGgu7 + /hicR1VwvXHYkNwI1tpW9LqxRVvgr6vheqHySljrBcoRfshfYvKejp03l2327Bfq + BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr + MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq + hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j + AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx + 5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w== + -----END CERTIFICATE REQUEST----- + principal: pinky + profile_id: IECUserRoles + state: requested + +- name: Temporarily hold a certificate + ipacert: + ipaadmin_password: SomeADMINpassword + serial_number: 12345 + state: held + +- name: Remove a certificate hold + ipacert: + ipaadmin_password: SomeADMINpassword + state: released + serial_number: 12345 + +- name: Permanently revoke a certificate issued by a lightweight sub-CA + ipacert: + ipaadmin_password: SomeADMINpassword + state: revoked + ca: vpn-ca + serial_number: 0x98765 + reason: keyCompromise + +- name: Retrieve a certificate + ipacert: + ipaadmin_password: SomeADMINpassword + serial_number: 12345 + state: retrieved + register: cert_retrieved +""" + +RETURN = """ +certificate: + description: Certificate fields and data. + returned: | + if `state` is `requested` or `retrived` and `certificate_out` + is not defined. + type: dict + contains: + certificate: + description: | + Issued X509 certificate in PEM encoding. Will include certificate + chain if `chain: true` is used. + type: list + elements: str + returned: always + issuer: + description: X509 distinguished name of issuer. + type: str + sample: CN=Certificate Authority,O=EXAMPLE.COM + returned: always + serial_number: + description: Serial number of the issued certificate. + type: int + sample: 902156300 + returned: always + valid_not_after: + description: | + Time when issued certificate ceases to be valid, + in GeneralizedTime format (YYYYMMDDHHMMSSZ). + type: str + returned: always + valid_not_before: + description: | + Time when issued certificate becomes valid, in + GeneralizedTime format (YYYYMMDDHHMMSSZ). + type: str + returned: always + subject: + description: X509 distinguished name of certificate subject. + type: str + sample: CN=www.example.com,O=EXAMPLE.COM + returned: always + san_dnsname: + description: X509 Subject Alternative Name. + type: list + elements: str + sample: ['www.example.com', 'other.example.com'] + returned: | + when DNSNames are present in the Subject Alternative Name + extension of the issued certificate. + revoked: + description: Revoked status of the certificate. + type: bool + returned: always + owner_user: + description: The username that owns the certificate. + type: str + returned: when `state` is `retrieved` + owner_host: + description: The host that owns the certificate. + type: str + returned: when `state` is `retrieved` + owner_service: + description: The service that owns the certificate. + type: str + returned: when `state` is `retrieved` +""" + +import base64 +import time +import ssl + +from ansible.module_utils import six +from ansible.module_utils._text import to_text +from ansible.module_utils.ansible_freeipa_module import ( + IPAAnsibleModule, certificate_loader, write_certificate_list, +) + +if six.PY3: + unicode = str + +# Reasons are defined in RFC 5280 sec. 5.3.1; removeFromCRL is not present in +# this list; run the module with state=released instead. +REVOCATION_REASONS = { + 'unspecified': 0, + 'keyCompromise': 1, + 'cACompromise': 2, + 'affiliationChanged': 3, + 'superseded': 4, + 'cessationOfOperation': 5, + 'certificateHold': 6, + 'removeFromCRL': 8, + 'privilegeWithdrawn': 9, + 'aACompromise': 10, +} + + +def gen_args( + module, principal=None, add_principal=None, ca=None, chain=None, + profile=None, certificate_out=None, reason=None +): + args = {} + if principal is not None: + args['principal'] = principal + if add_principal is not None: + args['add'] = add_principal + if ca is not None: + args['cacn'] = ca + if profile is not None: + args['profile_id'] = profile + if certificate_out is not None: + args['out'] = certificate_out + if chain: + args['chain'] = True + if ca: + args['cacn'] = ca + if reason is not None: + args['revocation_reason'] = get_revocation_reason(module, reason) + return args + + +def get_revocation_reason(module, reason): + """Ensure revocation reasion is a valid integer code.""" + reason_int = -1 + + try: + reason_int = int(reason) + except ValueError: + reason_int = REVOCATION_REASONS.get(reason, -1) + + if reason_int not in REVOCATION_REASONS.values(): + module.fail_json(msg="Invalid revocation reason: %s" % reason) + + return reason_int + + +def parse_cert_timestamp(dt): + """Ensure time is in GeneralizedTime format (YYYYMMDDHHMMSSZ).""" + return time.strftime( + "%Y%m%d%H%M%SZ", + time.strptime(dt, "%a %b %d %H:%M:%S %Y UTC") + ) + + +def result_handler(_module, result, _command, _name, _args, exit_args, chain): + """Split certificate into fields.""" + if chain: + exit_args['certificate'] = [ + ssl.DER_cert_to_PEM_cert(c) + for c in result['result'].get('certificate_chain', []) + ] + else: + exit_args['certificate'] = [ + ssl.DER_cert_to_PEM_cert( + base64.b64decode(result['result']['certificate']) + ) + ] + + exit_args['san_dnsname'] = [ + str(dnsname) + for dnsname in result['result'].get('san_dnsname', []) + ] + + exit_args.update({ + key: result['result'][key] + for key in [ + 'issuer', 'subject', 'serial_number', + 'revoked', 'revocation_reason' + ] + if key in result['result'] + }) + exit_args.update({ + key: result['result'][key][0] + for key in ['owner_user', 'owner_host', 'owner_service'] + if key in result['result'] + }) + + exit_args.update({ + key: parse_cert_timestamp(result['result'][key]) + for key in ['valid_not_after', 'valid_not_before'] + if key in result['result'] + }) + + +def do_cert_request( + module, csr, principal, add_principal=None, ca=None, profile=None, + chain=None, certificate_out=None +): + """Request a certificate.""" + args = gen_args( + module, principal=principal, ca=ca, chain=chain, + add_principal=add_principal, profile=profile, + ) + exit_args = {} + commands = [[to_text(csr), "cert_request", args]] + changed = module.execute_ipa_commands( + commands, + result_handler=result_handler, + exit_args=exit_args, + chain=chain + ) + + if certificate_out is not None: + certs = ( + certificate_loader(cert.encode("utf-8")) + for cert in exit_args['certificate'] + ) + write_certificate_list(certs, certificate_out) + exit_args = {} + + return changed, exit_args + + +def do_cert_revoke(ansible_module, serial_number, reason=None, ca=None): + """Revoke a certificate.""" + _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca) + if not cert or cert.get('revoked', False): + return False, cert + + args = gen_args(ansible_module, ca=ca, reason=reason) + + commands = [[serial_number, "cert_revoke", args]] + changed = ansible_module.execute_ipa_commands(commands) + + return changed, cert + + +def do_cert_release(ansible_module, serial_number, ca=None): + """Release hold on certificate.""" + _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca) + revoked = cert.get('revoked', True) + reason = cert.get('revocation_reason', -1) + if cert and not revoked: + return False, cert + + if revoked and reason != 6: # can only release held certificates + ansible_module.fail_json( + msg="Cannot release hold on certificate revoked with" + " reason: %d" % reason + ) + args = gen_args(ansible_module, ca=ca) + + commands = [[serial_number, "cert_remove_hold", args]] + changed = ansible_module.execute_ipa_commands(commands) + + return changed, cert + + +def do_cert_retrieve( + module, serial_number, ca=None, chain=None, outfile=None +): + """Retrieve a certificate with 'cert-show'.""" + args = gen_args(module, ca=ca, chain=chain, certificate_out=outfile) + exit_args = {} + commands = [[serial_number, "cert_show", args]] + module.execute_ipa_commands( + commands, + result_handler=result_handler, + exit_args=exit_args, + chain=chain, + ) + if outfile is not None: + exit_args = {} + + return False, exit_args + + +def main(): + ansible_module = IPAAnsibleModule( + argument_spec=dict( + # requested + csr=dict(type="str"), + csr_file=dict(type="str"), + principal=dict(type="str"), + add_principal=dict(type="bool", required=False, aliases=["add"]), + profile_id=dict(type="str", aliases=["profile"], required=False), + # revoked + revocation_reason=dict(type="raw", aliases=["reason"]), + # general + serial_number=dict(type="int"), + ca=dict(type="str"), + chain=dict(type="bool", required=False), + certificate_out=dict(type="str", required=False), + # state + state=dict( + type="str", + required=True, + choices=[ + "requested", "held", "released", "revoked", "retrieved" + ] + ), + ), + mutually_exclusive=[["csr", "csr_file"]], + required_if=[ + ('state', 'requested', ['principal']), + ('state', 'retrieved', ['serial_number']), + ('state', 'held', ['serial_number']), + ('state', 'released', ['serial_number']), + ('state', 'revoked', ['serial_number', 'revocation_reason']), + ], + supports_check_mode=False, + ) + + ansible_module._ansible_debug = True + + # Get parameters + + # requested + csr = ansible_module.params_get("csr") + csr_file = ansible_module.params_get("csr_file") + principal = ansible_module.params_get("principal") + add_principal = ansible_module.params_get("add_principal") + profile = ansible_module.params_get("profile_id") + + # revoked + reason = ansible_module.params_get("revocation_reason") + + # general + serial_number = ansible_module.params.get("serial_number") + ca = ansible_module.params_get("ca") + chain = ansible_module.params_get("chain") + certificate_out = ansible_module.params_get("certificate_out") + + # state + state = ansible_module.params_get("state") + + # Check parameters + + if ansible_module.params_get("ipaapi_context") == "server": + ansible_module.fail_json( + msg="Context 'server' for ipacert is not yet supported." + ) + + invalid = [] + if state == "requested": + invalid = ["serial_number", "revocation_reason"] + if csr is None and csr_file is None: + ansible_module.fail_json( + msg="Required 'csr' or 'csr_file' with 'state: requested'.") + else: + invalid = [ + "csr", "principal", "add_principal", "profile" + "certificate_out" + ] + if state in ["released", "held"]: + invalid.extend(["revocation_reason", "certificate_out", "chain"]) + if state == "retrieved": + invalid.append("revocation_reason") + if state == "revoked": + invalid.extend(["certificate_out", "chain"]) + elif state == "held": + reason = 6 # certificateHold + + ansible_module.params_fail_used_invalid(invalid, state) + + # Init + + changed = False + exit_args = {} + + # Connect to IPA API + # If executed on 'server' contexot, cert plugin uses the IPA RA agent + # TLS client certificate/key, which users are not able to access, + # resulting in a 'permission denied' exception when attempting to connect + # the CA service. Therefore 'client' context in forced for this module. + with ansible_module.ipa_connect(context="client"): + + if state == "requested": + if csr_file is not None: + with open(csr_file, "rt") as csr_in: + csr = "".join(csr_in.readlines()) + changed, exit_args = do_cert_request( + ansible_module, + csr, + principal, + add_principal, + ca, + profile, + chain, + certificate_out + ) + + elif state in ("held", "revoked"): + changed, exit_args = do_cert_revoke( + ansible_module, serial_number, reason, ca) + + elif state == "released": + changed, exit_args = do_cert_release( + ansible_module, serial_number, ca) + + elif state == "retrieved": + changed, exit_args = do_cert_retrieve( + ansible_module, serial_number, ca, chain, certificate_out) + + # Done + + ansible_module.exit_json(changed=changed, certificate=exit_args) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg index a199be564b12fd95627f85a44f5f55df09851993..4fe542423e7b8cc0c5e297d4f4a54cbd4218ba87 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,7 @@ disable = [pylint.BASIC] good-names = ex, i, j, k, Run, _, e, x, dn, cn, ip, os, unicode, __metaclass__, ds, + dt, ca, # These are utils tools, and not part of the released collection. galaxyfy-playbook, galaxyfy-README, galaxyfy-module-EXAMPLES, module_EXAMPLES diff --git a/tests/cert/test_cert_client_context.yml b/tests/cert/test_cert_client_context.yml new file mode 100644 index 0000000000000000000000000000000000000000..aceedea185414404fe95bf7cb43e5de26229668a --- /dev/null +++ b/tests/cert/test_cert_client_context.yml @@ -0,0 +1,60 @@ +--- +- name: Test cert + hosts: ipaclients, ipaserver + become: false + gather_facts: false + module_defaults: + ipacert: + ipaadmin_password: SomeADMINpassword + ipaapi_contetx: "{{ ipa_context | default(omit) }}" + + tasks: + - name: Include FreeIPA facts. + ansible.builtin.include_tasks: ../env_freeipa_facts.yml + + # Test will only be executed if host is not a server. + - name: Execute with server context in the client. + ipacert: + ipaapi_context: server + name: ThisShouldNotWork + register: result + failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*")) + when: ipa_host_is_client + +# Import basic module tests, and execute with ipa_context set to 'client'. +# If ipaclients is set, it will be executed using the client, if not, +# ipaserver will be used. +# +# With this setup, tests can be executed against an IPA client, against +# an IPA server using "client" context, and ensure that tests are executed +# in upstream CI. + +- name: Test host certs using client context, in client host. + ansible.builtin.import_playbook: test_cert_host.yml + when: groups['ipaclients'] + vars: + ipa_test_host: ipaclients + +- name: Test service certs using client context, in client host. + ansible.builtin.import_playbook: test_cert_service.yml + when: groups['ipaclients'] + vars: + ipa_test_host: ipaclients + +- name: Test user certs using client context, in client host. + ansible.builtin.import_playbook: test_cert_user.yml + when: groups['ipaclients'] + vars: + ipa_test_host: ipaclients + +- name: Test host certs using client context, in server host. + ansible.builtin.import_playbook: test_cert_host.yml + when: groups['ipaclients'] is not defined or not groups['ipaclients'] + +- name: Test service certs using client context, in server host. + ansible.builtin.import_playbook: test_cert_service.yml + when: groups['ipaclients'] is not defined or not groups['ipaclients'] + +- name: Test user certs using client context, in server host. + ansible.builtin.import_playbook: test_cert_user.yml + when: groups['ipaclients'] is not defined or not groups['ipaclients'] diff --git a/tests/cert/test_cert_host.yml b/tests/cert/test_cert_host.yml new file mode 100644 index 0000000000000000000000000000000000000000..c57c6e13e177aff1c1eb39cf224f307491ee886e --- /dev/null +++ b/tests/cert/test_cert_host.yml @@ -0,0 +1,214 @@ +--- +- name: Test host certificate requests + hosts: "{{ ipa_test_host | default('ipaserver') }}" + become: false + gather_facts: false + module_defaults: + ipahost: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + ipacert: + ipaadmin_password: SomeADMINpassword + # ipacert only supports client context + ipaapi_context: "client" + + tasks: + + # SETUP + + - name: Ensure test files do not exist + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/host.csr" + + # Ensure test items exist + + - name: Ensure domain name is set + ansible.builtin.set_fact: + ipa_domain: ipa.test + when: ipa_domain is not defined + + - name: Ensure test host exists + ipahost: + name: "certhost.{{ ipa_domain }}" + state: present + force: true + + - name: Create CSR + ansible.builtin.shell: + cmd: "openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certhost.{{ ipa_domain }}" + register: host_req + + - name: Create CSR file + ansible.builtin.copy: + dest: "/root/host.csr" + content: "{{ host_req.stdout }}" + mode: 0644 + + # TESTS + + - name: Request certificate for host + ipacert: + csr: '{{ host_req.stdout }}' + principal: "host/certhost.{{ ipa_domain }}" + state: requested + register: host_cert + failed_when: not host_cert.changed or host_cert.failed + + - name: Display data from the requested certificate. + ansible.builtin.debug: + var: host_cert + + - name: Retrieve certificate for host + ipacert: + serial_number: "{{ host_cert.certificate.serial_number }}" + state: retrieved + register: retrieved + failed_when: retrieved.certificate.serial_number != host_cert.certificate.serial_number + + - name: Display data from the retrieved certificate. + ansible.builtin.debug: + var: retrieved + + - name: Place certificate on hold + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: held + register: result + failed_when: not result.changed or result.failed + + - name: Place certificate on hold, again + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: held + register: result + failed_when: result.changed or result.failed + + - name: Release hold on certificate + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.changed or result.failed + + - name: Release hold on certificate, again + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: released + register: result + failed_when: result.changed or result.failed + + - name: Revoke certificate + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: not result.changed or result.failed + + - name: Revoke certificate, again + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: result.changed or result.failed + + - name: Try to revoke inexistent certificate + ipacert: + serial_number: 0x123456789 + reason: 9 + state: revoked + register: result + failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg)) + + - name: Try to release revoked certificate + ipacert: + serial_number: '{{ host_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg + + - name: Request certificate for host and save to file + ipacert: + csr: '{{ host_req.stdout }}' + principal: "host/certhost.{{ ipa_domain }}" + certificate_out: "/root/cert_1.pem" + state: requested + register: result + failed_when: not result.changed or result.failed or result.certificate + + - name: Check requested certificate file + ansible.builtin.file: + path: "/root/cert_1.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Retrieve certificate for host to a file + ipacert: + serial_number: "{{ host_cert.certificate.serial_number }}" + certificate_out: "/root/retrieved.pem" + state: retrieved + register: result + failed_when: result.changed or result.failed or result.certificate + + - name: Check retrieved certificate file + ansible.builtin.file: + path: "/root/retrieved.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Request with invalid CSR. + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr + MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq + hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j + AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx + 5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w== + -----END CERTIFICATE REQUEST----- + principal: "host/certhost.{{ ipa_domain }}" + state: requested + register: result + failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg) + + - name: Request certificate using a file + ipacert: + csr_file: "/root/host.csr" + principal: "host/certhost.{{ ipa_domain }}" + state: requested + register: result + failed_when: not result.changed or result.failed + + - name: Request certificate using an invalid profile + ipacert: + csr_file: "/root/host.csr" + principal: "host/certhost.{{ ipa_domain }}" + profile: invalid_profile + state: requested + register: result + failed_when: not (result.failed and "Request failed with status 400" in result.msg) + + + # CLEANUP TEST ITEMS + + - name: Removet test host + ipahost: + name: "certhost.{{ ipa_domain }}" + state: absent + + - name: Ensure test files do not exist + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/host.csr" diff --git a/tests/cert/test_cert_service.yml b/tests/cert/test_cert_service.yml new file mode 100644 index 0000000000000000000000000000000000000000..6e42ff4fa9c5256c03acb200cdc7e82aea9b272b --- /dev/null +++ b/tests/cert/test_cert_service.yml @@ -0,0 +1,232 @@ +--- +- name: Test service certificate requests + hosts: "{{ ipa_test_host | default('ipaserver') }}" + # Change "become" or "gather_facts" to "yes", + # if you test playbook requires any. + become: false + gather_facts: false + module_defaults: + ipahost: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + ipaservice: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + ipacert: + ipaadmin_password: SomeADMINpassword + # ipacert only supports client context + ipaapi_context: "client" + + tasks: + + # SETUP + + - name: Ensure test files do not exist + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/service.csr" + + # Ensure test items exist + + - name: Ensure domain name is set + ansible.builtin.set_fact: + ipa_domain: ipa.test + when: ipa_domain is not defined + + - name: Ensure test host exist + ipahost: + name: "certservice.{{ ipa_domain }}" + force: true + state: present + + - name: Ensure service exist + ipaservice: + name: "HTTP/certservice.{{ ipa_domain }}" + force: true + state: present + + - name: Create signing request for certificate + ansible.builtin.shell: + cmd: "openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certservice.{{ ipa_domain }}" + register: service_req + + - name: Create CSR file + ansible.builtin.copy: + dest: "/root/service.csr" + content: "{{ service_req.stdout }}" + mode: '0644' + + # TESTS + + - name: Request certificate for service + ipacert: + csr: '{{ service_req.stdout }}' + principal: "HTTP/certservice.{{ ipa_domain }}" + add_principal: true + state: requested + register: service_cert + failed_when: not service_cert.changed or service_cert.failed + + - name: Display data from the requested certificate. + ansible.builtin.debug: + var: service_cert + + - name: Retrieve certificate for service + ipacert: + serial_number: "{{ service_cert.certificate.serial_number }}" + state: retrieved + register: retrieved + failed_when: retrieved.certificate.serial_number != service_cert.certificate.serial_number + + - name: Display data from the retrieved certificate. + ansible.builtin.debug: + var: retrieved + + - name: Place certificate on hold + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: held + register: result + failed_when: not result.changed or result.failed + + - name: Place certificate on hold, again + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: held + register: result + failed_when: result.changed or result.failed + + - name: Release hold on certificate + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.changed or result.failed + + - name: Release hold on certificate, again + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: released + register: result + failed_when: result.changed or result.failed + + - name: Revoke certificate + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: not result.changed or result.failed + + - name: Revoke certificate, again + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: result.changed or result.failed + + - name: Try to revoke inexistent certificate + ipacert: + serial_number: 0x123456789 + reason: 9 + state: revoked + register: result + failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg)) + + - name: Try to release revoked certificate + ipacert: + serial_number: '{{ service_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg + + - name: Request certificate for service and save to file + ipacert: + csr: '{{ service_req.stdout }}' + principal: "HTTP/certservice.{{ ipa_domain }}" + add_principal: true + certificate_out: "/root/cert_1.pem" + state: requested + register: result + failed_when: not result.changed or result.failed or result.certificate + + - name: Check requested certificate file + ansible.builtin.file: + path: "/root/cert_1.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Retrieve certificate for service to a file + ipacert: + serial_number: "{{ service_cert.certificate.serial_number }}" + certificate_out: "/root/retrieved.pem" + state: retrieved + register: result + failed_when: result.changed or result.failed or result.certificate + + - name: Check retrieved certificate file + ansible.builtin.file: + path: "/root/retrieved.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Request with invalid CSR. + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr + MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq + hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j + AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx + 5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w== + -----END CERTIFICATE REQUEST----- + principal: "HTTP/certservice.{{ ipa_domain }}" + state: requested + register: result + failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg) + + - name: Request certificate using a file + ipacert: + csr_file: "/root/service.csr" + principal: "HTTP/certservice.{{ ipa_domain }}" + state: requested + register: result + failed_when: not result.changed or result.failed + + - name: Request certificate using an invalid profile + ipacert: + csr_file: "/root/service.csr" + principal: "HTTP/certservice.{{ ipa_domain }}" + profile: invalid_profile + state: requested + register: result + failed_when: not (result.failed and "Request failed with status 400" in result.msg) + + # CLEANUP TEST ITEMS + + - name: Remove test service + ipaservice: + name: "HTTP/certservice.{{ ipa_domain }}" + state: absent + continue: true + + - name: Remove test host + ipahost: + name: certservice.example.com + state: absent + + - name: Ensure test files do not exist + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/service.csr" diff --git a/tests/cert/test_cert_user.yml b/tests/cert/test_cert_user.yml new file mode 100644 index 0000000000000000000000000000000000000000..41c97bb3f2eb173aa9a7810887faae8cf2d28e30 --- /dev/null +++ b/tests/cert/test_cert_user.yml @@ -0,0 +1,213 @@ +--- +- name: Test user certificate requests + hosts: "{{ ipa_test_host | default('ipaserver') }}" + become: false + gather_facts: false + module_defaults: + ipauser: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + ipacert: + ipaadmin_password: SomeADMINpassword + # ipacert only supports client context + ipaapi_context: "client" + + tasks: + + # Ensure test files do not exist + + - name: Check retrieved certificate file + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/user.csr" + + # Ensure test items exist. + + - name: Ensure test user exists + ipauser: + name: certuser + first: certificate + last: user + + - name: Crete CSR + ansible.builtin.shell: + cmd: + 'openssl req -newkey rsa:1024 -keyout /dev/null -nodes -subj /CN=certuser -reqexts IECUserRoles + -config <(cat /etc/pki/tls/openssl.cnf; printf "[IECUserRoles]\n1.2.840.10070.8.1=ASN1:UTF8String:hello world")' + executable: /bin/bash + register: user_req + + - name: Create CSR file + ansible.builtin.copy: + dest: "/root/user.csr" + content: "{{ user_req.stdout }}" + mode: 0644 + + # TESTS + + - name: Request certificate for user + ipacert: + csr: '{{ user_req.stdout }}' + principal: certuser + profile: IECUserRoles + state: requested + register: user_cert + failed_when: not user_cert.changed or user_cert.failed + + - name: Display data from the requested certificate. + ansible.builtin.debug: + var: user_cert + + - name: Retrieve certificate for user + ipacert: + serial_number: "{{ user_cert.certificate.serial_number }}" + state: retrieved + register: retrieved + failed_when: retrieved.certificate.serial_number != user_cert.certificate.serial_number + + - name: Display data from the retrieved certificate. + ansible.builtin.debug: + var: retrieved + + - name: Place certificate on hold + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: held + register: result + failed_when: not result.changed or result.failed + + - name: Place certificate on hold, again + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: held + register: result + failed_when: result.changed or result.failed + + - name: Release hold on certificate + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.changed or result.failed + + - name: Release hold on certificate, again + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: released + register: result + failed_when: result.changed or result.failed + + - name: Revoke certificate + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: not result.changed or result.failed + + - name: Revoke certificate, again + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: revoked + reason: keyCompromise + register: result + failed_when: result.changed or result.failed + + - name: Try to revoke inexistent certificate + ipacert: + serial_number: 0x123456789 + reason: 9 + state: revoked + register: result + failed_when: not (result.failed and ("Request failed with status 404" in result.msg or "Certificate serial number 0x123456789 not found" in result.msg)) + + - name: Try to release revoked certificate + ipacert: + serial_number: '{{ user_cert.certificate.serial_number }}' + state: released + register: result + failed_when: not result.failed or "Cannot release hold on certificate revoked with reason" not in result.msg + + - name: Request certificate for user and save to file + ipacert: + csr: '{{ user_req.stdout }}' + principal: certuser + profile: IECUserRoles + certificate_out: "/root/cert_1.pem" + state: requested + register: result + failed_when: not result.changed or result.failed or result.certificate + + - name: Check requested certificate file + ansible.builtin.file: + path: "/root/cert_1.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Retrieve certificate for user to a file + ipacert: + serial_number: "{{ user_cert.certificate.serial_number }}" + certificate_out: "/root/retrieved.pem" + state: retrieved + register: result + failed_when: result.changed or result.failed or result.certificate + + - name: Check retrieved certificate file + ansible.builtin.file: + path: "/root/retrieved.pem" + check_mode: true + register: result + failed_when: result.changed or result.failed + + - name: Request with invalid CSR. + ipacert: + csr: | + -----BEGIN CERTIFICATE REQUEST----- + BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr + MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq + hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j + AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx + 5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w== + -----END CERTIFICATE REQUEST----- + principal: certuser + state: requested + register: result + failed_when: not (result.failed and "Failure decoding Certificate Signing Request" in result.msg) + + - name: Request certificate using a file + ipacert: + csr_file: "/root/user.csr" + principal: certuser + state: requested + register: result + failed_when: not result.changed or result.failed + + - name: Request certificate using an invalid profile + ipacert: + csr_file: "/root/user.csr" + principal: certuser + profile: invalid_profile + state: requested + register: result + failed_when: not (result.failed and "Request failed with status 400" in result.msg) + + # CLEANUP TEST ITEMS + + - name: Remove test user + ipauser: + name: certuser + state: absent + + - name: Check retrieved certificate file + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_items: + - "/root/retrieved.pem" + - "/root/cert_1.pem" + - "/root/user.csr"