Repository: privacyidea/privacyidea

Issue:
Add tokentype to policy condition
In many cases we have several policy actions for different tokentypes. This is somehow annoying since it clutters the actions.
At other places we already discussed tokentypes in policies.

The problem is, that in certain cases policies do not refer to actions, that are about tokens. So this would not be applicable.

I think it is too complicated to link policy actions and allowed conditions. So we need to decide and define, how a tokentype condition should be handled, if it is not applicable. There are two possibilities to handle tokentypes at all:

* via extended attributes (while a lot of functionalities in the extended attributes do not make sense)
  On the other hand we could use the same logic like "take care that you define policies that make sense, otherwise an exceptoin gets raised". We could have an extended condition "token".  No database migration needed.
   We might allow all DB columns generically to be checked like "serial", "tokentype", "maxfail", "revoked", "locked" and even "rollout_state"...
* via a new specific condition. However, this very condition would be superfluous in 90% of the policies. We would need a database migration.

We already discussed this in #1947.
This will solve, what we wanted to do in #2142. 
We could also have scope dependent conditions in #2510!

# Condition Matching / Determining tokentype

So what should happen, if a tokentype can not be determined?
In the following cases:

* authentication, calling validate/check. Usually a token and a token type are available, but could be not...
   *Expected behaviour*: 
   a) I think the policy should not match. We *can* define a policy  without a token type condition, so this would be the default policy and the on with the tokentype definition would be the exlcusion, only if the specific tokentype does exist.
   b) OR An exception could be raised.
* defining a policy. Most probably not a tokentype contained, but could we define a policy for a tokentype?
      *Expected behaviour*:
   a) An exception should be raised.
* passthru (no token, no tokentype: would not make sense at all)
      *Expected behaviou*: 
   a) I think the policy should not match. We *can* define a policy without a token type definition, so this would be ok as a fallback. 
   b) An exception could be raised.

**I am thinking using a additional condition "token" provides the highest flexibility and does not break the current design.**


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -437,6 +437,7 @@ class CONDITION_SECTION(object):
     __doc__ = """This is a list of available sections for conditions of policies """
     USERINFO = "userinfo"
     TOKENINFO = "tokeninfo"
+    TOKEN = "token"
     HTTP_REQUEST_HEADER = "HTTP Request header"
 
 
