0fcb1e
From 9246a8a003b2b0062e07c289cd7cde8fe902b16f Mon Sep 17 00:00:00 2001
0fcb1e
From: Rob Crittenden <rcritten@redhat.com>
0fcb1e
Date: Thu, 12 Jan 2023 15:06:27 -0500
0fcb1e
Subject: [PATCH] ipa-acme-manage: add certificate/request pruning management
0fcb1e
0fcb1e
Configures PKI to remove expired certificates and non-resolved
0fcb1e
requests on a schedule.
0fcb1e
0fcb1e
This is geared towards ACME which can generate a lot of certificates
0fcb1e
over a short period of time but is general purpose. It lives in
0fcb1e
ipa-acme-manage because that is the primary reason for including it.
0fcb1e
0fcb1e
Random Serial Numbers v3 must be enabled for this to work.
0fcb1e
0fcb1e
Enabling pruning enables the job scheduler within CS and sets the
0fcb1e
job user as the IPA RA user which has full rights to certificates
0fcb1e
and requests.
0fcb1e
0fcb1e
Disabling pruning does not disable the job scheduler because the
0fcb1e
tool is stateless. Having the scheduler enabled should not be a
0fcb1e
problem.
0fcb1e
0fcb1e
A restart of PKI is required to apply any changes. This tool forks
0fcb1e
out to pki-server which does direct writes to CS.cfg. It might
0fcb1e
be easier to use our own tooling for this but this makes the
0fcb1e
integration tighter so we pick up any improvements in PKI.
0fcb1e
0fcb1e
The "cron" setting is quite limited, taking only integer values
0fcb1e
and *. It does not accept ranges, either - or /.
0fcb1e
0fcb1e
No error checking is done in PKI when setting a value, only when
0fcb1e
attempting to use it, so some rudimentary validation is done.
0fcb1e
0fcb1e
Fixes: https://pagure.io/freeipa/issue/9294
0fcb1e
0fcb1e
Signed-off-by: Rob Crittenden rcritten@redhat.com
0fcb1e
Reviewed-By: Florence Blanc-Renaud <flo@redhat.com>
0fcb1e
---
0fcb1e
 install/tools/man/ipa-acme-manage.1    |  83 +++++++
0fcb1e
 ipaserver/install/ipa_acme_manage.py   | 303 ++++++++++++++++++++++++-
0fcb1e
 ipatests/test_integration/test_acme.py | 158 +++++++++++++
0fcb1e
 3 files changed, 534 insertions(+), 10 deletions(-)
0fcb1e
0fcb1e
diff --git a/install/tools/man/ipa-acme-manage.1 b/install/tools/man/ipa-acme-manage.1
0fcb1e
index e15d25bd0017d8bd71e425fcb633827fa6f67693..e6cec4e4a7fd460c514a72456a2dc9a2e3682ebd 100644
0fcb1e
--- a/install/tools/man/ipa-acme-manage.1
0fcb1e
+++ b/install/tools/man/ipa-acme-manage.1
0fcb1e
@@ -27,6 +27,89 @@ Disable the ACME service on this host.
0fcb1e
 .TP
0fcb1e
 \fBstatus\fR
0fcb1e
 Display the status of the ACME service.
