phi/src/phi/async_ldap/model.py

441 lines
12 KiB
Python

# -*- encoding: utf-8 -*-
import asyncio
import logging
import typing as T
from bonsai import LDAPEntry, LDAPModOp, NoSuchObjectError # type: ignore
from phi.exceptions import (
PhiAttributeMissing,
PhiEntryDoesNotExist,
PhiEntryExists,
PhiUnauthorized,
PhiUnexpectedRuntimeValue,
)
from phi.logging import get_logger
from phi.security import hash_pass
log = get_logger(__name__)
alog = logging.getLogger("asyncio")
alog.setLevel(logging.DEBUG)
def get_class(obj):
"""
Return the input if input is a class, else tryes to get the class from the
`__class__` method.
"""
if type(obj) is type:
return obj
return obj.__class__
def recall(cls):
"""
Prints the name of the parent class.
"""
_cls = get_class(cls)
return _cls.__bases__[0]
def call_if_callable(cls, attr):
"""
Tell between methods and properties. Call if method.
"""
_attr = getattr(cls, attr)
if "__call__" in dir(_attr):
return _attr()
return _attr
def inheritance(obj, attr, root_cls=object):
"""
Concatenates the value obtained from invoking the method attr
on the class and on any parent class until root_cls.
"""
res = call_if_callable(obj, attr)
base = get_class(obj)
while base is not root_cls:
if base is not get_class(obj):
res += ",{}".format(call_if_callable(base, attr))
base = recall(base)
return res.strip(",")
def singletonize(cls, name):
"""
Helper function to be plugged in `__new__` method to make the class a singleton.
"""
if name not in cls._instances:
cls._instances[name] = object.__new__(cls)
return cls._instances[name]
async def iter_children(children):
return [child async for child in children]
class Entry(object):
"""
LDAP Entry. Interface to LDAP.
"""
kind: T.Union[None, str] = None
_name: T.Union[None, str] = None
@classmethod
def name(cls):
if "_name" in dir(cls) and cls._name is not None:
return cls._name
return cls.__name__
@classmethod
def qualified_name(cls):
return "{}={}".format(cls.kind, cls.name())
def __init__(self, client):
self.client = client
self.base_dn = client.base_dn
def __repr__(self):
return f"<{call_if_callable(self, 'name')} {self.dn}>"
def __dict__(self):
return self._dict
def __aiter__(self):
return self
async def get_children(self):
async with self.client.connect(is_async=True) as conn:
for el in await conn.search(self.dn, 2):
yield el
@property
def children(self):
"""
Synchronous property to enumerate the children.
"""
return asyncio.run(iter_children(self.get_children()))
@property
def dn(self):
return "{},{}".format(inheritance(self, "qualified_name", Entry), self.base_dn)
@property
def attributes(self):
raise NotImplemented()
async def describe(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.
res = await conn.search(self.dn, 0)
if len(res) == 0:
return
elif len(res) > 1:
raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", res
)
res = res[0]
res.update({"dn": self.dn, "name": self.name})
return res
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(_name, obj.client)
class Hackers(Entry):
"""
This class is where Users belong.
"""
kind = "ou"
_name = "Hackers"
_instances: T.Dict[str, Entry] = dict()
def __new__(cls, client, *args, **kwargs):
return singletonize(cls, f"{cls._name}-{id(client)}")
def __init__(self, client, *args, **kwargs):
super().__init__(client)
self._hackers = build_heritage(self, User)
async def __anext__(self):
try:
return await self._hackers.__anext__()
except StopAsyncIteration:
self._hackers = build_heritage(self, User)
raise
async def get_by_attr(self, attr, value):
async with self.client.connect(is_async=True) as conn:
res = await conn.search("{}={},{}".format(attr, value, self.dn), 0)
if len(res) == 0:
return None
return [User(r["uid"][0], self.client) for r in res]
async def get_by_uid(self, uid):
res = await self.get_by_attr("uid", uid)
return res[0]
@property
def attributes(self):
return {"kind": self.kind}
class Robots(Entry):
"""
This class is where Services belong.
"""
kind = "ou"
_name = "Services"
_instances: T.Dict[str, Entry] = dict()
def __new__(cls, client, *args, **kwargs):
return singletonize(cls, f"{cls._name}-{id(client)}")
def __init__(self, client, *args, **kwargs):
super().__init__(client)
self._robots = build_heritage(self, Service)
async def __anext__(self):
try:
return await self._robots.__anext__()
except StopAsyncIteration:
self._robots = build_heritage(self, Service)
raise
class Congregations(Entry):
"""
This class is where Groups belong.
"""
kind = "ou"
_name = "Groups"
_instances: T.Dict[str, Entry] = dict()
def __new__(cls, client, *args, **kwargs):
return singletonize(cls, f"{cls._name}-{id(client)}")
def __init__(self, client, *args, **kwargs):
super().__init__(client)
self._groups = build_heritage(self, Group, attribute_id="cn")
async def __anext__(self):
try:
return await self._groups.__anext__()
except StopAsyncIteration:
self._groups = build_heritage(self, Group, attribute_id="cn")
raise
async def create_new_(self, **kwargs):
entry = LDAPEntry(self.dn)
entry["objectClass"] = self.object_class
for k, w in kwargs.items():
entry[k] = w
async with self.client.connect(is_async=True) as conn:
await conn.add(entry)
return entry
class User(Hackers):
"""
This class models a user. Users may have attributes
and belong one or more Group(s).
"""
kind = "uid"
_instances: T.Dict[str, Entry] = dict()
object_class = ["inetOrgPerson", "organizationalPerson", "person", "top"]
def __new__(cls, client, name, *args, **kwargs):
return singletonize(cls, f"{name}-{id(client)}")
def __init__(self, client, name, *args, **kwargs):
super().__init__(client, *args, **kwargs)
self._name = name
self._entry = LDAPEntry(self.dn)
def __repr__(self):
return f"<{get_class(self).__name__}({self.name}) {self.dn}>"
def qualified_name(self):
return "{}={}".format(self.kind, self.name)
@property
def name(self):
return self._name
@name.setter
def name(self, name):
raise RuntimeError("Name property is not modifiable.")
async def create(self, mail, password=None, sn=None, cn=None):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(self.dn, 0)
if len(res) > 0:
raise PhiEntryExists(self.dn)
_sn = sn if sn is not None else self.name
_cn = cn if cn is not None else self.name
hashed = hash_pass(password)
self._entry = await create_new_(
self, uid=self.name, mail=mail, sn=_sn, cn=_cn, userPassword=hashed
)
alog.info("User(%s): created", self.name)
async def sync(self):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(self.dn, 0)
if len(res) == 0:
raise PhiEntryDoesNotExist(self.dn)
for k, v in res[0].items():
if not k == "dn":
self._entry[k] = v
alog.info("User(%s): synced", self.name)
async def modify(self, key, value, append=False):
async with self.client.connect(is_async=True) as conn:
self._entry.connection = conn
try:
if not append:
del self._entry[key]
self._entry[key] = value
except KeyError:
raise PhiAttributeMissing(self.dn, key)
await self._entry.modify()
alog.info("User(%s): modified (%s)", self.name, key)
async def remove(self):
async with self.client.connect(is_async=True) as conn:
self._entry.connection = conn
try:
await self._entry.delete()
alog.info("User(%s): removed", self.name)
except NoSuchObjectError:
raise PhiEntryDoesNotExist(self.dn)
async def modify_password(self, new_pass, old_pass=None):
try:
await self.modify("userPassword", hash_pass(new_pass))
except PhiAttributeMissing:
raise PhiUnauthorized(user=self.client.username,)
alog.info("User(%s): password modified", self.name)
async def verify_password(self, given_pass):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(self.dn, 0)
if len(res) == 0:
raise PhiEntryDoesNotExist(self.dn)
try:
match_pass = res[0]["userPassword"][0] == hash_pass(given_pass)
except KeyError:
raise PhiUnauthorized(user=self.client.username,)
return match_pass
class Service(Robots):
"""
This class models a system user (i.e. users that are ancillary to
services on a machine). System user may have attributes
and belong to one or more Group(s).
"""
kind = "uid"
_instances: T.Dict[str, Entry] = dict()
object_class = ["account", "top", "simpleSecurityObject"]
def __new__(cls, client, name, *args, **kwargs):
return singletonize(cls, f"{name}-{id(client)}")
def __init__(self, client, name, *args, **kwargs):
super().__init__(client, *args, **kwargs)
self._name = name
self._entry = LDAPEntry(self.dn)
def __repr__(self):
return f"<{get_class(self).__name__}({self.name}) {self.dn}>"
def qualified_name(self):
return "{}={}".format(self.kind, self.name)
@property
def name(self):
return self._name
@name.setter
def name(self, name):
raise RuntimeError("Name property is not modifiable.")
async def create(self, password):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(self.dn, 0)
if len(res) > 0:
raise PhiEntryExists(self.dn)
self._entry = await create_new_(self, uid=self.name, userPassword=password)
alog.info("Service(%s): created", self.name)
async def sync(self):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(self.dn, 0)
if len(res) == 0:
raise PhiEntryDoesNotExist(self.dn)
for k, v in res[0].items():
if not k == "dn":
self._entry[k] = v
alog.info("Service(%s): synced", self.name)
async def remove(self):
pass
async def modify_password(self, new_pass, old_pass=None):
pass
async def verify_password(self, given_pass):
pass
class Group(Congregations):
"""
This class models a group. Groups may have attributes
and may have Users and Services belonging to them.
"""
kind = "cn"
_instances: T.Dict[str, Entry] = dict()
object_class = ["groupOfNames", "top"]
def __new__(cls, client, name, *args, **kwargs):
return singletonize(cls, f"{name}-{id(client)}")
def __init__(self, client, name, *args, **kwargs):
super().__init__(client, *args, **kwargs)
self._name = name
self._entry = LDAPEntry(self.dn)
def __repr__(self):
return f"<{get_class(self).__name__}({self.name}) {self.dn}>"
def qualified_name(self):
return "{}={}".format(self.kind, self.name)
@property
def name(self):
return self._name
@name.setter
def name(self, name):
raise RuntimeError("Name property is not modifiable.")