# -*- encoding: utf-8 -*- from bonsai import LDAPEntry, LDAPModOp, NoSuchObjectError # type: ignore from bonsai.ldapvaluelist import LDAPValueList import bonsai.errors from phi.exceptions import ( PhiAttributeMissing, PhiEntryDoesNotExist, PhiEntryExists, PhiUnauthorized, PhiUnexpectedRuntimeValue, PhiCannotExecute, ) from phi.security import hash_pass, handle_password def de_listify(elems): if not isinstance(elems, list): return elems if len(elems) == 1: return elems[0] else: return elems async def build_heritage(obj, child_class, attribute_id="uid"): """ Given the object and the child class, yields the instances of the children. """ async for child in obj.get_children(): if attribute_id in child: _name = child[attribute_id][0] yield child_class(obj.client, _name) class Singleton(object): """ Mixin to singletonize a class. The class is crafted to be used with the mixins that implement the compatible __init__. """ def __new__(cls, client, *args, **kwargs): if "name" in kwargs: name = f"{cls.__name__}-{args['name']}-{id(client)}" elif args: name = f"{cls.__name__}-{args[0]}-{id(client)}" else: name = f"{cls.__name__}-{id(client)}" if name not in cls._instances: cls._instances[name] = object.__new__(cls) return cls._instances[name] class Entry(object): """ Mixin to interact with LDAP. """ def __repr__(self): return f"<{self.__class__.__name__} {self.dn}>" def __str__(self): return f"<{self.__class__.__name__} {self.dn}>" async def _create_new(self): self._entry["objectClass"] = self.object_class async with self.client.connect(is_async=True) as conn: await conn.add(self._entry) return self._entry async def _get(self): async with self.client.connect(is_async=True) as conn: # This returns a list of dicts. It should always contain only one item: # the one we are interested in. _res = await conn.search(self.dn, 0) if len(_res) == 0: raise PhiEntryDoesNotExist(self.dn) elif len(_res) > 1: raise PhiUnexpectedRuntimeValue( "return value should be no more than one", res ) return _res[0] async def _modify(self): _ = await self._get() async with self.client.connect(is_async=True) as conn: self._entry.connection = conn await self._entry.modify() async def _delete(self): async with self.client.connect(is_async=True) as conn: await conn.delete(self.dn, recursive=self.delete_cascade) async def describe(self): _internal = await self._get() values = dict((k, de_listify(_internal[k])) for k in self.ldap_attributes) if "userPassword" in self.ldap_attributes: values.pop("userPassword") values["dn"] = self.dn return values @property def delete_cascade(self): if hasattr(self, "_delete_cascade"): return self._delete_cascade return False @delete_cascade.setter def delete_cascade(self, value): if not isinstance(value, bool): raise ValueError("delete_cascade must be a boolean") self._delete_cascade = value class OrganizationalUnit(object): """ Mixin that represents an OrganizationalUnit. It provides the methods to interact with the LDAP db _and_ to supervise its `Member`s. To properly use it, one must specify the `ou` and `child_class` class attributes when inheriting. """ object_class = ["organizationalUnit", "top"] def __init__(self, client, **kwargs): self.client = client self.base_dn = client.base_dn self.name = self.__class__.__name__ self.children = build_heritage(self, self.child_class, self.child_class.id_tag) self._entry = LDAPEntry(self.dn) for k, v in kwargs.items(): if k in self.ldap_attributes: self._entry[k] = v if "delete_cascade" in kwargs: self.delete_cascade = delete_cascade def __aiter__(self): return self async def __anext__(self): try: return await self.children.__anext__() except StopAsyncIteration: self.children = build_heritage( self, self.child_class, self.child_class.id_tag ) raise async def get_children(self): async with self.client.connect(is_async=True) as conn: for el in await conn.search(self.dn, 1): yield el @property def dn(self): return f"ou={self.ou},{self.base_dn}" async def save(self): """ This function iterates over the OU's children and invokes its `save` method, ignoring errors from yet existing ones. """ async for child in self: try: await child.save() except PhiEntryExists: pass async def search(self, member_name): """ This function allows one to search through the OU's children. The search function is the one from the underlying library (bonsai) and is strict as such. """ result = None async with self.client.connect(is_async=True) as conn: result = await conn.search( f"{self.child_class.id_tag}={member_name},{self.dn}", 0 ) if not result: raise PhiEntryDoesNotExist( f"{self.child_class.id_tag}={member_name},{self.dn}" ) if isinstance(result, list) and len(result) > 1: raise PhiUnexpectedRuntimeValue( "return value should be no more than one", result ) return self.child_class(self.client, member_name, **result[0]) async def delete(self): """ Delete all the members of this OU only if `delete_cascade` is set to `True`, raises otherwise. """ if self.delete_cascade: async for member in self: await member.delete() else: raise PhiCannotExecute("Cannot delete an OU and delete_cascade is not set") def _hydrate(obj, data): """ Iterate over the structure of the given `data`. Using the key name, filtering only on the values that the given `obj` accepts (`obj.ldap_attributes`), appropriately set the corresponding value in the given `obj`. In particular: - append to lists - handle password setting - set scalars This is called `_hydrate` because its aim is to fill a structure (the `obj`) with substance. """ for k, v in data.items(): if k in obj.ldap_attributes: if isinstance(v, list) and not isinstance(v, LDAPValueList): obj._entry[k] = [] for _v in v: obj._entry[k].append(_v.dn) elif k == "userPassword": obj._entry[k] = handle_password(v) else: obj._entry[k] = v class Member(object): """ Mixin that represents a generic member of an `OrganizationalUnit`. It provides the methods to interact with the LDAP db. To properly use, `ou`, `object_class` and `ldap_attributes` class attributes must be specified when inheriting. ## Usage The initialization needs an `phi.async_ldap.AsyncClient` and a `name`, that is used as value in the identification attribute (i.e. `uid`). This inits an object in memory that may or may not exist in the ldap database yet. To test it, one can invoke the async method `exists` or may try to `sync`, handling the corresponding exception (`PhiEntryDoesNotExist`). To save a new instance, one can `save`. The instance accepts dict-like get and set on the aforementioned `ldap_attributes`. Once an attribute value has been modified, one can invoke `modify` to persist the changes. To remove an instance from the database, one can invoke `delete`. ## Comparisons The comparison operation with a `Member` is quite loose: it returns `True` with either: - an instance of the same `type` (i.e. the same class whose this mixin is used into) whose `dn` matches - an `LDAPEntry` whose `dn` matches - a string matching the `dn` """ def __init__(self, client, name, **kwargs): super().__init__() self.client = client self.base_dn = client.base_dn self.name = name self._entry = LDAPEntry(self.dn) self[self.id_tag] = name self._entry["ou"] = self.ou if kwargs: _hydrate(self, kwargs) def __eq__(self, other): if isinstance(other, type(self)): return other.dn == self.dn elif isinstance(other, str): return other == self.dn elif isinstance(other, LDAPEntry): return other["dn"] == self.dn else: return False @property def dn(self): return f"{self.id_tag}={self.name},ou={self.ou},{self.base_dn}" def __setitem__(self, attr, val): if attr not in self.ldap_attributes: raise PhiCannotExecute( f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}" ) self._entry[attr] = val def __getitem__(self, attr): if attr not in self.ldap_attributes: raise PhiCannotExecute( f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}" ) return self._entry[attr][0] async def save(self): """ This method persists on the ldap database an inited instance. Raises `PhiEntryExists` in case of a yet existing instance. Raises a specific error if the instance misses any of the needed attributes (accoding to `ldap_attributes`). """ try: await self._create_new() except bonsai.errors.AlreadyExists: raise PhiEntryExists(self.dn) async def modify(self): """ This method saves the changes made to the instance on the ldap database. Raises `PhiEntryDoesNotExist` in case of an instance not yet persisted. """ await self._modify() async def delete(self): """ This method removes the instance from the database. Raises `PhiEntryDoesNotExist` in case the entry does not exist. """ await self._delete() async def sync(self): """ This method reads the `ldap_attributes` of an existing instance from the ldap database and assigns the values to `self`. It is needed at first instantiation of the object, in case an instance exists on the database. """ res = await self._get() _hydrate(self, res) return self async def exists(self): """ This method returns `True` if the instance exists on the ldap database, `False` if it does not. It might raise `PhiUnexpectedRuntimeValue` if the ldap state is inconsistent. """ try: _ = await self.sync() return True except PhiEntryDoesNotExist: return False