0fcb1e
+.TP
0fcb1e
+\fBpruning\fR
0fcb1e
+Configure certificate and request pruning.
0fcb1e
+
0fcb1e
+.SH "PRUNING"
0fcb1e
+Pruning is a job that runs in the CA that can remove expired
0fcb1e
+certificates and certificate requests which have not been issued.
0fcb1e
+This is particularly important when using short-lived certificates
0fcb1e
+like those issued with the ACME protocol. Pruning requires that
0fcb1e
+the IPA server be installed with random serial numbers enabled.
0fcb1e
+
0fcb1e
+The CA needs to be restarted after modifying the pruning configuration.
0fcb1e
+
0fcb1e
+The job is a cron-like task within the CA that is controlled by a
0fcb1e
+number of options which dictate how long after the certificate or
0fcb1e
+request is considered no longer valid and removed from the LDAP
0fcb1e
+database.
0fcb1e
+
0fcb1e
+The cron time and date fields are:
0fcb1e
+.IP
0fcb1e
+.ta 1.5i
0fcb1e
+field	allowed values
0fcb1e
+.br
0fcb1e
+-----	--------------
0fcb1e
+.br
0fcb1e
+minute	0-59
0fcb1e
+.br
0fcb1e
+hour	0-23
0fcb1e
+.br
0fcb1e
+day of month	1-31
0fcb1e
+.br
0fcb1e
+month	1-12
0fcb1e
+.br
0fcb1e
+day of week	0-6 (0 is Sunday)
0fcb1e
+.br
0fcb1e
+.PP
0fcb1e
+
0fcb1e
+The cron syntax is limited to * or specific numbers. Ranges are not supported.
0fcb1e
+
0fcb1e
+.TP
0fcb1e
+\fB\-\-enable\fR
0fcb1e
+Enable certificate pruning.
0fcb1e
+.TP
0fcb1e
+\fB\-\-disable\fR
0fcb1e
+Disable certificate pruning.
0fcb1e
+.TP
0fcb1e
+\fB\-\-cron=CRON\fR
0fcb1e
+Configure the pruning cron job. The syntax is similar to crontab(5) syntax.
0fcb1e
+For example, "0 0 1 * *" schedules the job to run at 12:00am on the first
0fcb1e
+day of each month.
0fcb1e
+.TP
0fcb1e
+\fB\-\-certretention=CERTRETENTION\fR
0fcb1e
+Certificate retention time. The default is 30.
0fcb1e
+.TP
0fcb1e
+\fB\-\-certretentionunit=CERTRETENTIONUNIT\fR
0fcb1e
+Certificate retention units. Valid units are: minute, hour, day, year.
0fcb1e
+The default is days.
0fcb1e
+.TP
0fcb1e
+\fB\-\-certsearchsizelimit=CERTSEARCHSIZELIMIT\fR
0fcb1e
+LDAP search size limit searching for expired certificates. The default is 1000. This is a client-side limit. There may be additional server-side limitations.
0fcb1e
+.TP
0fcb1e
+\fB\-\-certsearchtimelimit=CERTSEARCHTIMELIMIT\fR
0fcb1e
+LDAP search time limit searching for expired certificates. The default is 0, no limit. This is a client-side limit. There may be additional server-side limitations.
0fcb1e
+.TP
0fcb1e
+\fB\-\-requestretention=REQUESTRETENTION\fR
0fcb1e
+Request retention time. The default is 30.
0fcb1e
+.TP
0fcb1e
+\fB\-\-requestretentionunit=REQUESTRETENTIONUNIT\fR
0fcb1e
+Request retention units. Valid units are: minute, hour, day, year.
0fcb1e
+The default is days.
0fcb1e
+.TP
0fcb1e
+\fB\-\-requestsearchsizelimit=REQUESTSEARCHSIZELIMIT\fR
0fcb1e
+LDAP search size limit searching for unfulfilled requests. The default is 1000. There may be additional server-side limitations.
0fcb1e
+.TP
0fcb1e
+\fB\-\-requestsearchtimelimit=REQUESTSEARCHTIMELIMIT\fR
0fcb1e
+LDAP search time limit searching for unfulfilled requests. The default is 0, no limit. There may be additional server-side limitations.
0fcb1e
+.TP
0fcb1e
+\fB\-\-config\-show\fR
0fcb1e
+Show the current pruning configuration
0fcb1e
+.TP
0fcb1e
+\fB\-\-run\fR
0fcb1e
+Run the pruning job now. The IPA RA certificate is used to authenticate to the PKI REST backend.
0fcb1e
+
0fcb1e
 
0fcb1e
 .SH "EXIT STATUS"
0fcb1e
 0 if the command was successful
0fcb1e
diff --git a/ipaserver/install/ipa_acme_manage.py b/ipaserver/install/ipa_acme_manage.py
0fcb1e
index 0474b9f4a051063ac6df41a81877a2af9d4a2096..b7b2111d9edcec2580aa4a485d7a7340146ff065 100644
0fcb1e
--- a/ipaserver/install/ipa_acme_manage.py
0fcb1e
+++ b/ipaserver/install/ipa_acme_manage.py
0fcb1e
@@ -2,7 +2,12 @@
0fcb1e
 # Copyright (C) 2020  FreeIPA Contributors see COPYING for license
0fcb1e
 #
0fcb1e
 
0fcb1e
+
0fcb1e
 import enum
0fcb1e
+import pki.util
0fcb1e
+import logging
0fcb1e
+
0fcb1e
+from optparse import OptionGroup  # pylint: disable=deprecated-module
0fcb1e
 
0fcb1e
 from ipalib import api, errors, x509