@@ -749,6 +750,8 @@ def filter_policies_by_conditions(self, policies, user_object=None, request_head
         :return: generates a list of policy dictionaries
         """
         reduced_policies = []
+        # If we have several token specific conditions, we only create the db_token (query token DB) once.
+        dbtoken = None
         for policy in policies:
             include_policy = True
             for section, key, comparator, value, active in policy['conditions']:
@@ -760,12 +763,17 @@ def filter_policies_by_conditions(self, policies, user_object=None, request_head
                             include_policy = False
                             break
                     elif section == CONDITION_SECTION.TOKENINFO:
-                        dbtoken = Token.query.filter(Token.serial == serial).first() if serial else None
+                        dbtoken = dbtoken or 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.TOKEN:
+                        dbtoken = dbtoken or Token.query.filter(Token.serial == serial).first() if serial else None
+                        if not self._policy_matches_token_condition(policy, key, comparator, value, dbtoken):
+                            include_policy = False
+                            break
                     elif section == CONDITION_SECTION.HTTP_REQUEST_HEADER:
                         if not self._policy_matches_request_header_condition(policy, key, comparator, value,
                                                                              request_headers):
@@ -804,10 +812,47 @@ def _policy_matches_request_header_condition(policy, key, comparator, value, req
                 raise PolicyError(u"Unknown HTTP header key referenced in condition of policy "
                                   u"{!r}: {!r}".format(policy["name"], key))
         else:  # pragma: no cover
-            log.error(u"Policy {!r} has conditions on headers {!r}, but http header"
-                      u" is not available. This should not happen.".format(policy["name"], key))
-            raise PolicyError(u"Policy {!r} has conditions on headers {!r}, but http header"
-                        u" is not available".format(policy["name"], key))
+            log.error(u"Policy {!r} has conditions on HTTP headers, but HTTP header"
+                      u" is not available. This should not happen - possible "
+                      u"programming error {!s}.".format(policy["name"],
+                                                        ''.join(traceback.format_stack())))
+            raise PolicyError(u"Policy {!r} has conditions on headers {!r}, but HTTP header"
+                              u" is not available".format(policy["name"], key))
+
+    @staticmethod
+    def _policy_matches_token_condition(policy, key, comparator, value, db_token):
+        """
+        This extended policy checks for token attributes, which are existing columns in
+        the token DB table.
+
+        :param policy: a policy dictionary, the policy in question
+        :param key: the column name of the token
+        :param comparator: a value comparator: one of "equal", "contains"
+        :param value: a value against which the token value will be compared
+        :param db_token: a dbtoken object
+        :return: bool
+        """
+        if db_token:
+            if key in db_token.get():
+                try:
+                    return compare_values(db_token.get(key), comparator, value)
+                except Exception as exx:
+                    log.warning(u"Error during handling the condition on token {!r} "
+                                u"of policy {!r}: {!r}".format(key, policy['name'], exx))
+                    raise PolicyError(
+                        u"Invalid comparison in the {!s} conditions of policy {!r}".format(type, policy['name']))
+            else:
+                log.warning(u"Unknown token column referenced in a "
+                            u"condition of policy {!r}: {!r}".format(policy['name'], key))
+                # If we do have token object but the referenced key is not an attribute of the token,
+                # we have a misconfiguration and raise an error.
+                raise PolicyError(u"Unknown key in the token conditions of policy {!r}".format(policy['name']))
+        else:  # pragma: no cover
+            log.error(u"Policy {!r} has conditions on tokens, but a token object"
+                      u" is not available. This should not happen - possible programming "
+                      u"error: {!s}.".format(policy["name"], ''.join(traceback.format_stack())))
+            raise PolicyError(u"Policy {!r} has conditions on tokens, but a token object"
+                              u" is not available".format(policy["name"]))
 
     @staticmethod
     def _policy_matches_info_condition(policy, key, comparator, value, type, user_object=None, dbtoken=None):
@@ -849,9 +894,9 @@ def _policy_matches_info_condition(policy, key, comparator, value, type, user_ob
                     type, policy['name']
                 ))
         else:
-            log.warning(u"Policy {!r} has condition on {!s} {!r}, but the according object"
-                        u" is not available.".format(policy['name'], type, key
-            ))
+            log.error(u"Policy {!r} has condition on {!s}, but the according object"
+                      u" is not available - possible programming error "
+                      u"{!s}.".format(policy['name'], type, ''.join(traceback.format_stack())))
             # 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.
@@ -2366,6 +2411,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.TOKEN: {
+            "description": _("The policy only matches if certain conditions of the token attributes are fulfilled.")
+        },
         CONDITION_SECTION.TOKENINFO: {
             "description": _("The policy only matches if certain conditions on the token info are fulfilled.")
         },
diff --git a/privacyidea/lib/utils/compare.py b/privacyidea/lib/utils/compare.py
--- a/privacyidea/lib/utils/compare.py
+++ b/privacyidea/lib/utils/compare.py
@@ -73,6 +73,20 @@ def _compare_equality(left, comparator, right):
     return left == right
 
 
+def _compare_smaller(left, comparator, right):
+    """
+    Return True if the left value as integer is smaller than the right integer
+    """
+    return int(left or 0) < int(right)
+
+
+def _compare_bigger(left, comparator, right):
+    """
+    Return True if the left value as integer is bigger than the right integer
+    """
+    return int(left or 0) > int(right)
+
+
 def _compare_contains(left, comparator, right):
     """
     Return True if ``left`` has ``right`` as an element.
@@ -148,6 +162,9 @@ class COMPARATORS(object):
     IN = "in"
     NOT_IN = "!in"
 
+    SMALLER = "<"
+    BIGGER = ">"
+
 
 #: This dictionary connects comparators to comparator functions.
 #: A comparison function takes three parameters ``left``, ``comparator``, ``right``.
@@ -163,6 +180,9 @@ class COMPARATORS(object):
 
     COMPARATORS.IN: _compare_in,
     COMPARATORS.NOT_IN: negate(_compare_in),
+
+    COMPARATORS.SMALLER: _compare_smaller,
+    COMPARATORS.BIGGER: _compare_bigger
 }
 
 
