diff --git a/README-cert.md b/README-cert.md
new file mode 100644
index 00000000..ad2ee75c
--- /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)
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 34d8e69d..6bc009eb 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 00000000..bd9ab85a
--- /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 00000000..3dec4ca3
--- /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 00000000..5f704cce
--- /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 00000000..340f7bb1
--- /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 00000000..5d99d9f9
--- /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 00000000..62a4c5ef
--- /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 00000000..7bdb2df9
--- /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 c5f8d7f2..6bae7cc3 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 00000000..c88d4d1e
--- /dev/null
+++ b/plugins/modules/ipacert.py
@@ -0,0 +1,571 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Authors:
+# Sam Morris
+# Rafael Guterres Jeffman
+#
+# 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 .
+
+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 a199be56..4fe54242 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 00000000..aceedea1
--- /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 00000000..c57c6e13
--- /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 00000000..6e42ff4f
--- /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 00000000..41c97bb3
--- /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"