0fcb1e
 from ipalib import _
0fcb1e
@@ -10,10 +15,64 @@ from ipalib.facts import is_ipa_configured
0fcb1e
 from ipaplatform.paths import paths
0fcb1e
 from ipapython.admintool import AdminTool
0fcb1e
 from ipapython import cookie, dogtag
0fcb1e
+from ipapython.ipautil import run
0fcb1e
+from ipapython.certdb import NSSDatabase, EXTERNAL_CA_TRUST_FLAGS
0fcb1e
 from ipaserver.install import cainstance
0fcb1e
+from ipaserver.install.ca import lookup_random_serial_number_version
0fcb1e
 
0fcb1e
 from ipaserver.plugins.dogtag import RestClient
0fcb1e
 
0fcb1e
+logger = logging.getLogger(__name__)
0fcb1e
+
0fcb1e
+default_pruning_options = {
0fcb1e
+    'certRetentionTime': '30',
0fcb1e
+    'certRetentionUnit': 'day',
0fcb1e
+    'certSearchSizeLimit': '1000',
0fcb1e
+    'certSearchTimeLimit': '0',
0fcb1e
+    'requestRetentionTime': 'day',
0fcb1e
+    'requestRetentionUnit': '30',
0fcb1e
+    'requestSearchSizeLimit': '1000',
0fcb1e
+    'requestSearchTimeLimit': '0',
0fcb1e
+    'cron': ''
0fcb1e
+}
0fcb1e
+
0fcb1e
+pruning_labels = {
0fcb1e
+    'certRetentionTime': 'Certificate Retention Time',
0fcb1e
+    'certRetentionUnit': 'Certificate Retention Unit',
0fcb1e
+    'certSearchSizeLimit': 'Certificate Search Size Limit',
0fcb1e
+    'certSearchTimeLimit': 'Certificate Search Time Limit',
0fcb1e
+    'requestRetentionTime': 'Request Retention Time',
0fcb1e
+    'requestRetentionUnit': 'Request Retention Unit',
0fcb1e
+    'requestSearchSizeLimit': 'Request Search Size Limit',
0fcb1e
+    'requestSearchTimeLimit': 'Request Search Time Limit',
0fcb1e
+    'cron': 'cron Schedule'
0fcb1e
+}
0fcb1e
+
0fcb1e
+
0fcb1e
+def validate_range(val, min, max):
0fcb1e
+    """dogtag appears to have no error checking in the cron
0fcb1e
+       entry so do some minimum amount of validation. It is
0fcb1e
+       left as an exercise for the user to do month/day
0fcb1e
+       validation so requesting Feb 31 will be accepted.
0fcb1e
+
0fcb1e
+       Only * and a number within a min/max range are allowed.
0fcb1e
+    """
0fcb1e
+    if val == '*':
0fcb1e
+        return
0fcb1e
+
0fcb1e
+    if '-' in val or '/' in val:
0fcb1e
+        raise ValueError(f"{val} ranges are not supported")
0fcb1e
+
0fcb1e
+    try:
0fcb1e
+        int(val)
0fcb1e
+    except ValueError:
0fcb1e
+        # raise a clearer error
0fcb1e
+        raise ValueError(f"{val} is not a valid integer")
0fcb1e
+
0fcb1e
+    if int(val) < min or int(val) > max:
0fcb1e
+        raise ValueError(f"{val} not within the range {min}-{max}")
0fcb1e
+
0fcb1e
+
0fcb1e
 # Manages the FreeIPA ACME service on a per-server basis.
0fcb1e
 #
0fcb1e
 # This program is a stop-gap until the deployment-wide management of
0fcb1e
@@ -66,32 +125,121 @@ class acme_state(RestClient):
0fcb1e
         status, unused, _unused = self._request('/acme/disable',
0fcb1e
                                                 headers=headers)
0fcb1e
         if status != 200:
0fcb1e
-            raise RuntimeError('Failed to disble ACME')
0fcb1e
+            raise RuntimeError('Failed to disable ACME')
0fcb1e
 
0fcb1e
 
0fcb1e
 class Command(enum.Enum):
0fcb1e
     ENABLE = 'enable'
0fcb1e
     DISABLE = 'disable'
0fcb1e
     STATUS = 'status'
0fcb1e
+    PRUNE = 'pruning'
0fcb1e
 
0fcb1e
 
0fcb1e
 class IPAACMEManage(AdminTool):
0fcb1e
     command_name = "ipa-acme-manage"
