Repository: privacyidea/privacyidea

Issue:
Add prepolicy for certificate (smartcard) enrollment
The smartcard enrollment client only calls `/token/init` with the CSR.
We should provide a policy probably in scope `enrollment` that sets the API parameter for `/token/init`:

Both can be token tpye specific policies defined in the token class `certificatetoken.py`.

* ca (the CA connector to use)  see https://github.com/privacyidea/privacyidea/blob/fbd203496d30b8186ae2b304790eedfed49df506/privacyidea/lib/tokens/certificatetoken.py#L418

   * scope: enrollment 
   * group: token
   * action: ca=[one of the list of ca connectors]

* template (the template to use) see https://github.com/privacyidea/privacyidea/blob/fbd203496d30b8186ae2b304790eedfed49df506/privacyidea/lib/tokens/certificatetoken.py#L413

   * scope: enrollment 
   * group: token
   * action: template=[one of the list of corresponding templates]

## Thoughts

In regards to #2159 we could later add in a scope `ca` a list of "allowed CAs" and "allowed templates".

But currently we only want to "force" one specific parameter for `ca` and  `template`.



Discussion:

Resolving patch:
diff --git a/privacyidea/api/lib/prepolicy.py b/privacyidea/api/lib/prepolicy.py
--- a/privacyidea/api/lib/prepolicy.py
+++ b/privacyidea/api/lib/prepolicy.py
@@ -85,6 +85,7 @@
 from privacyidea.lib.clientapplication import save_clientapplication
 from privacyidea.lib.config import (get_token_class)
 from privacyidea.lib.tokenclass import ROLLOUTSTATE
+from privacyidea.lib.tokens.certificatetoken import ACTION as CERTIFICATE_ACTION
 import functools
 import jwt
 import re
@@ -604,6 +605,48 @@ def init_tokenlabel(request=None, action=None):
     return True
 
 