@@ -178,7 +198,10 @@ class COMPARATORS(object):
     COMPARATORS.NOT_MATCHES: _("false if the value of the left attribute completely matches the given regular expression pattern on the right"),
 
     COMPARATORS.IN: _("true if the value of the left attribute is contained in the comma-separated values on the right"),
-    COMPARATORS.NOT_IN: _("false if the value of the left attribute is contained in the comma-separated values on the right")
+    COMPARATORS.NOT_IN: _("false if the value of the left attribute is contained in the comma-separated values on the right"),
+
+    COMPARATORS.SMALLER: _("true if the integer value of the left attribute is smaller than the right integer value"),
+    COMPARATORS.BIGGER: _("true if the integer value of the left attribute is bigger than the right integer value")
 }
 
 


Test patch:
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
@@ -1390,6 +1390,69 @@ class MockUser(object):
         delete_policy("setpin_pol")
         db_token.delete()
 
+    def test_32_filter_by_conditions_token(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()
+
+        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=[("token", "tokentype", "equals", "hotp", 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=[("token", "tokentype", "equals", "hotp", True)])
+
+        # policy does not match anymore, because the condition on tokeninfo is active
+        # setpin action not returned for our token with tokentype == spass
+        self.assertEqual(_names(P.match_policies(user_object=user1, serial=serial)),
+                         set())
+
+        # Now set a policy condition with a non-case matching token type!
+        set_policy("setpin_pol", conditions=[("token", "tokentype", "equals", "Spass", True)])
+        self.assertEqual(_names(P.match_policies(user_object=user1, serial=serial)),
+                         set())
+
+        # Now check, if we can compare numbers.
+        set_policy("setpin_pol", conditions=[("token", "count", "<", "100", True)])
+        self.assertEqual(_names(P.match_policies(user_object=user1, serial=serial)),
+                         {"setpin_pol"})
+        # The the counter of the token is >=100, the policy will not match anymore
+        db_token.count = 102
+        db_token.save()
+        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)
+
+        # Now check, if a wrong comparison raises an exception
+        set_policy("setpin_pol", conditions=[("token", "count", "<", "not a number", True)])
+        self.assertRaises(PolicyError, P.match_policies, user_object=user1, serial=serial)
+
+        delete_policy("setpin_pol")
+        db_token.delete()
+
+
 class PolicyMatchTestCase(MyTestCase):
     @classmethod
     def setUpClass(cls):
diff --git a/tests/test_lib_utils_compare.py b/tests/test_lib_utils_compare.py
--- a/tests/test_lib_utils_compare.py
+++ b/tests/test_lib_utils_compare.py
@@ -97,3 +97,10 @@ def test_06_compare_in(self):
         self.assertTrue(compare_values("hello", "!in", "world"))
         self.assertFalse(compare_values("hello", "!in", " hello, world"))
         self.assertTrue(compare_values("hello", "!in", "hello world"))
+
+    def test_07_smaller_and_bigger(self):
+        self.assertTrue(compare_values("1", "<", 2))
+        self.assertTrue(compare_values(7, ">", 1))
+        self.assertFalse(compare_values("2", "<", "1"))
+        self.assertFalse(compare_values(2, "<", 1))
+        self.assertFalse(compare_values(2, ">", "2"))