Repository: privacyidea/privacyidea

Issue:
Add tokeninfo or tokentype to policy condition
For certain policies it might make sense to allow the tokentype to be taken into accout like:

* only require a PIN for a certain token type
* ...

But there are a lot of other policies, where it would not make sense or where it would result in wrong expectations

* A ``userlist`` admin-policy with a condition "tokentype" - what do you think would this mean? Only allow the admin to list users, who have a token of this very tokentype? - probably not.

So the question is, if it makes sense at all to add further conditions to the policies or to split the actions, where it seems to be necessary like:

  * allow max tokens for different tokentypes
  * otppin for certain tokentypes...

In some scenarios we already have this distinction like totp_hash and hotp_hash.

This is why tokenclasses were allowed to add policy-actions in the first place.


Discussion:
With the new policy ``Match`` API adding tokeninfo seems quite straight forward compared to the userinfo and the http_header.
Adding this to the milestone. :-)
## Todo

We need to do several changes to ``lib/policy.py``.

Add new section in:

~~~~python
class CONDITION_SECTION(object):
    __doc__ = """This is a list of available sections for conditions of policies """
    USERINFO = "userinfo"
    HTTP_REQUEST_HEADER = "HTTP Request header"
    TOKENINFO = "tokeninfo"
~~~~

Then we need to add a ``PolicyClass._policy_matches_tokeninfo_condition`` here.
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/policy.py#L712

* [ ] add tokeninfo
* [ ] add tokentype

## The challenge

We have to get the request or the serial number or the token_object in here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/policy.py#L620

In the *Match*-class we have the ``g`` object (https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/policy.py#L2245) so we could also access a serial number.

We could implement this in the same way as we added the ``userinfo`` in the additional conditions.
We also do not always have a user in a request. And we also do not always have a token in the request. So imho implementing ``tokeninfo``  additional condition should be straighforward when applyhing the same logic and code.

We should consider skipping the *tokentype*. If the tokentype indeed is need for some conditions, we could still write the tokentype into a tokeninfo field.

The implementation should be clear and the documentation should be clear, that this condition will only apply, if a token is actually given in the request or if the request is actually ment to act on a token. I.e. when listing users, deleting users, deleting policies, the logic can not check, if this policy only applies to tokens with a certain tokeninfo. We need to avoid getting into a mess ;-)
The challenge mentioned above is indeed the key point. Since it varies from request to request if we have a token serial or anything similar we can work with. If for instance token/setpin ist requested with authorization and serial, we do not pass the serial down to the matching class.

So to make this work, we have to check every policy matching and pass it to the Match functions.

> So to make this work, we have to check every policy matching and pass it to the Match functions.

