phi/src/phi/async_ldap/mixins.py

345 lines
11 KiB
Python

# -*- encoding: utf-8 -*-
from bonsai import LDAPEntry, LDAPModOp, NoSuchObjectError # type: ignore
from bonsai.ldapvaluelist import LDAPValueList
import bonsai.errors
from phi.exceptions import (
PhiEntryDoesNotExist,
PhiEntryExists,
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 not hasattr(cls, "_instances"):
cls._instances = dict()
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()
for attr in self.ldap_attributes:
value = _internal.get(attr)
if value:
values[attr] = de_listify(value)
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