0fcb1e
-    usage = "%prog [enable|disable|status]"
0fcb1e
+    usage = "%prog [enable|disable|status|pruning]"
0fcb1e
     description = "Manage the IPA ACME service"
0fcb1e
 
0fcb1e
+    @classmethod
0fcb1e
+    def add_options(cls, parser):
0fcb1e
+
0fcb1e
+        group = OptionGroup(parser, 'Pruning')
0fcb1e
+        group.add_option(
0fcb1e
+            "--enable", dest="enable", action="store_true",
0fcb1e
+            default=False, help="Enable certificate pruning")
0fcb1e
+        group.add_option(
0fcb1e
+            "--disable", dest="disable", action="store_true",
0fcb1e
+            default=False, help="Disable certificate pruning")
0fcb1e
+        group.add_option(
0fcb1e
+            "--cron", dest="cron", action="store",
0fcb1e
+            default=None, help="Configure the pruning cron job")
0fcb1e
+        group.add_option(
0fcb1e
+            "--certretention", dest="certretention", action="store",
0fcb1e
+            default=None, help="Certificate retention time", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--certretentionunit", dest="certretentionunit", action="store",
0fcb1e
+            choices=['minute', 'hour', 'day', 'year'],
0fcb1e
+            default=None, help="Certificate retention units")
0fcb1e
+        group.add_option(
0fcb1e
+            "--certsearchsizelimit", dest="certsearchsizelimit",
0fcb1e
+            action="store",
0fcb1e
+            default=None, help="LDAP search size limit", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--certsearchtimelimit", dest="certsearchtimelimit", action="store",
0fcb1e
+            default=None, help="LDAP search time limit", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--requestretention", dest="requestretention", action="store",
0fcb1e
+            default=None, help="Request retention time", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--requestretentionunit", dest="requestretentionunit",
0fcb1e
+            choices=['minute', 'hour', 'day', 'year'],
0fcb1e
+            action="store", default=None, help="Request retention units")
0fcb1e
+        group.add_option(
0fcb1e
+            "--requestsearchsizelimit", dest="requestsearchsizelimit",
0fcb1e
+            action="store",
0fcb1e
+            default=None, help="LDAP search size limit", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--requestsearchtimelimit", dest="requestsearchtimelimit",
0fcb1e
+            action="store",
0fcb1e
+            default=None, help="LDAP search time limit", type=int)
0fcb1e
+        group.add_option(
0fcb1e
+            "--config-show", dest="config_show", action="store_true",
0fcb1e
+            default=False, help="Show the current pruning configuration")
0fcb1e
+        group.add_option(
0fcb1e
+            "--run", dest="run", action="store_true",
0fcb1e
+            default=False, help="Run the pruning job now")
0fcb1e
+        parser.add_option_group(group)
0fcb1e
+        super(IPAACMEManage, cls).add_options(parser, debug_option=True)
0fcb1e
+
0fcb1e
+
0fcb1e
     def validate_options(self):
0fcb1e
-        # needs root now - if/when this program changes to an API
0fcb1e
-        # wrapper we will no longer need root.
0fcb1e
         super(IPAACMEManage, self).validate_options(needs_root=True)
0fcb1e
 
0fcb1e
         if len(self.args) < 1:
0fcb1e
             self.option_parser.error(f'missing command argument')