I do not understand this.
We should first do #2534
#2534 is merged. So now we can add the ``TOKENINFO`` to the ``CONDITION_SECTION`` as mentioned above and enhance
the [filter_policy_by_condition](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/policy.py#L737).

We can create a new ``self._policy_matches_tokeninfo_condition``. 
*But!* We still have to pass the g-object or the serial number (or preferrably the token_object) into the ``PolicyClass``. This is subject to discussion.
Since the serial is only required to filter policies by condition, I would introduce it not in list_policies but only in match_policies.

The argument serial must be fetched from the g object in the Match class (Match.generic).

Since tokeninfo and userinfo work very similar, I suggest to create a filter function self._policy_matches_info_condition which gets either a dbtoken object or a user object to filter.

A full-fledged token object does not work in lib/policy due to circular imports.

By the way: from there, the implementation of token properties as conditions is a small step.

The WebUI fetches the rights to enable/disable buttons without any tokeninfo.

Resolving patch:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -169,7 +169,7 @@
 from operator import itemgetter
 import six
 import logging
-from ..models import (Policy, db, save_config_timestamp)
+from ..models import (Policy, db, save_config_timestamp, Token)
 from privacyidea.lib.config import (get_token_classes, get_token_types,
                                     get_config_object, get_privacyidea_node)
 from privacyidea.lib.framework import get_app_config_value
@@ -435,6 +435,7 @@ class TIMEOUT_ACTION(object):
 class CONDITION_SECTION(object):
     __doc__ = """This is a list of available sections for conditions of policies """
     USERINFO = "userinfo"
+    TOKENINFO = "tokeninfo"
     HTTP_REQUEST_HEADER = "HTTP Request header"
 
 
@@ -656,7 +657,7 @@ def list_policies(self, name=None, scope=None, realm=None, active=None,
     def match_policies(self, name=None, scope=None, realm=None, active=None,
                        resolver=None, user=None, user_object=None, pinode=None,
                        client=None, action=None, adminrealm=None, adminuser=None, time=None,
-                       sort_by_priority=True, audit_data=None, request_headers=None):
+                       sort_by_priority=True, audit_data=None, request_headers=None, serial=None):
         """
         Return all policies matching the given context.
         Optionally, write the matching policies to the audit log.
@@ -725,7 +726,8 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,
         log.debug("Policies after matching time: {0!s}".format([p.get("name") for p in reduced_policies]))
 
         # filter policies by the policy conditions
-        reduced_policies = self.filter_policies_by_conditions(reduced_policies, user_object, request_headers)
+        reduced_policies = self.filter_policies_by_conditions(reduced_policies, user_object, request_headers,
+                                                              serial)
         log.debug("Policies after matching conditions".format([p.get("name") for p in reduced_policies]))
 
         if audit_data is not None:
@@ -734,7 +736,7 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,
 
         return reduced_policies
 
-    def filter_policies_by_conditions(self, policies, user_object=None, request_headers=None):
+    def filter_policies_by_conditions(self, policies, user_object=None, request_headers=None, serial=None):
         """
         Given a list of policy dictionaries and a current user object (if any),
         return a list of all policies whose conditions match the given user object.
@@ -751,7 +753,16 @@ def filter_policies_by_conditions(self, policies, user_object=None, request_head
             for section, key, comparator, value, active in policy['conditions']:
                 if active:
                     if section == CONDITION_SECTION.USERINFO:
-                        if not self._policy_matches_userinfo_condition(policy, key, comparator, value, user_object):
+                        if not self._policy_matches_info_condition(policy, key, comparator, value,
+                                                                   CONDITION_SECTION.USERINFO,
+                                                                   user_object=user_object):
+                            include_policy = False
+                            break
+                    elif section == CONDITION_SECTION.TOKENINFO:
+                        dbtoken = Token.query.filter(Token.serial == serial).first() if serial else None
+                        if not self._policy_matches_info_condition(policy, key, comparator, value,
+                                                                   CONDITION_SECTION.TOKENINFO,
+                                                                   dbtoken=dbtoken):
                             include_policy = False
                             break
                     elif section == CONDITION_SECTION.HTTP_REQUEST_HEADER:
@@ -798,48 +809,54 @@ def _policy_matches_request_header_condition(policy, key, comparator, value, req
                         u" is not available".format(policy["name"], key))
 
     @staticmethod
-    def _policy_matches_userinfo_condition(policy, key, comparator, value, user_object):
+    def _policy_matches_info_condition(policy, key, comparator, value, type, user_object=None, dbtoken=None):
         """
-        Check if the given policy matches a certain userinfo condition.
-        If ``user_object`` is None, a PolicyError is raised.
+        Check if the given policy matches a certain userinfo or tokeninfo condition depending
+        on the specified ``type``.
+        For the userinfo, if ``user_object`` is None or the requested ``key`` is not contained,
+        a PolicyError is raised.
+        In case of a tokeninfo, no exception is raised if ``dbtoken`` is malformed. Instead, the
+        condition is effectively set to True and the policy may apply.
         :param policy: a policy dictionary, the policy in question
-        :param key: a userinfo key
+        :param key: a tokeninfo or userinfo key
         :param comparator: a value comparator: one of "equal", "contains"
-        :param value: a value against which the userinfo value will be compared
+        :param value: a value against which the tokeninfo or userinfo value will be compared
+        :param type: the info type to match, "userinfo" or "tokeninfo"
         :param user_object: a User object, if any, or None
+        :param dbtoken: a dbtoken object, if any, or None
         :return: a Boolean
         """
-        # Match the user object's user info, if it is not-None and non-empty
-        if user_object is not None:
-            info = user_object.info
+        if user_object is not None or dbtoken is not None:
+            info = user_object.info if user_object is not None else dbtoken.get_info()
+
             if key in info:
                 try:
                     return compare_values(info[key], comparator, value)
                 except Exception as exx:
-                    log.warning(u"Error during handling the condition on userinfo {!r} of policy {!r}: {!r}".format(
-                        key, policy['name'], exx
+                    log.warning(u"Error during handling the condition on {!s} {!r} of policy {!r}: {!r}".format(
+                        type, key, policy['name'], exx
                     ))
                     raise PolicyError(
-                        u"Invalid comparison in the userinfo conditions of policy {!r}".format(policy['name']))
+                        u"Invalid comparison in the {!s} conditions of policy {!r}".format(type, policy['name']))
             else:
-                # If we do have a user object, but the conditions of policies reference
-                # an unknown userinfo key, we have a misconfiguration and raise an error.
-                log.warning(u"Unknown userinfo key referenced in a condition of policy {!r}: {!r}".format(
-                    policy['name'], key
+                log.warning(u"Unknown {!s} key referenced in a condition of policy {!r}: {!r}".format(
+                    type, policy['name'], key
                 ))
-                raise PolicyError(u"Unknown key in the userinfo conditions of policy {!r}".format(
-                    policy['name']
+                # If we do have an user or token object, but the conditions of policies reference
+                # an unknown userinfo or tokeninfo key, we have a misconfiguration and raise an error.
+                raise PolicyError(u"Unknown key in the {!s} conditions of policy {!r}".format(
+                    type, policy['name']
                 ))
         else:
-            log.warning(u"Policy {!r} has condition on userinfo {!r}, but a user_object is not available".format(
-                policy['name'], key
+            log.warning(u"Policy {!r} has condition on {!s} {!r}, but the according object"
+                        u" is not available.".format(policy['name'], type, key
             ))
-            # If the policy specifies a userinfo condition, but no user object is available,
+            # If the policy specifies a userinfo or tokeninfo condition, but no object is available,
             # the policy is misconfigured. We have to raise a PolicyError to ensure that
             # the privacyIDEA server does not silently misbehave.
             raise PolicyError(
-                u"Policy {!r} has condition on userinfo, but a user_object is not available".format(
-                    policy['name']
+                u"Policy {!r} has condition on {!s}, but an according object is not available".format(
+                    policy['name'], type
                 ))
 
     @staticmethod
@@ -2343,6 +2360,9 @@ def get_policy_condition_sections():
         CONDITION_SECTION.USERINFO: {
             "description": _("The policy only matches if certain conditions on the user info are fulfilled.")
         },
+        CONDITION_SECTION.TOKENINFO: {
+            "description": _("The policy only matches if certain conditions on the token info are fulfilled.")
+        },
         CONDITION_SECTION.HTTP_REQUEST_HEADER: {
             "description": _("The policy only matches if certain conditions on the HTTP Request header are fulfilled.")
         }
@@ -2519,7 +2539,7 @@ def realm(cls, g, scope, action, realm):
         return cls(g, name=None, scope=scope, realm=realm, active=True,
                    resolver=None, user=None, user_object=None,
                    client=g.client_ip, action=action, adminrealm=None, time=None,
-                   sort_by_priority=True)
+                   sort_by_priority=True, serial=g.serial)
 
     @classmethod
     def user(cls, g, scope, action, user_object):
@@ -2545,7 +2565,7 @@ def user(cls, g, scope, action, user_object):
         return cls(g, name=None, scope=scope, realm=None, active=True,
                    resolver=None, user=None, user_object=user_object,
                    client=g.client_ip, action=action, adminrealm=None, time=None,
-                   sort_by_priority=True)
+                   sort_by_priority=True, serial=g.serial)
 
     @classmethod
     def token(cls, g, scope, action, token_obj):
@@ -2600,7 +2620,7 @@ def admin(cls, g, action, user_obj=None):
         return cls(g, name=None, scope=SCOPE.ADMIN, user_object=user_obj, active=True,
                    resolver=None, client=g.client_ip, action=action,
                    adminuser=adminuser, adminrealm=adminrealm, time=None,
-                   sort_by_priority=True)
+                   sort_by_priority=True, serial=g.serial)
 
     @classmethod
     def admin_or_user(cls, g, action, user_obj):
@@ -2633,12 +2653,12 @@ def admin_or_user(cls, g, action, user_obj):
         return cls(g, name=None, scope=scope, realm=userrealm, active=True,
                    resolver=None, user=username, user_object=user_obj,
                    client=g.client_ip, action=action, adminrealm=adminrealm, adminuser=adminuser,
-                   time=None, sort_by_priority=True)
+                   time=None, sort_by_priority=True, serial=g.serial)
 
     @classmethod
     def generic(cls, g, scope=None, realm=None, resolver=None, user=None, user_object=None,
                 client=None, action=None, adminrealm=None, adminuser=None, time=None,
-                active=True, sort_by_priority=True):
+                active=True, sort_by_priority=True, serial=None):
         """
         Low-level legacy policy matching interface: Search for active policies and return
         them sorted by priority. All parameters that should be used for matching have to
@@ -2650,10 +2670,12 @@ def generic(cls, g, scope=None, realm=None, resolver=None, user=None, user_objec
         """
         if client is None:
             client = g.client_ip if hasattr(g, "client_ip") else None
+        if serial is None:
+            serial = g.serial if hasattr(g, "serial") else None
         return cls(g, name=None, scope=scope, realm=realm, active=active,
                    resolver=resolver, user=user, user_object=user_object,
                    client=client, action=action, adminrealm=adminrealm,
-                   adminuser=adminuser, time=time,
+                   adminuser=adminuser, time=time, serial=serial,
                    sort_by_priority=sort_by_priority)
 
 


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -2866,6 +2866,84 @@ def test_35_application_tokentype(self):
         delete_policy("pol_chalresp")
         delete_policy("pol_application_tokentype")
 
+    def test_36_authorize_by_tokeninfo_condition(self):
+
+        init_token({"type": "spass", "serial": "softwareToken", "pin": "software1"},
+                   tokenkind="software", user=User("cornelius", self.realm1))
+        init_token({"type": "spass", "serial": "hardwareToken", "pin": "hardware1"},
+                   tokenkind="hardware", user=User("cornelius", self.realm1))
+        set_policy(name="always_deny_access", action="{0!s}=deny_access".format(ACTION.AUTHORIZED),
+                   scope=SCOPE.AUTHZ, priority=100)
+        # policy to allow tokens, condition is deactivated. All tokens will be authorized
+        set_policy(name="allow_hardware_tokens", action="{0!s}=grant_access".format(ACTION.AUTHORIZED),
+                   scope=SCOPE.AUTHZ, priority=1,
+                   conditions=[("tokeninfo", "tokenkind", "equals", "hardware", False)])
+
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "software1"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "hardware1"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        # activate condition, only hardware tokens will be authorized
+        set_policy(name="allow_hardware_tokens",
+                   conditions=[("tokeninfo", "tokenkind", "equals", "hardware", True)])
+
+        # token with tokenkind = software is not authorized
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "software1"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 400, res)
+            result = res.json.get("result")
+            # User is not authorized under these conditions (tokenkind = software)
+            self.assertEqual(result.get("error").get("code"), 401)
+            self.assertFalse(result.get("status"))
+
+        # token with tokenkind = hardware is authorized
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "hardware1"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        # wrong password raises exception since the tokeninfo policy cannot be checked
+        # because there is not token serial in the result
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "wrongpassword"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 403, res)
+            result = res.json.get("result")
+            # Policy has tokeninfo, but no token object available
+            self.assertEqual(result.get("error").get("code"), 303)
+            self.assertFalse(result.get("status"))
+
+        delete_policy("always_deny_access")
+        delete_policy("allow_hardware_tokens")
+        remove_token("softwareToken")
+        remove_token("hardwareToken")
+
 
 class RegistrationValidity(MyApiTestCase):
 
diff --git a/tests/test_lib_policy.py b/tests/test_lib_policy.py
--- a/tests/test_lib_policy.py
+++ b/tests/test_lib_policy.py
@@ -1198,7 +1198,7 @@ class MockUser(object):
         user3.info = {"type": "notverysecure", "groups": ["b", "c"]}
 
         # no user => policy error
-        with self.assertRaisesRegexp(PolicyError, ".* a user_object is not available.*"):
+        with self.assertRaisesRegexp(PolicyError, ".* an according object is not available.*"):
             P.match_policies(user_object=None)
 
         # empty user => policy error
@@ -1345,6 +1345,50 @@ def test_31_match_pinode(self):
         delete_policy("delete_node2")
         delete_policy("enable")
 
+    def test_31_filter_by_conditions_tokeninfo(self):
+        def _names(policies):
+            return set(p['name'] for p in policies)
+
+        from privacyidea.lib.tokenclass import TokenClass
+        from privacyidea.models import Token
+        serial = "filter_by_conditions_token"
+        db_token = Token(serial, tokentype="spass")
+        db_token.save()
+        token = TokenClass(db_token)
+        token.set_tokeninfo({"fixedpin": "true", "otherinfo": "true"})
+
+        P = PolicyClass()
+
+        class MockUser(object):
+            login = 'login'
+            realm = 'realm'
+            resolver = 'resolver'
+
+        user1 = MockUser()
+        user1.info = {"email": "foo@bar.com"}
+
+        # Policy with initially inactive condition, setpin is allowed for this token
+        set_policy("setpin_pol", scope=SCOPE.USER, action=ACTION.SETPIN,
+                   conditions=[("tokeninfo", "fixedpin", "equals", "false", False)])
+
+        # policy matches, because the condition on tokeninfo is inactive
+        self.assertEqual(_names(P.match_policies(user_object=user1, serial=serial)),
+                         {"setpin_pol"})
+
+        # activate the condition
+        set_policy("setpin_pol", conditions=[("tokeninfo", "fixedpin", "equals", "false", True)])
+
+        # policy does not match anymore, because the condition on tokeninfo is active
+        # setpin action not returned for our token with tokeninfo "fixedpin" = "true"
+        self.assertEqual(_names(P.match_policies(user_object=user1, serial=serial)),
+                         set())
+
+        # A request without any serial number will raise a Policy error, since condition
+        # on tokeninfo is there, but no dbtoken object is available.
+        self.assertRaises(PolicyError, P.match_policies, user_object=user1)
+
+        delete_policy("setpin_pol")
+        db_token.delete()
 
 class PolicyMatchTestCase(MyTestCase):
     @classmethod
diff --git a/tests/test_lib_policydecorator.py b/tests/test_lib_policydecorator.py
--- a/tests/test_lib_policydecorator.py
+++ b/tests/test_lib_policydecorator.py
@@ -801,6 +801,7 @@ def test_15_reset_all_failcounters(self):
         g.policy_object = PolicyClass()
         g.audit_object = FakeAudit()
         g.client_ip = None
+        g.serial = None
         options = {"g": g}
 
         r = reset_all_user_tokens(self.fake_check_token_list,
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -1737,6 +1737,7 @@ def test_04_reset_all_failcounters(self):
         g.policy_object = PolicyClass()
         g.audit_object = FakeAudit()
         g.client_ip = None
+        g.serial = None
         options = {"g": g}
 
         check_token_list([token1, token2], pin1, user=user,