Repository: privacyidea/privacyidea

Issue:
Allow case insensitive usersnames in Policies
**Is your feature request related to a problem? Please describe.**
When configuring policy application to a user, the username must exactly match the case of the user store.

**Describe the solution you'd like**
Either remove case sensitivity or expose a setting to remove case sensitivity from a policy.

**Additional context**
Windows systems are not case sensitive and in moderately sized organizations, ensuring uniform creation of user account names can be difficult.



Discussion:
Thank you for filing an issue and sharing your observations or ideas. Please be sure to provide as many information as possible to help us working on this issue.
@wwalker0307 so case insensitivity only refers to usernames?
What about simply adding a checkbox, or even allow regexp in usernames?
We think this should be done in the policy checking, since this can be independent from user resolving.

So a checkbox in the policy if it should be independent or not.
Hm, this will be somewhere here in the Matching object:
https://github.com/privacyidea/privacyidea/blob/741e79052e44fcc89759e8dc43337f6579bcd61c/privacyidea/lib/policy.py#L596




Patch:
diff --git a/doc/policies/index.rst b/doc/policies/index.rst
index 7e663e0809..dabdacc572 100644
--- a/doc/policies/index.rst
+++ b/doc/policies/index.rst
@@ -78,6 +78,9 @@ Each policy can contain the following attributes:
 
   If this field is left blank, this policy is valid for all users.
 
+  .. note:: Starting with version 3.10 you can chooses if the username and the
+    adminname has to match case-sensitive or not.
+
 .. _check_all_resolvers:
 
 **resolver**
diff --git a/migrations/versions/db6b2ef8100f_.py b/migrations/versions/db6b2ef8100f_.py
new file mode 100644
index 0000000000..6293d3bc0f
--- /dev/null
+++ b/migrations/versions/db6b2ef8100f_.py
@@ -0,0 +1,35 @@
+"""v3.10: Add case-insensitive usernames in policies
+
+Revision ID: db6b2ef8100f
+Revises: d0e7144947d0
+Create Date: 2024-01-29 14:12:38.070014
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'db6b2ef8100f'
+down_revision = 'e3a64b4ca634'
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.exc import OperationalError, ProgrammingError
+
+def upgrade():
+    try:
+        # ### commands auto generated by Alembic - please adjust! ###
+        op.add_column('policy', sa.Column('user_case_insensitive', sa.Boolean(), nullable=True))
+        # ### end Alembic commands ###
+    except (OperationalError, ProgrammingError) as exx:
+        if "already exists" in str(exx.orig).lower():
+            print("Ok, column 'user_case_insensitive' already exists.")
+        else:
+            print(exx)
+    except Exception as exx:
+        print("Could not add column 'user_case_insensitive' to database")
+        print(exx)
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('policy', 'user_case_insensitive')
+    # ### end Alembic commands ###
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
index a10949ef66..b7b161d77c 100644
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -593,11 +593,12 @@ def list_policies(self, name=None, scope=None, realm=None, active=None,
                 log.debug("Policies after matching {1!s}={2!s}: {0!s}".format(
                     reduced_policies, searchkey, searchvalue))
 
-        p = [("action", action), ("user", user), ("realm", realm)]
+        p = [("action", action), ("realm", realm)]
+        q = [("user", user)]
         # If this is an admin-policy, we also do check the adminrealm
         if scope == SCOPE.ADMIN:
             p.append(("adminrealm", adminrealm))
-            p.append(("adminuser", adminuser))
+            q.append(("adminuser", adminuser))
         for searchkey, searchvalue in p:
             if searchvalue is not None:
                 new_policies = []
@@ -618,6 +619,30 @@ def list_policies(self, name=None, scope=None, realm=None, active=None,
                 log.debug("Policies after matching {1!s}={2!s}: {0!s}".format(
                     reduced_policies, searchkey, searchvalue))
 
+        for searchkey, searchvalue in q:
+            if searchvalue is not None:
+                new_policies = []
+                # first we find policies, that really match!
+                # Either with the real value or with a "*"
+                # values can be excluded by a leading "!" or "-"
+                for policy in reduced_policies:
+                    if not policy.get(searchkey):
+                        # We also find the policies with no distinct information
+                        # about the request value
+                        new_policies.append(policy)
+                    else:
+                        searchkeys = policy.get(searchkey)
+                        current_searchvalue = searchvalue
+                        if policy.get("user_case_insensitive"):
+                            current_searchvalue = current_searchvalue.lower()
+                            searchkeys = [x.lower() for x in searchkeys]
+                        value_found, value_excluded = self._search_value(searchkeys, current_searchvalue)
+                        if value_found and not value_excluded:
+                            new_policies.append(policy)
+                reduced_policies = new_policies
+                log.debug("Policies after matching {1!s}={2!s}: {0!s}".format(
+                    reduced_policies, searchkey, searchvalue))
+
         # We need to act individually on the resolver key word
         # We either match the resolver exactly or we match another resolver (
         # which is not the first resolver) of the user, but only if the
@@ -1298,7 +1323,7 @@ def ui_get_enroll_tokentypes(self, client, logged_in_user):
 def set_policy(name=None, scope=None, action=None, realm=None, resolver=None,
                user=None, time=None, client=None, active=True,
                adminrealm=None, adminuser=None, priority=None, check_all_resolvers=False,
-               conditions=None, pinode=None):
+               conditions=None, pinode=None, user_case_insensitive=False):
     """
     Function to set a policy.
 