0fcb1e
-        else:
0fcb1e
-            try:
0fcb1e
-                self.command = Command(self.args[0])
0fcb1e
-            except ValueError:
0fcb1e
-                self.option_parser.error(f'unknown command "{self.args[0]}"')
0fcb1e
+
0fcb1e
+        if self.args[0] == "pruning":
0fcb1e
+            if self.options.enable and self.options.disable:
0fcb1e
+                self.option_parser.error("Cannot both enable and disable")
0fcb1e
+            elif (
0fcb1e
+                any(
0fcb1e
+                    [
0fcb1e
+                        self.options.enable,
0fcb1e
+                        self.options.disable,
0fcb1e
+                        self.options.cron,
0fcb1e
+                        self.options.certretention,
0fcb1e
+                        self.options.certretentionunit,
0fcb1e
+                        self.options.requestretention,
0fcb1e
+                        self.options.requestretentionunit,
0fcb1e
+                        self.options.certsearchsizelimit,
0fcb1e
+                        self.options.certsearchtimelimit,
0fcb1e
+                        self.options.requestsearchsizelimit,
0fcb1e
+                        self.options.requestsearchtimelimit,
0fcb1e
+                    ]
0fcb1e
+                )
0fcb1e
+                and (self.options.config_show or self.options.run)
0fcb1e
+            ):
0fcb1e
+
0fcb1e
+                self.option_parser.error(
0fcb1e
+                    "Cannot change and show config or run at the same time"
0fcb1e
+                )
0fcb1e
+            elif self.options.cron:
0fcb1e
+                if len(self.options.cron.split()) != 5:
0fcb1e
+                    self.option_parser.error("Invalid format for --cron")
0fcb1e
+                # dogtag does no validation when setting an option so
0fcb1e
+                # do the minimum. The dogtag cron is limited compared to
0fcb1e
+                # crontab(5).
0fcb1e
+                opt = self.options.cron.split()
0fcb1e
+                validate_range(opt[0], 0, 59)
0fcb1e
+                validate_range(opt[1], 0, 23)
0fcb1e
+                validate_range(opt[2], 1, 31)
0fcb1e
+                validate_range(opt[3], 1, 12)
0fcb1e
+                validate_range(opt[4], 0, 6)
0fcb1e
+
0fcb1e
+        try:
0fcb1e
+            self.command = Command(self.args[0])
0fcb1e
+        except ValueError:
0fcb1e
+            self.option_parser.error(f'unknown command "{self.args[0]}"')
0fcb1e
 
0fcb1e
     def check_san_status(self):