+def init_ca_connector(request=None, action=None):
+    """
+    This is a pre decorator for the endpoint '/token/init'.
+    It reads the policy scope=enrollment, action=certificate_ca_connector and
+    sets the API parameter "ca" accordingly.
+
+    :param request: The request to enhance
+    :return: None, but we modify the request
+    """
+    params = request.all_data
+    user_object = get_user_from_param(params)
+    token_type = getParam(request.all_data, "type", optional)
+    if token_type and token_type.lower() == "certificate":
+        # get the CA connectors from the policies
+        ca_pols = Match.user(g, scope=SCOPE.ENROLL, action=CERTIFICATE_ACTION.CA_CONNECTOR,
+                             user_object=user_object).action_values(unique=True)
+        if len(ca_pols) == 1:
+            # The policy was set, so we need to set the CA in the request
+            request.all_data["ca"] = list(ca_pols)[0]
+
+
+def init_ca_template(request=None, action=None):
+    """
+    This is a pre decorator for the endpoint '/token/init'.
+    It reads the policy scope=enrollment, action=certificate_template and
+    sets the API parameter "template" accordingly.
+
+    :param request: The request to enhance
+    :return: None, but we modify the request
+    """
+    params = request.all_data
+    user_object = get_user_from_param(params)
+    token_type = getParam(request.all_data, "type", optional)
+    if token_type and token_type.lower() == "certificate":
+        # get the CA template from the policies
+        template_pols = Match.user(g, scope=SCOPE.ENROLL, action=CERTIFICATE_ACTION.CERTIFICATE_TEMPLATE,
+                                   user_object=user_object).action_values(unique=True)
+        if len(template_pols) == 1:
+            # The policy was set, so we need to set the template in the request
+            request.all_data["template"] = list(template_pols)[0]
+
+
 def twostep_enrollment_activation(request=None, action=None):
     """
     This policy function enables the two-step enrollment process according
diff --git a/privacyidea/api/token.py b/privacyidea/api/token.py
--- a/privacyidea/api/token.py
+++ b/privacyidea/api/token.py
@@ -100,7 +100,7 @@
                                            indexedsecret_force_attribute,
                                            check_admin_tokenlist, webauthntoken_enroll, webauthntoken_allowed,
                                            webauthntoken_request, required_piv_attestation,
-                                           hide_tokeninfo)
+                                           hide_tokeninfo, init_ca_connector, init_ca_template)
 from privacyidea.api.lib.postpolicy import (save_pin_change, check_verify_enrollment,
                                             postpolicy)
 from privacyidea.lib.event import event
@@ -128,6 +128,8 @@
 @prepolicy(check_max_token_user, request)
 @prepolicy(check_token_init, request)
 @prepolicy(init_tokenlabel, request)
+@prepolicy(init_ca_connector, request)
+@prepolicy(init_ca_template, request)
 @prepolicy(enroll_pin, request)
 @prepolicy(twostep_enrollment_activation, request)
 @prepolicy(twostep_enrollment_parameters, request)
diff --git a/privacyidea/lib/tokens/certificatetoken.py b/privacyidea/lib/tokens/certificatetoken.py
--- a/privacyidea/lib/tokens/certificatetoken.py
+++ b/privacyidea/lib/tokens/certificatetoken.py
@@ -39,7 +39,7 @@
 from privacyidea.lib.tokenclass import TokenClass, ROLLOUTSTATE
 from privacyidea.lib.log import log_with
 from privacyidea.api.lib.utils import getParam
-from privacyidea.lib.caconnector import get_caconnector_object
+from privacyidea.lib.caconnector import get_caconnector_object, get_caconnector_list
 from privacyidea.lib.user import get_user_from_param
 from privacyidea.lib.utils import determine_logged_in_userparams
 from OpenSSL import crypto
@@ -67,6 +67,8 @@ class ACTION(BASE_ACTION):
     __doc__ = """This is the list of special certificate actions."""
     TRUSTED_CA_PATH = "certificate_trusted_Attestation_CA_path"
     REQUIRE_ATTESTATION = "certificate_require_attestation"
+    CA_CONNECTOR = "ca_connector"
+    CERTIFICATE_TEMPLATE = "certificate_template"
 
 
 class REQUIRE_ACTIONS(object):
@@ -303,6 +305,17 @@ def get_class_info(key=None, ret='all'):
                            'value': [REQUIRE_ACTIONS.IGNORE,
                                      REQUIRE_ACTIONS.VERIFY,
                                      REQUIRE_ACTIONS.REQUIRE_AND_VERIFY]
+                       },
+                       ACTION.CA_CONNECTOR: {
+                           'type': 'str',
+                           'desc': _("The CA connector that should be used during certificate enrollment."),
+                           'group': GROUP.TOKEN,
+                           'value': [x.get("connectorname") for x in get_caconnector_list()]
+                       },
+                       ACTION.CERTIFICATE_TEMPLATE: {
+                           'type': 'str',
+                           'desc': _("The template that should be used to issue a certificate."),
+                           'group': GROUP.TOKEN
                        }
                    },
                    SCOPE.USER: {


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -49,7 +49,7 @@
                                            webauthntoken_enroll, webauthntoken_request,
                                            webauthntoken_allowed, check_application_tokentype,
                                            required_piv_attestation, check_custom_user_attributes,
-                                           hide_tokeninfo)
+                                           hide_tokeninfo, init_ca_template, init_ca_connector)
 from privacyidea.lib.realm import set_realm as create_realm
 from privacyidea.lib.realm import delete_realm
 from privacyidea.api.lib.postpolicy import (check_serial, check_tokentype,
@@ -72,6 +72,7 @@
 from privacyidea.lib.tokens.pushtoken import PUSH_ACTION
 from privacyidea.lib.tokens.indexedsecrettoken import PIIXACTION
 from privacyidea.lib.tokens.registrationtoken import DEFAULT_LENGTH, DEFAULT_CONTENTS
+from privacyidea.lib.tokens.certificatetoken import ACTION as CERTIFICATE_ACTION
 
 from flask import Request, g, current_app, jsonify
 from werkzeug.test import EnvironBuilder
@@ -106,6 +107,23 @@
          "tu3dokA2YbLa7uWkAIXvX/HHauGLMTyCOpYi1BxN47c/kccxyNg" \
          "jPw== corny@schnuck"
 
+YUBIKEY_CSR = """-----BEGIN CERTIFICATE REQUEST-----
+MIICbTCCAVUCAQAwFzEVMBMGA1UEAwwMY249Y29ybmVsaXVzMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzqQ5MCVz5J3mNzuFWYPvo275g7n3SLAL1egF
+QGHNxVq8ES9bTtBPuNDofwWrrSQLkNQ/YOwVtk6+jtkmq/IvfzXCpqG8UjrZiPuz
++nEguVEO+K8NFr69XbmrMNWsazy87ihKT2/UCRX3mK932zYVLh7uD9DZYK3bucQ/
+xiUy+ePpULjshKDE0pz9ziWAhX46rUynQBsKWBifwl424dFE6Ua23LQoouztAvcl
+eeGGjmlnUu6CIbeELcznqDlXfaMe6VBjdymr5KX/3O5MS14IK2IILXW4JmyT6/VF
+6s2DWFMVRuZrQ8Ev2YhLPdX9DP9RUu1U+yctWe9MUM5xzetamQIDAQABoBEwDwYJ
+KoZIhvcNAQkOMQIwADANBgkqhkiG9w0BAQsFAAOCAQEArLWY74prQRtKojwMEOsw
+4efmzCwOvLoO/WXDwzrr7kgSOawQanhFzD+Z4kCwapf1ZMmobBnyWREpL4EC9PzC
+YH+mgSDCI0jDj/4OSfklb31IzRhuWcCVOpV9xuiDW875WM792t09ILCpx4rayw2a
+8t92zv49IcWHtJNqpo2Q8064p2fzYf1J1r4OEBKUUxEIcw2/nifIiHHTb7DqDF4+
+XjcD3ygUfTVbCzPYBmLPwvt+80AxgT2Nd6E612L/fbI9clv5DsvMwnVeSvlP1wXo
+5BampVY4p5CQRFLlCQa9fGWZrT+ArC9Djo0mHf32x6pEsSz0zMOlmjHrh+ChVkAs
+tA==
+-----END CERTIFICATE REQUEST-----"""
+
 
 class PrePolicyDecoratorTestCase(MyApiTestCase):
 
@@ -3226,6 +3244,54 @@ def test_40_custom_user_attributes(self):
                         "key": "size", "value": "3"}
         check_custom_user_attributes(req, "set")
 
+    def test_50_enroll_ca_connector(self):
+        g.logged_in_user = {"username": "admin1",
+                            "realm": "",
+                            "role": "admin"}
+        builder = EnvironBuilder(method='POST',
+                                 data={'type': "certificate", "genkey": 1},
+                                 headers={})
+        env = builder.get_environ()
+        req = Request(env)
+
+        # first test without any policy
+        req.all_data = {"user": "cornelius", "realm": "home", "type": "certificate", "genkey": 1}
+        init_ca_connector(req)
+        init_ca_template(req)
+        # Check, if that there is no parameter set
+        self.assertNotIn("ca", req.all_data)
+        self.assertNotIn("template", req.all_data)
+
+        # now create a policy for the CA connector and the template
+        set_policy(name="ca",
+                   scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s},{2!s}={3!s}".format(
+                     CERTIFICATE_ACTION.CA_CONNECTOR, "caconnector",
+                     CERTIFICATE_ACTION.CERTIFICATE_TEMPLATE, "catemplate"
+                   ))
+        # request, that matches the policy
+        req.all_data = {"user": "cornelius", "realm": "home", "type": "certificate", "genkey": 1}
+        # check that the parameters were added
+        init_ca_connector(req)
+        init_ca_template(req)
+        # Check, if that there is no parameter set
+        self.assertIn("ca", req.all_data)
+        self.assertIn("template", req.all_data)
+        self.assertEqual("caconnector", req.all_data.get("ca"))
+        self.assertEqual("catemplate", req.all_data.get("template"))
+
+        # request, that matches the policy
+        req.all_data = {"user": "cornelius", "realm": "home", "type": "hotp", "genkey": 1}
+        # check that it only works for certificate tokens
+        init_ca_connector(req)
+        init_ca_template(req)
+        # Check, if that there is no parameter set
+        self.assertNotIn("ca", req.all_data)
+        self.assertNotIn("template", req.all_data)
+
+        # delete policy
+        delete_policy("ca")
+
 
 class PostPolicyDecoratorTestCase(MyApiTestCase):