@@ -1325,6 +1350,8 @@ def set_policy(name=None, scope=None, action=None, realm=None, resolver=None,
     :param check_all_resolvers: If all the resolvers of a user should be
         checked with this policy
     :type check_all_resolvers: bool
+    :param user_case_insensitive: The username should be case-insensitive.
+    :type user_case_insensitive: bool
     :param conditions: A list of 5-tuples (section, key, comparator, value, active) of policy conditions
     :param pinode: A privacyIDEA node or a list of privacyIDEA nodes.
     :return: The database ID of the policy
@@ -1336,6 +1363,7 @@ def set_policy(name=None, scope=None, action=None, realm=None, resolver=None,
     if priority is not None and priority <= 0:
         raise ParameterError("Priority must be at least 1")
     check_all_resolvers = is_true(check_all_resolvers)
+    user_case_insensitive = is_true(user_case_insensitive)
     if type(action) == dict:
         action_list = []
         for k, v in action.items():
@@ -1406,6 +1434,7 @@ def set_policy(name=None, scope=None, action=None, realm=None, resolver=None,
             p1.pinode = pinode
         p1.active = active
         p1.check_all_resolvers = check_all_resolvers
+        p1.user_case_insensitive = user_case_insensitive
         if conditions is not None:
             p1.set_conditions(conditions)
         save_config_timestamp()
@@ -1418,7 +1447,7 @@ def set_policy(name=None, scope=None, action=None, realm=None, resolver=None,
                      resolver=resolver, adminrealm=adminrealm,
                      adminuser=adminuser, priority=priority,
                      check_all_resolvers=check_all_resolvers,
-                     conditions=conditions, pinode=pinode).save()
+                     conditions=conditions, pinode=pinode, user_case_insensitive=user_case_insensitive).save()
     return ret
 
 
diff --git a/privacyidea/models.py b/privacyidea/models.py
index c72dfbb27e..81585b4463 100644
--- a/privacyidea/models.py
+++ b/privacyidea/models.py
@@ -1515,6 +1515,7 @@ class Policy(TimestampMethodsMixin, db.Model):
     active = db.Column(db.Boolean, default=True)
     check_all_resolvers = db.Column(db.Boolean, default=False)
     name = db.Column(db.Unicode(64), unique=True, nullable=False)
+    user_case_insensitive = db.Column(db.Boolean, default=False)
     scope = db.Column(db.Unicode(32), nullable=False)
     action = db.Column(db.Unicode(2000), default="")
     realm = db.Column(db.Unicode(256), default="")
@@ -1541,10 +1542,11 @@ class Policy(TimestampMethodsMixin, db.Model):
     def __init__(self, name,
                  active=True, scope="", action="", realm="", adminrealm="", adminuser="",
                  resolver="", user="", client="", time="", pinode="", priority=1,
-                 check_all_resolvers=False, conditions=None):
+                 check_all_resolvers=False, conditions=None, user_case_insensitive=False):
         if isinstance(active, str):
             active = is_true(active.lower())
         self.name = name
+        self.user_case_insensitive = user_case_insensitive
         self.action = action
         self.scope = scope
         self.active = active
@@ -1606,6 +1608,7 @@ def get(self, key=None):
         :rytpe: dict or value
         """
         d = {"name": self.name,
+             "user_case_insensitive": self.user_case_insensitive,
              "active": self.active,
              "scope": self.scope,
              "realm": self._split_string(self.realm),
diff --git a/privacyidea/static/components/config/controllers/configControllers.js b/privacyidea/static/components/config/controllers/configControllers.js
index 01b8b974b3..cd7b7d0dda 100644
--- a/privacyidea/static/components/config/controllers/configControllers.js
+++ b/privacyidea/static/components/config/controllers/configControllers.js
@@ -303,6 +303,7 @@ myApp.controller("policyDetailsController", ["$scope", "$stateParams",
         user: "",
         active: true,
         check_all_resolvers: false,
+        user_case_insensitive: false,
         client: "",
         time: "",
         priority: 1,
@@ -406,6 +407,7 @@ myApp.controller("policyDetailsController", ["$scope", "$stateParams",
             $scope.params.adminuser = policy.adminuser;
             $scope.params.active = policy.active;
             $scope.params.check_all_resolvers = policy.check_all_resolvers;
+            $scope.params.user_case_insensitive = policy.user_case_insensitive;
             $scope.params.client = policy.client;
             $scope.params.time = policy.time;
             $scope.params.priority = policy.priority;
diff --git a/privacyidea/static/components/config/views/config.policies.details.html b/privacyidea/static/components/config/views/config.policies.details.html
index 08b2e13f00..18177b99cf 100644
--- a/privacyidea/static/components/config/views/config.policies.details.html
+++ b/privacyidea/static/components/config/views/config.policies.details.html
@@ -221,6 +221,16 @@ <h3 translate>Condition</h3>
                            placeholder="userA, userB"/>
                 </div>
             </div>
+            <div class="form-group">
+                <label for="user_case_insensitive" class="col-sm-6">
+                    <input type="checkbox" name="user_case_insensitive"
+                           ng-model="params.user_case_insensitive"
+                           id="case-sensitive">
+                    <translate>
+                Username case-insensitive.
+                    </translate>
+                </label>
+            </div>
             <hr>
 
             <div class="form-group">


Test patch:
diff --git a/tests/test_lib_policy.py b/tests/test_lib_policy.py
index 158f25ee9f..11b39642e7 100644
--- a/tests/test_lib_policy.py
+++ b/tests/test_lib_policy.py
@@ -120,6 +120,14 @@ def test_02_update_policies(self):
                        adminuser=["admin", "superroot"])
         self.assertTrue(p > 0)
 
+        p = set_policy(name="pol5",
+                       action="enroll, init, disable , enable",
+                       scope="admin",
+                       realm="realm2",
+                       user_case_insensitive=True,
+                       adminuser=["Admin", "superroot"])
+        self.assertTrue(p > 0)
+
         # enable and disable policies
         policies = PolicyClass().match_policies(active=False)
         num_old = len(policies)
@@ -148,8 +156,11 @@ def test_02_update_policies(self):
         # find policies authorization and realm2
         policies = P.match_policies(action="tokentype", scope=SCOPE.AUTHZ)
         self.assertTrue(len(policies) == 2, policies)
-        # find policies with user admin
+        # find policies with user admin and just as case-insensitive police with Admin
         policies = P.match_policies(scope="admin", adminuser="admin")
+        self.assertTrue(len(policies) == 2, "{0!s}".format(len(policies)))
+        # find policies with user Admin and no case-sensitive police with admin
+        policies = P.match_policies(scope="admin", adminuser="Admin")
         self.assertTrue(len(policies) == 1, "{0!s}".format(len(policies)))
         # find policies with resolver2 and authorization. THe result should
         # be pol2 and pol2a
@@ -168,6 +179,8 @@ def test_02_update_policies(self):
         policies = P.match_policies(resolver="resolver1", scope=SCOPE.AUTHZ)
         self.assertTrue(len(policies) == 3, policies)
 
+        delete_policy(name="pol5")
+
     def test_04_delete_policy(self):
         delete_policy(name="pol4")
         P = PolicyClass()