0fcb1e
         """
0fcb1e
@@ -100,6 +248,140 @@ class IPAACMEManage(AdminTool):
0fcb1e
         cert = x509.load_certificate_from_file(paths.HTTPD_CERT_FILE)
0fcb1e
         cainstance.check_ipa_ca_san(cert)
0fcb1e
 
0fcb1e
+    def pruning(self):
0fcb1e
+        def run_pki_server(command, directive, prefix, value=None):
0fcb1e
+            """Take a set of arguments to append to pki-server"""
0fcb1e
+            args = [
0fcb1e
+                'pki-server', command,
0fcb1e
+                f'{prefix}.{directive}'
0fcb1e
+            ]
0fcb1e
+            if value:
0fcb1e
+                args.extend([str(value)])
0fcb1e
+            logger.debug(args)
0fcb1e
+            result = run(args, raiseonerr=False, capture_output=True,
0fcb1e
+                         capture_error=True)
0fcb1e
+            if result.returncode != 0:
0fcb1e
+                raise RuntimeError(result.error_output)
0fcb1e
+            return result
0fcb1e
+
0fcb1e
+        def ca_config_set(directive, value,
0fcb1e
+                          prefix='jobsScheduler.job.pruning'):
0fcb1e
+            run_pki_server('ca-config-set', directive, prefix, value)
0fcb1e
+            # ca-config-set always succeeds, even if the option is
0fcb1e
+            # not supported.
0fcb1e
+            newvalue = ca_config_show(directive)
0fcb1e
+            if str(value) != newvalue.strip():
0fcb1e
+                raise RuntimeError('Updating %s failed' % directive)
0fcb1e
+
0fcb1e
+        def ca_config_show(directive):
0fcb1e
+            result = run_pki_server('ca-config-show', directive,
0fcb1e
+                                    prefix='jobsScheduler.job.pruning')
0fcb1e
+            return result.output.strip()
0fcb1e
+
0fcb1e
+        def config_show():
0fcb1e
+            status = ca_config_show('enabled')
0fcb1e
+            if status.strip() == 'true':
0fcb1e
+                print("Status: enabled")
0fcb1e
+            else:
0fcb1e
+                print("Status: disabled")
0fcb1e
+            for option in (
0fcb1e
+                'certRetentionTime', 'certRetentionUnit',
0fcb1e
+                'certSearchSizeLimit', 'certSearchTimeLimit',
0fcb1e
+                'requestRetentionTime', 'requestRetentionUnit',
0fcb1e
+                'requestSearchSizeLimit', 'requestSearchTimeLimit',
0fcb1e
+                'cron',
0fcb1e
+            ):
0fcb1e
+                value = ca_config_show(option)
0fcb1e
+                if value:
0fcb1e
+                    print("{}: {}".format(pruning_labels[option], value))
0fcb1e
+                else:
0fcb1e
+                    print("{}: {}".format(pruning_labels[option],
0fcb1e
+                                          default_pruning_options[option]))
0fcb1e
+
0fcb1e
+        def run_pruning():
0fcb1e
+            """Run the pruning job manually"""
0fcb1e
+
0fcb1e
+            with NSSDatabase() as tmpdb:
0fcb1e
+                print("Preparing...")
0fcb1e
+                tmpdb.create_db()
0fcb1e
+                tmpdb.import_files((paths.RA_AGENT_PEM, paths.RA_AGENT_KEY),
0fcb1e
+                                   import_keys=True)
0fcb1e
+                tmpdb.import_files((paths.IPA_CA_CRT,))
0fcb1e
+                for nickname, trust_flags in tmpdb.list_certs():
0fcb1e
+                    if trust_flags.has_key:
0fcb1e
+                        ra_nickname = nickname
0fcb1e
+                        continue
0fcb1e
+                    # external is suffucient for our purposes: C,,
0fcb1e
+                    tmpdb.trust_root_cert(nickname, EXTERNAL_CA_TRUST_FLAGS)
0fcb1e
+                print("Starting job...")
0fcb1e
+                args = ['pki', '-C', tmpdb.pwd_file, '-d', tmpdb.secdir,
0fcb1e
+                        '-n', ra_nickname,
0fcb1e
+                        'ca-job-start', 'pruning']
0fcb1e
+                logger.debug(args)
0fcb1e
+                run(args, stdin='y')
0fcb1e
+
0fcb1e
+        pki_version = pki.util.Version(pki.specification_version())
0fcb1e
+        if pki_version < pki.util.Version("11.3.0"):
0fcb1e
+            raise RuntimeError(
0fcb1e
+                'Certificate pruning is not supported in PKI version %s'
0fcb1e
+                % pki_version
0fcb1e
+            )
0fcb1e
+
0fcb1e
+        if lookup_random_serial_number_version(api) == 0:
0fcb1e
+            raise RuntimeError(
0fcb1e
+                'Certificate pruning requires random serial numbers'
0fcb1e
+            )
0fcb1e
+
0fcb1e
+        if self.options.config_show:
0fcb1e
+            config_show()
0fcb1e
+            return
0fcb1e
+
0fcb1e
+        if self.options.run:
0fcb1e
+            run_pruning()
0fcb1e
+            return
0fcb1e
+
0fcb1e
+        # Don't play the enable/disable at the same time game
0fcb1e
+        if self.options.enable:
0fcb1e
+            ca_config_set('owner', 'ipara')
0fcb1e
+            ca_config_set('enabled', 'true')
0fcb1e
+            ca_config_set('enabled', 'true', 'jobsScheduler')
0fcb1e
+        elif self.options.disable:
0fcb1e
+            ca_config_set('enabled', 'false')
0fcb1e
+
0fcb1e
+        # pki-server ca-config-set can only set one option at a time so
0fcb1e
+        # loop through all the options and set what is there.
0fcb1e
+        if self.options.certretention:
0fcb1e
+            ca_config_set('certRetentionTime',
0fcb1e
+                          self.options.certretention)
0fcb1e
+        if self.options.certretentionunit:
0fcb1e
+            ca_config_set('certRetentionUnit',
0fcb1e
+                          self.options.certretentionunit)
0fcb1e
+        if self.options.certsearchtimelimit:
0fcb1e
+            ca_config_set('certSearchTimeLimit',
0fcb1e
+                          self.options.certsearchtimelimit)
0fcb1e
+        if self.options.certsearchsizelimit:
0fcb1e
+            ca_config_set('certSearchSizeLimit',
0fcb1e
+                          self.options.certsearchsizelimit)
0fcb1e
+        if self.options.requestretention:
0fcb1e
+            ca_config_set('requestRetentionTime',
0fcb1e
+                          self.options.requestretention)
0fcb1e
+        if self.options.requestretentionunit:
0fcb1e
+            ca_config_set('requestRetentionUnit',
0fcb1e
+                          self.options.requestretentionunit)
0fcb1e
+        if self.options.requestsearchsizelimit:
0fcb1e
+            ca_config_set('requestSearchSizeLimit',
0fcb1e
+                          self.options.requestsearchsizelimit)
0fcb1e
+        if self.options.requestsearchtimelimit:
0fcb1e
+            ca_config_set('requestSearchTimeLimit',
0fcb1e
+                          self.options.requestsearchtimelimit)
0fcb1e
+        if self.options.cron:
0fcb1e
+            ca_config_set('cron', self.options.cron)
0fcb1e
+
0fcb1e
+        config_show()
0fcb1e
+
0fcb1e
+        print("The CA service must be restarted for changes to take effect")
0fcb1e
+
0fcb1e
+
0fcb1e
     def run(self):
0fcb1e
         if not is_ipa_configured():
0fcb1e
             print("IPA is not configured.")
0fcb1e
@@ -123,7 +405,8 @@ class IPAACMEManage(AdminTool):
0fcb1e
             elif self.command == Command.STATUS:
0fcb1e
                 status = "enabled" if dogtag.acme_status() else "disabled"
0fcb1e
                 print("ACME is {}".format(status))
0fcb1e
-                return 0
0fcb1e
+            elif self.command == Command.PRUNE:
0fcb1e
+                self.pruning()
0fcb1e
             else:
0fcb1e
                 raise RuntimeError('programmer error: unhandled enum case')
0fcb1e
 
0fcb1e
diff --git a/ipatests/test_integration/test_acme.py b/ipatests/test_integration/test_acme.py
0fcb1e
index 15d7543cfb0fa0fcb921166f7cd8f13d0535a41d..93e785d8febd9fa8d7b3ef87ecb3f2eb42ac5da2 100644
0fcb1e
--- a/ipatests/test_integration/test_acme.py
0fcb1e
+++ b/ipatests/test_integration/test_acme.py
0fcb1e
@@ -12,6 +12,9 @@ from ipalib.constants import IPA_CA_RECORD
0fcb1e
 from ipatests.test_integration.base import IntegrationTest
0fcb1e
 from ipatests.pytest_ipa.integration import tasks
0fcb1e
 from ipatests.test_integration.test_caless import CALessBase, ipa_certs_cleanup
0fcb1e
+from ipatests.test_integration.test_random_serial_numbers import (
0fcb1e
+    pki_supports_RSNv3
0fcb1e
+)
0fcb1e
 from ipaplatform.osinfo import osinfo
0fcb1e
 from ipaplatform.paths import paths
0fcb1e
 from ipatests.test_integration.test_external_ca import (
0fcb1e
@@ -388,6 +391,16 @@ class TestACME(CALessBase):
0fcb1e
         status = check_acme_status(self.replicas[0], 'disabled')
0fcb1e
         assert status == 'disabled'
0fcb1e
 
0fcb1e
+    def test_acme_pruning_no_random_serial(self):
0fcb1e
+        """This ACME install is configured without random serial
0fcb1e
+           numbers. Verify that we can't enable pruning on it."""
0fcb1e
+        self.master.run_command(['ipa-acme-manage', 'enable'])
0fcb1e
+        result = self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning', '--enable'],
0fcb1e
+            raiseonerr=False)
0fcb1e
+        assert result.returncode == 1
0fcb1e
+        assert "requires random serial numbers" in result.stderr_text
0fcb1e
+
0fcb1e
     @server_install_teardown
