From fe7ba91791a70fb1695ed12997c380796bc70d7d Mon Sep 17 00:00:00 2001 From: Jonathan Maw Date: Fri, 21 Oct 2022 15:27:59 +0100 Subject: [PATCH 1/3] models: make 'locked' a field inside the User instead of hiding all locked users --- lifecycle/models.py | 1 + lifecycle/source_ldap3.py | 13 +++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lifecycle/models.py b/lifecycle/models.py index a323c9f..2b31e6f 100644 --- a/lifecycle/models.py +++ b/lifecycle/models.py @@ -24,6 +24,7 @@ class User: fullname: str = "" email: tuple[str] = field(default_factory=tuple) groups: tuple[Group] = field(default_factory=tuple) + locked: bool = False def __post_init__(self): diff --git a/lifecycle/source_ldap3.py b/lifecycle/source_ldap3.py index f8a0727..ad6ae81 100644 --- a/lifecycle/source_ldap3.py +++ b/lifecycle/source_ldap3.py @@ -81,21 +81,18 @@ def fetch_users(self): if connection.entries: for ldap_entry in connection.entries: user_account = ldap_entry.entry_attributes_as_dict - ns_account_lock = user_account["nsAccountLock"] - locked = len(ns_account_lock) > 0 and ns_account_lock[0] == "TRUE" - - if ( - not locked - and len(user_account["uid"]) > 0 - and len(user_account["mail"]) > 0 - ): + + if len(user_account["uid"]) > 0 and len(user_account["mail"]) > 0: uid = user_account["uid"][0] + ns_account_lock = user_account["nsAccountLock"] + locked = len(ns_account_lock) > 0 and ns_account_lock[0] == "TRUE" user = User( uid, forename=user_account["givenName"][0], surname=user_account["surName"][0], email=user_account["mail"], groups=[], + locked=locked, ) self.users[uid] = user else: From 18da399790b21441507853fece5444ae2d920e75 Mon Sep 17 00:00:00 2001 From: Jonathan Maw Date: Fri, 21 Oct 2022 17:48:32 +0100 Subject: [PATCH 2/3] WIP: Add an ldap3 target --- lifecycle/target_ldap3.py | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 lifecycle/target_ldap3.py diff --git a/lifecycle/target_ldap3.py b/lifecycle/target_ldap3.py new file mode 100644 index 0000000..18fdc23 --- /dev/null +++ b/lifecycle/target_ldap3.py @@ -0,0 +1,91 @@ +"""Target for pushing users and groups to LDAP""" + +import ldap3 + +from lifecycle.models import User +from lifecycle.model_diff import ModelDifference +from lifecycle.source_ldap3 import SourceLDAP3 + + +# Inherits from LDAP source because we want to reuse their user and group fetching +class TargetLDAP3(SourceLDAP3): + """Given an LDAP Server config, will provide an interface to push users and groups to said LDAP Server""" + + def reconfigure(self, config: dict): + """Apply new configuration to object. + + The newly supplied config entry will be merged over the existing config. + """ + super().reconfigure(self, config) + + extra_default_config = { + "target_operations": [ + "add_users", + "remove_users", + "modify_users", + ], + "new_user_cn_field": "fullname", # username also a good candidate + "new_user_group_path": "cn=users,cn=accounts" + } + self.config = extra_default_config | self.config + + @staticmethod + def _user_to_ldap_changes(user: User) -> dict: + # 'uid' is also a field that could go into ldap changes, but we don't expect that to ever change + + # XXX: HOW DO I SET WHICH USERS ARE MEMBERS OF GROUPS? + lock_status = "TRUE" if user.locked else "FALSE" + return { + "givenName": [(ldap3.MODIFY_REPLACE, [user.forename])], + "surName": [(ldap3.MODIFY_REPLACE, [user.surname])], + "nsAccountLock": [(ldap3.MODIFY_REPLACE, [lock_status])], + "mail": [(ldap3.MODIFY_REPLACE, sorted(user.email))], + } + + + def _find_user_by_uid(self, connection: ldap3.Connection, uid: str) -> str: + """Searches for a user by its unique uid and returns its dn""" + + # "uid", the key to the users dict, is the only part that's guaranteed to be unique + connection.search( + search_base=self.config["base_dn"], + search_filter=f"(uid={uid})", + search_scope=ldap3.SUBTREE, + attributes=[ + "uid", + ], + ) + assert len(connection.entries) == 1, f"UID {uid} is not unique! Lifecycle can't handle this!" + return connection.entries[0].entry_dn + + def add_users(self, users: dict[str, User]): + """Adds the listed users to the starget""" + # XXX: Not 100% sure what a new user's cn should be. configurable?? + + def remove_users(self, users: dict[str, User]): + """Removes the listed users from the target""" + + connection = self.connect() + for uid, user in users.items(): + user_dn = self._find_user_by_uid(self, connection, uid) + connection.delete(user_dn) + + def modify_users(self, users: dict[str, User]): + """Modifies all listed users in the target""" + + connection = self.connect() + for uid, user in users.items(): + user_dn = self._find_user_by_uid(self, connection, uid) + changes = _user_to_ldap_changes(user) + connection.modify(user_dn, changes) + + def sync_users_changes(self, changes: ModelDifference): + """Synchronises the difference in the users model with the server""" + operations = self.config["target_operations"] + if "add_users" in operations: + self.add_users(changes.added_users) + if "remove_users" in operations: + self.remove_users(changes.removed_users) + if "modify_users" in operations: + self.modify_users(changes.changed_users) + From b150b0b9a66033ef3794bd86a47dda53e5f6b8f2 Mon Sep 17 00:00:00 2001 From: Jonathan Maw Date: Mon, 7 Nov 2022 14:59:17 +0000 Subject: [PATCH 3/3] WIP: Add some helpful comments --- lifecycle/source_ldap3.py | 2 +- lifecycle/target_ldap3.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lifecycle/source_ldap3.py b/lifecycle/source_ldap3.py index ad6ae81..662299a 100644 --- a/lifecycle/source_ldap3.py +++ b/lifecycle/source_ldap3.py @@ -120,7 +120,7 @@ def fetch_groups(self): else: description = "" group = Group(name, description, ldap_group["mail"]) - + # member is of the format uid=admin,cn=users,cn=accounts,... for member in ldap_group["member"]: components = member.split(",") uid = components[0].split("=")[1] diff --git a/lifecycle/target_ldap3.py b/lifecycle/target_ldap3.py index 18fdc23..d62f337 100644 --- a/lifecycle/target_ldap3.py +++ b/lifecycle/target_ldap3.py @@ -34,6 +34,11 @@ def _user_to_ldap_changes(user: User) -> dict: # 'uid' is also a field that could go into ldap changes, but we don't expect that to ever change # XXX: HOW DO I SET WHICH USERS ARE MEMBERS OF GROUPS? + # Get the Groups that the User is a part of, + # Search for them in LDAP + # And update every Group entry to add/remove/update the list of "member" + # to include the User's dn. + # XXX: Hang on, do users' DN start with "uid=" or "cn="? lock_status = "TRUE" if user.locked else "FALSE" return { "givenName": [(ldap3.MODIFY_REPLACE, [user.forename])],