0fcb1e
     def test_third_party_certs(self):
0fcb1e
         """Require ipa-ca SAN on replacement web certificates"""
0fcb1e
@@ -630,3 +643,148 @@ class TestACMERenew(IntegrationTest):
0fcb1e
         renewed_expiry = cert.not_valid_after
0fcb1e
 
0fcb1e
         assert initial_expiry != renewed_expiry
0fcb1e
+
0fcb1e
+
0fcb1e
+class TestACMEPrune(IntegrationTest):
0fcb1e
+    """Validate that ipa-acme-manage configures dogtag for pruning"""
0fcb1e
+
0fcb1e
+    random_serial = True
0fcb1e
+
0fcb1e
+    @classmethod
0fcb1e
+    def install(cls, mh):
0fcb1e
+        if not pki_supports_RSNv3(mh.master):
0fcb1e
+            raise pytest.skip("RNSv3 not supported")
0fcb1e
+        tasks.install_master(cls.master, setup_dns=True,
0fcb1e
+                             random_serial=True)
0fcb1e
+
0fcb1e
+    @classmethod
0fcb1e
+    def uninstall(cls, mh):
0fcb1e
+        if not pki_supports_RSNv3(mh.master):
0fcb1e
+            raise pytest.skip("RSNv3 not supported")
0fcb1e
+        super(TestACMEPrune, cls).uninstall(mh)
0fcb1e
+
0fcb1e
+    def test_enable_pruning(self):
0fcb1e
+        if (tasks.get_pki_version(self.master)
0fcb1e
+           < tasks.parse_version('11.3.0')):
0fcb1e
+            raise pytest.skip("Certificate pruning is not available")
0fcb1e
+        cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH)
0fcb1e
+        assert "jobsScheduler.job.pruning.enabled=false".encode() in cs_cfg
0fcb1e
+
0fcb1e
+        self.master.run_command(['ipa-acme-manage', 'pruning', '--enable'])
0fcb1e
+
0fcb1e
+        cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH)
0fcb1e
+        assert "jobsScheduler.enabled=true".encode() in cs_cfg
0fcb1e
+        assert "jobsScheduler.job.pruning.enabled=true".encode() in cs_cfg
0fcb1e
+        assert "jobsScheduler.job.pruning.owner=ipara".encode() in cs_cfg
0fcb1e
+
0fcb1e
+    def test_pruning_options(self):
0fcb1e
+        if (tasks.get_pki_version(self.master)
0fcb1e
+           < tasks.parse_version('11.3.0')):
0fcb1e
+            raise pytest.skip("Certificate pruning is not available")
0fcb1e
+
0fcb1e
+        self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--certretention=60',
0fcb1e
+             '--certretentionunit=minute',
0fcb1e
+             '--certsearchsizelimit=2000',
0fcb1e
+             '--certsearchtimelimit=5',]
0fcb1e
+        )
0fcb1e
+        cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH)
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.certRetentionTime=60".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.certRetentionUnit=minute".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.certSearchSizeLimit=2000".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.certSearchTimeLimit=5".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+
0fcb1e
+        self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--requestretention=60',
0fcb1e
+             '--requestretentionunit=minute',
0fcb1e
+             '--requestresearchsizelimit=2000',
0fcb1e
+             '--requestsearchtimelimit=5',]
0fcb1e
+        )
0fcb1e
+        cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH)
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.requestRetentionTime=60".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.requestRetentionUnit=minute".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.requestSearchSizeLimit=2000".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.requestSearchTimeLimit=5".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+
0fcb1e
+        self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--cron="0 23 1 * *',]
0fcb1e
+        )
0fcb1e
+        cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH)
0fcb1e
+        assert (
0fcb1e
+            "jobsScheduler.job.pruning.cron=0 23 1 * *".encode()
0fcb1e
+            in cs_cfg
0fcb1e
+        )
0fcb1e
+
0fcb1e
+    def test_pruning_negative_options(self):
0fcb1e
+        """Negative option testing for things we directly cover"""
0fcb1e
+        if (tasks.get_pki_version(self.master)
0fcb1e
+           < tasks.parse_version('11.3.0')):
0fcb1e
+            raise pytest.skip("Certificate pruning is not available")
0fcb1e
+
0fcb1e
+        result = self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--enable', '--disable'],
0fcb1e
+            raiseonerr=False
0fcb1e
+        )
0fcb1e
+        assert result.returncode == 1
0fcb1e
+        assert "Cannot both enable and disable" in result.stderr_text
0fcb1e
+
0fcb1e
+        for cmd in ('--config-show', '--run'):
0fcb1e
+            result = self.master.run_command(
0fcb1e
+                ['ipa-acme-manage', 'pruning',
0fcb1e
+                 cmd, '--enable'],
0fcb1e
+                raiseonerr=False
0fcb1e
+            )
0fcb1e
+            assert result.returncode == 1
0fcb1e
+            assert "Cannot change and show config" in result.stderr_text
0fcb1e
+
0fcb1e
+        result = self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--cron="* *"'],
0fcb1e
+            raiseonerr=False
0fcb1e
+        )
0fcb1e
+        assert result.returncode == 1
0fcb1e
+        assert "Invalid format format --cron" in result.stderr_text
0fcb1e
+
0fcb1e
+        result = self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--cron="100 * * * *"'],
0fcb1e
+            raiseonerr=False
0fcb1e
+        )
0fcb1e
+        assert result.returncode == 1
0fcb1e
+        assert "100 not within the range 0-59" in result.stderr_text
0fcb1e
+
0fcb1e
+        result = self.master.run_command(
0fcb1e
+            ['ipa-acme-manage', 'pruning',
0fcb1e
+             '--cron="10 1-5 * * *"'],
0fcb1e
+            raiseonerr=False
0fcb1e
+        )
0fcb1e
+        assert result.returncode == 1
0fcb1e
+        assert "1-5 ranges are not supported" in result.stderr_text
0fcb1e
-- 
0fcb1e
2.39.1
0fcb1e