Compare commits
83 Commits
master
...
feat/init_
Author | SHA1 | Date | |
---|---|---|---|
b376d1f270 | |||
fa7b633a16 | |||
d51acc6999 | |||
d414634f42 | |||
a222fe9560 | |||
7224cd73f2 | |||
9fdef6f2e5 | |||
593255e853 | |||
ef08fe5997 | |||
714f3221f9 | |||
7201d9a529 | |||
297b31ab90 | |||
b704ea4d10 | |||
b0d2f759ea | |||
d6aac65bba | |||
c0d835d8d1 | |||
2932957afb | |||
077c53c5be | |||
e6cee09429 | |||
c5a4b86349 | |||
b0e5d00994 | |||
401f01f110 | |||
30c46c059a | |||
b400127cc6 | |||
6bd1beba9e | |||
e202d54a7d | |||
67c7830ba4 | |||
95bc52ebd8 | |||
ac0ab02d6e | |||
d024232d66 | |||
166b542846 | |||
d87c89a075 | |||
b7316ff513 | |||
67c83975d1 | |||
205c87dc49 | |||
c05b023bdb | |||
1b97d6d7ca | |||
b979002f78 | |||
e41e03f464 | |||
d9a6db63d7 | |||
6fea75022f | |||
12f37b3a55 | |||
a0cf7e9603 | |||
8779db9ca0 | |||
e454fbd84a | |||
66885641c4 | |||
a22f459915 | |||
08c45b54f2 | |||
2bc7f0b75f | |||
d9e8eb23e3 | |||
ce4c085e3f | |||
7634f0f530 | |||
79f682cbb7 | |||
2b46eb0353 | |||
fed48022af | |||
3454c194b1 | |||
f05fe8a0d5 | |||
ec472218c4 | |||
79d7dcc653 | |||
fe3450b886 | |||
f558492975 | |||
706f109faf | |||
0f7882a387 | |||
1be1aac9d0 | |||
7e6b757e3a | |||
ed8af40392 | |||
2cf07d6732 | |||
8dce6566ee | |||
b466bf8ed2 | |||
d0cba75ee0 | |||
fd729170d3 | |||
c4046a83ff | |||
4e2cadaa92 | |||
b1837f80e4 | |||
27fc927254 | |||
a434ff9b4c | |||
422d238fc1 | |||
b0f312284d | |||
543416368b | |||
bdd4a39531 | |||
e67b97b214 | |||
c8eb5c2dd4 | |||
80fb51f7de |
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -90,6 +90,9 @@ ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
|
# Personal local venv standard ("*" matches the version number)
|
||||||
|
/cpy*
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
.spyproject
|
.spyproject
|
||||||
|
|
27
Pipfile
27
Pipfile
|
@ -4,9 +4,30 @@ url = "https://pypi.org/simple"
|
||||||
verify_ssl = true
|
verify_ssl = true
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
ipython = "*"
|
||||||
|
pytest = "*"
|
||||||
|
ipdb = "*"
|
||||||
|
async-generator = "*"
|
||||||
|
pytest-cov = "*"
|
||||||
|
pytest-asyncio = "*"
|
||||||
|
pytest-aiohttp = "*"
|
||||||
|
mock = "*"
|
||||||
|
yarl = {editable = true, path = "."}
|
||||||
|
pytest-integration = "*"
|
||||||
|
iniconfig = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
phi = {editable = true,path = "."}
|
phi = {editable = true, path = "."}
|
||||||
|
aiohttp = "==3.8.1"
|
||||||
|
click = "==8"
|
||||||
|
pyYAML = "*"
|
||||||
|
bonsai = "==1.3.0"
|
||||||
|
passlib = "==1.7.1"
|
||||||
|
bcrypt = "==3.2.0"
|
||||||
|
multidict = "*"
|
||||||
|
iniconfig = "*"
|
||||||
|
aiohttp-session = {version = "==2.11.0", extras = ["secure"]}
|
||||||
|
jinja2 = "==3"
|
||||||
|
|
||||||
[requires]
|
[pipenv]
|
||||||
python_version = "3.7"
|
allow_prereleases = true
|
||||||
|
|
1637
Pipfile.lock
generated
1637
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -8,4 +8,4 @@ APIs for the Unit hacklab.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
* Python >= 3.5
|
* Python >= 3.9
|
||||||
|
|
117
async_tests/test_async_ldap_client.py
Normal file
117
async_tests/test_async_ldap_client.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import mock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from phi.async_ldap.client import (
|
||||||
|
parse_host,
|
||||||
|
checked_port,
|
||||||
|
compose_dn_username,
|
||||||
|
AsyncClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_DN = "dc=unit,dc=macaomilano,dc=org"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def does_not_raise():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"test_url, exp_proto, exp_addr, exp_port",
|
||||||
|
[
|
||||||
|
("1.3.1.2", "ldap", "1.3.1.2", 389),
|
||||||
|
("ldap://localhost:1312", "ldap", "localhost", 1312),
|
||||||
|
("localhost:1312", "ldap", "localhost", 1312),
|
||||||
|
("localhost", "ldap", "localhost", 389),
|
||||||
|
("ldap://localhost", "ldap", "localhost", 389),
|
||||||
|
("ldaps://localhost", "ldaps", "localhost", 636),
|
||||||
|
("ldaps://localhost:1312", "ldaps", "localhost", 1312),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_host(test_url, exp_proto, exp_addr, exp_port):
|
||||||
|
proto, addr, port = parse_host(test_url)
|
||||||
|
|
||||||
|
assert proto == exp_proto
|
||||||
|
assert addr == exp_addr
|
||||||
|
assert port == exp_port
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"manual, auto, exp_port", [(None, 389, 389), (1312, 389, 1312), (1312, 1312, 1312)]
|
||||||
|
)
|
||||||
|
def test_checked_port(manual, auto, exp_port, caplog):
|
||||||
|
port = checked_port(manual, auto)
|
||||||
|
if manual and manual != auto:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
"The former prevails" in caplog.text
|
||||||
|
|
||||||
|
assert port == exp_port
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"username, base_dn, ou, attribute_id, exp_dn",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
f"uid=conte_mascetti,{BASE_DN}",
|
||||||
|
BASE_DN,
|
||||||
|
None,
|
||||||
|
"uid",
|
||||||
|
f"uid=conte_mascetti,{BASE_DN}",
|
||||||
|
),
|
||||||
|
("root", BASE_DN, None, "cn", f"cn=root,{BASE_DN}"),
|
||||||
|
("necchi", BASE_DN, "Hackers", "uid", f"uid=necchi,ou=Hackers,{BASE_DN}"),
|
||||||
|
("perozzi", BASE_DN, "Phrackers", "cn", f"cn=perozzi,ou=Phrackers,{BASE_DN}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_compose_dn_username(username, base_dn, ou, attribute_id, exp_dn):
|
||||||
|
dn = compose_dn_username(username, base_dn, ou, attribute_id)
|
||||||
|
|
||||||
|
assert dn == exp_dn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url, encryption, validate, ca_cert, expectation",
|
||||||
|
[
|
||||||
|
("localhost", None, False, None, does_not_raise()),
|
||||||
|
("localhost", True, False, None, does_not_raise()),
|
||||||
|
("localhost", False, True, None, does_not_raise()),
|
||||||
|
("localhost", True, True, "path/to/cert.pem", does_not_raise()),
|
||||||
|
("ldaps://localhost", False, False, None, pytest.raises(ValueError)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_AsyncClient_init(url, encryption, validate, ca_cert, expectation):
|
||||||
|
with expectation as exp:
|
||||||
|
cl = AsyncClient(
|
||||||
|
host=url,
|
||||||
|
port=389,
|
||||||
|
encryption=encryption,
|
||||||
|
ciphers=None,
|
||||||
|
validate=validate,
|
||||||
|
ca_cert=ca_cert,
|
||||||
|
username="conte_mascetti",
|
||||||
|
password="pass",
|
||||||
|
base_dn=BASE_DN,
|
||||||
|
ou="Hackers",
|
||||||
|
)
|
||||||
|
|
||||||
|
if exp is not None:
|
||||||
|
assert "Incompatible provided protocol" in str(exp.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert cl.base_dn == BASE_DN
|
||||||
|
assert url in cl.full_uri
|
||||||
|
assert "389" in cl.full_uri
|
||||||
|
assert cl._tls if encryption else not cl._tls
|
||||||
|
if validate:
|
||||||
|
assert cl.cert_policy == -1
|
||||||
|
else:
|
||||||
|
assert cl.cert_policy == 0
|
||||||
|
if ca_cert:
|
||||||
|
assert cl.ca_cert == ca_cert
|
||||||
|
else:
|
||||||
|
assert cl.ca_cert == ""
|
196
async_tests/test_async_ldap_model.py
Normal file
196
async_tests/test_async_ldap_model.py
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from async_generator import asynccontextmanager
|
||||||
|
from bonsai import LDAPEntry
|
||||||
|
import mock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from phi.async_ldap.model import get_dn, User
|
||||||
|
from phi.async_ldap.mixins import Member
|
||||||
|
from phi.exceptions import PhiCannotExecute
|
||||||
|
|
||||||
|
BASE_DN = "dc=test,dc=domain,dc=tld"
|
||||||
|
|
||||||
|
|
||||||
|
class MockClient(object):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.return_value = kwargs.get("return_value")
|
||||||
|
self.connect_called = False
|
||||||
|
self.conn = mock.MagicMock()
|
||||||
|
self.search_event = asyncio.Event()
|
||||||
|
self.add_event = asyncio.Event()
|
||||||
|
self.delete_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def connect_called_with_search(self):
|
||||||
|
return await self.search_event.wait()
|
||||||
|
|
||||||
|
async def connect_called_with_add(self):
|
||||||
|
return await self.add_event.wait()
|
||||||
|
|
||||||
|
async def connect_called_with_delete(self):
|
||||||
|
return await self.delete_event.wait()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_dn(self):
|
||||||
|
return BASE_DN
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connect(self, *args, **kwargs):
|
||||||
|
self.connect_called = True
|
||||||
|
|
||||||
|
async def _search(*a, **kw):
|
||||||
|
self.search_event.set()
|
||||||
|
return self.return_value
|
||||||
|
|
||||||
|
async def _add(*a, **kw):
|
||||||
|
self.add_event.set()
|
||||||
|
return self.return_value
|
||||||
|
|
||||||
|
async def _modify(*a, **kw):
|
||||||
|
return self.return_value
|
||||||
|
|
||||||
|
async def _delete(*a, **kw):
|
||||||
|
self.delete_event.set()
|
||||||
|
return self.return_value
|
||||||
|
|
||||||
|
self.conn.search = mock.MagicMock(side_effect=_search)
|
||||||
|
self.conn.add = mock.MagicMock(side_effect=_add)
|
||||||
|
self.conn.modify = mock.MagicMock(side_effect=_modify)
|
||||||
|
self.conn.delete = mock.MagicMock(side_effect=_delete)
|
||||||
|
|
||||||
|
yield self.conn
|
||||||
|
|
||||||
|
|
||||||
|
cl = mock.MagicMock()
|
||||||
|
cl.base_dn = BASE_DN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_obj, expected_result",
|
||||||
|
[
|
||||||
|
(User(cl, "test_user"), f"uid=test_user,ou=Hackers,{BASE_DN}"),
|
||||||
|
(
|
||||||
|
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
|
||||||
|
f"uid=test_user,ou=Hackers,{BASE_DN}",
|
||||||
|
),
|
||||||
|
(f"uid=test_user,ou=Hackers,{BASE_DN}", f"uid=test_user,ou=Hackers,{BASE_DN}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_dn(input_obj, expected_result):
|
||||||
|
assert get_dn(input_obj) == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_dn_raises():
|
||||||
|
with pytest.raises(ValueError) as e:
|
||||||
|
_ = get_dn(object)
|
||||||
|
|
||||||
|
assert "Unacceptable input:" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
assert repr(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
assert str(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_obj",
|
||||||
|
[
|
||||||
|
User(cl, "test_user"),
|
||||||
|
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
|
||||||
|
f"uid=test_user,ou=Hackers,{BASE_DN}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_eq(input_obj):
|
||||||
|
u = User(cl, "test_user")
|
||||||
|
|
||||||
|
assert u == input_obj
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_User_add():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
assert u.dn == f"uid=test_user,ou=Hackers,{BASE_DN}"
|
||||||
|
|
||||||
|
_ = await u.save()
|
||||||
|
|
||||||
|
assert _cl.connect_called
|
||||||
|
assert await _cl.connect_called_with_add()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_User_modify():
|
||||||
|
"""
|
||||||
|
This test does not use the MockClient check facilities because
|
||||||
|
of implementation details of the Entry class.
|
||||||
|
"""
|
||||||
|
_cl = MockClient(
|
||||||
|
return_value=[
|
||||||
|
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
# This is the asyncio equivalent of a semaphore
|
||||||
|
modified = asyncio.Event()
|
||||||
|
|
||||||
|
async def _mock_modify():
|
||||||
|
modified.set()
|
||||||
|
|
||||||
|
u._entry = mock.MagicMock()
|
||||||
|
u._entry.modify = mock.MagicMock(side_effect=_mock_modify)
|
||||||
|
|
||||||
|
u["cn"] = "random_cn"
|
||||||
|
_ = await u.modify()
|
||||||
|
|
||||||
|
assert _cl.connect_called
|
||||||
|
assert await _cl.connect_called_with_search()
|
||||||
|
# The `wait()` call here is needed to wait for `_mock_modify`
|
||||||
|
# to end its async execution
|
||||||
|
assert await modified.wait()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_User_delete():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
_ = await u.delete()
|
||||||
|
|
||||||
|
assert _cl.connect_called
|
||||||
|
assert await _cl.connect_called_with_delete()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_User_get_invalid_attr_raises():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
with pytest.raises(PhiCannotExecute) as ex:
|
||||||
|
_ = u["iDoNotExist"]
|
||||||
|
|
||||||
|
assert "iDoNotExist" in str(ex.value)
|
||||||
|
assert "is not an allowed ldap attribute" in str(ex.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_User_set_invalid_attr_raises():
|
||||||
|
_cl = MockClient(return_value=None)
|
||||||
|
u = User(_cl, "test_user")
|
||||||
|
|
||||||
|
with pytest.raises(PhiCannotExecute) as ex:
|
||||||
|
u["iDoNotExist"] = "hello"
|
||||||
|
|
||||||
|
assert "iDoNotExist" in str(ex.value)
|
||||||
|
assert "is not an allowed ldap attribute" in str(ex.value)
|
21
config.yml
21
config.yml
|
@ -3,6 +3,8 @@ core:
|
||||||
listen:
|
listen:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8080
|
port: 8080
|
||||||
|
# generated with: openssl rand -hex 16
|
||||||
|
cookiestore_secret: "e41133b5cfdd8660815b8d5cc2c74843"
|
||||||
|
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
|
@ -11,15 +13,15 @@ ldap:
|
||||||
|
|
||||||
encryption: TLSv1.2 # Can either be None or TLSv1.2. Default: None
|
encryption: TLSv1.2 # Can either be None or TLSv1.2. Default: None
|
||||||
ciphers: "HIGH"
|
ciphers: "HIGH"
|
||||||
validate: True # Can either be True or False. Default: False
|
validate: False # Can either be True or False. Default: False
|
||||||
ca_certs: openldap/cert.pem
|
ca_certs: openldap/cert.pem
|
||||||
|
|
||||||
username: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
|
# username: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
|
||||||
password: phi
|
# password: phi
|
||||||
|
# username: cn=root,dc=unit,dc=macaomilano,dc=org
|
||||||
|
# password: root
|
||||||
|
|
||||||
base_dn: dc=unit,dc=macaomilano,dc=org
|
base_dn: dc=unit,dc=macaomilano,dc=org
|
||||||
attribute_id: uid
|
|
||||||
attribute_mail: mail
|
|
||||||
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
@ -42,9 +44,14 @@ logging:
|
||||||
phi:
|
phi:
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
handlers: [console, file]
|
handlers: [console, file]
|
||||||
|
asyncio:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [console, file]
|
||||||
|
propagate: yes
|
||||||
aiohttp:
|
aiohttp:
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
handlers: [console, file]
|
handlers: [console, file]
|
||||||
ldap3:
|
propagate: yes
|
||||||
level: WARNING
|
bonsai:
|
||||||
|
level: DEBUG
|
||||||
handlers: [console, file]
|
handlers: [console, file]
|
||||||
|
|
361
integration_tests/test_model.py
Normal file
361
integration_tests/test_model.py
Normal file
|
@ -0,0 +1,361 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from async_generator import asynccontextmanager
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from phi.async_ldap.model import (
|
||||||
|
Hackers,
|
||||||
|
User,
|
||||||
|
Robots,
|
||||||
|
Service,
|
||||||
|
Group,
|
||||||
|
Roles,
|
||||||
|
)
|
||||||
|
from phi.async_ldap.mixins import build_heritage
|
||||||
|
from phi.async_ldap.client import AsyncClient
|
||||||
|
import phi.exceptions as e
|
||||||
|
|
||||||
|
BASE_DN = "dc=unit,dc=macaomilano,dc=org"
|
||||||
|
|
||||||
|
cl = AsyncClient(
|
||||||
|
"ldap://localhost",
|
||||||
|
port=389,
|
||||||
|
encryption=True,
|
||||||
|
# validate=True,
|
||||||
|
ca_cert="../openldap/cert.pem",
|
||||||
|
username="root",
|
||||||
|
password="root",
|
||||||
|
base_dn=BASE_DN,
|
||||||
|
attribute_id="cn",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def clean_db():
|
||||||
|
h = Hackers(cl)
|
||||||
|
r = Robots(cl)
|
||||||
|
c = Roles(cl)
|
||||||
|
h.delete_cascade = True
|
||||||
|
r.delete_cascade = True
|
||||||
|
c.delete_cascade = True
|
||||||
|
await h.delete()
|
||||||
|
await r.delete()
|
||||||
|
await c.delete()
|
||||||
|
yield
|
||||||
|
await h.delete()
|
||||||
|
await r.delete()
|
||||||
|
await c.delete()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_achilles():
|
||||||
|
u = User(cl, "achilles")
|
||||||
|
u["cn"] = "Achilles"
|
||||||
|
u["sn"] = "achilles"
|
||||||
|
u["mail"] = "achilles@phthia.gr"
|
||||||
|
u["userPassword"] = "Patroclus123"
|
||||||
|
|
||||||
|
await u.save()
|
||||||
|
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
async def init_patroclus():
|
||||||
|
u = User(cl, "patroclus")
|
||||||
|
u["cn"] = "Patroclus"
|
||||||
|
u["sn"] = "patroclus"
|
||||||
|
u["mail"] = "patroclus@phthia.gr"
|
||||||
|
u["userPassword"] = "WannabeAnHero"
|
||||||
|
|
||||||
|
await u.save()
|
||||||
|
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
async def init_athena():
|
||||||
|
s = Service(cl, "athena")
|
||||||
|
s["userPassword"] = "ἁ θεονόα"
|
||||||
|
|
||||||
|
await s.save()
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def init_group(group_name, members):
|
||||||
|
g = Group(cl, group_name, member=members)
|
||||||
|
|
||||||
|
await g.save()
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_init():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
|
||||||
|
h = Hackers(cl)
|
||||||
|
|
||||||
|
res = await h.search("achilles")
|
||||||
|
|
||||||
|
assert u == res
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_exists():
|
||||||
|
async with clean_db():
|
||||||
|
u1 = await init_achilles()
|
||||||
|
u2 = User(cl, "enea")
|
||||||
|
|
||||||
|
assert await u1.exists()
|
||||||
|
assert not await u2.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_double_save_raises():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
# Read all the data from the db
|
||||||
|
await u.sync()
|
||||||
|
|
||||||
|
with pytest.raises(e.PhiEntryExists) as ex:
|
||||||
|
await u.save()
|
||||||
|
|
||||||
|
assert u.dn in str(ex.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_describe():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
|
||||||
|
res = await u.describe()
|
||||||
|
|
||||||
|
assert res == {
|
||||||
|
"uid": "achilles",
|
||||||
|
"cn": "Achilles",
|
||||||
|
"sn": "achilles",
|
||||||
|
"dn": f"uid=achilles,ou=Hackers,{BASE_DN}",
|
||||||
|
"mail": "achilles@phthia.gr",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_modify():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
NEW_EMAIL = "a@myrmidons.mil"
|
||||||
|
u["mail"] = NEW_EMAIL
|
||||||
|
await u.modify()
|
||||||
|
|
||||||
|
h = Hackers(cl)
|
||||||
|
|
||||||
|
res = await h.search("achilles")
|
||||||
|
await u.sync()
|
||||||
|
|
||||||
|
assert u["mail"] == res["mail"] == NEW_EMAIL
|
||||||
|
for attr in u.ldap_attributes:
|
||||||
|
assert u[attr] == res[attr]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_modify_raises():
|
||||||
|
"""Modifying a not-yet-existing user raises."""
|
||||||
|
async with clean_db():
|
||||||
|
u = User(cl, "enea")
|
||||||
|
|
||||||
|
with pytest.raises(e.PhiEntryDoesNotExist) as ex:
|
||||||
|
await u.modify()
|
||||||
|
|
||||||
|
assert u.dn in str(ex.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_delete():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
await u.delete()
|
||||||
|
|
||||||
|
h = Hackers(cl)
|
||||||
|
|
||||||
|
with pytest.raises(e.PhiEntryDoesNotExist) as ex:
|
||||||
|
await h.search("achilles")
|
||||||
|
|
||||||
|
assert u.dn in str(ex.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Service_init():
|
||||||
|
async with clean_db():
|
||||||
|
s = await init_athena()
|
||||||
|
|
||||||
|
r = Robots(cl)
|
||||||
|
|
||||||
|
res = await r.search("athena")
|
||||||
|
|
||||||
|
assert s == res
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Service_describe():
|
||||||
|
async with clean_db():
|
||||||
|
s = await init_athena()
|
||||||
|
|
||||||
|
res = await s.describe()
|
||||||
|
|
||||||
|
assert res == {
|
||||||
|
"ou": "Robots",
|
||||||
|
"uid": "athena",
|
||||||
|
"dn": f"uid=athena,ou=Robots,{BASE_DN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Group_init():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
g = await init_group("achaeans", [u])
|
||||||
|
|
||||||
|
c = Roles(cl)
|
||||||
|
|
||||||
|
res = await c.search("achaeans")
|
||||||
|
|
||||||
|
assert g == res
|
||||||
|
assert [u] == [a async for a in g.get_members()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Group_describe():
|
||||||
|
async with clean_db():
|
||||||
|
u1 = await init_achilles()
|
||||||
|
u2 = await init_patroclus()
|
||||||
|
g = await init_group("achaeans", [u1, u2])
|
||||||
|
|
||||||
|
res = await g.describe()
|
||||||
|
|
||||||
|
assert res == {
|
||||||
|
"ou": "Roles",
|
||||||
|
"cn": "achaeans",
|
||||||
|
"dn": f"cn=achaeans,ou=Roles,{BASE_DN}",
|
||||||
|
"member": [u1.dn, u2.dn],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Group_add_member():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
a = await init_athena()
|
||||||
|
g1 = await init_group("achaeans", [u])
|
||||||
|
g2 = await init_group("gods", [u])
|
||||||
|
|
||||||
|
await g2.add_member(a)
|
||||||
|
|
||||||
|
m1 = [m async for m in g1.get_members()]
|
||||||
|
m2 = [m async for m in g2.get_members()]
|
||||||
|
|
||||||
|
assert u in m1
|
||||||
|
assert u in m2
|
||||||
|
assert a not in m1
|
||||||
|
assert a in m2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_Group_remove_member():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
a = await init_athena()
|
||||||
|
g = await init_group("achaeans", [u, a])
|
||||||
|
|
||||||
|
m = [a async for a in g.get_members()]
|
||||||
|
|
||||||
|
assert u in m
|
||||||
|
assert a in m
|
||||||
|
|
||||||
|
await g.remove_member(a)
|
||||||
|
|
||||||
|
assert [u] == [el async for el in g.get_members()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_User_groups():
|
||||||
|
async with clean_db():
|
||||||
|
u = await init_achilles()
|
||||||
|
a = await init_athena()
|
||||||
|
g1 = await init_group("achaeans", [u])
|
||||||
|
g2 = await init_group("gods", [u, a])
|
||||||
|
|
||||||
|
res1 = await u.groups()
|
||||||
|
res2 = await a.groups()
|
||||||
|
|
||||||
|
assert g1 in res1
|
||||||
|
assert g2 in res1
|
||||||
|
assert g1 not in res2
|
||||||
|
assert g2 in res2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_OU_delete_raises():
|
||||||
|
async with clean_db():
|
||||||
|
u1 = await init_achilles()
|
||||||
|
u2 = await init_patroclus()
|
||||||
|
a = await init_athena()
|
||||||
|
g1 = await init_group("achaeans", [u1, u2])
|
||||||
|
g2 = await init_group("gods", [a, u1])
|
||||||
|
h = Hackers(cl)
|
||||||
|
_saved_val = h.delete_cascade
|
||||||
|
h.delete_cascade = False
|
||||||
|
|
||||||
|
assert not h.delete_cascade
|
||||||
|
|
||||||
|
with pytest.raises(e.PhiCannotExecute) as ex:
|
||||||
|
await h.delete()
|
||||||
|
|
||||||
|
assert "delete_cascade is not set" in str(ex.value)
|
||||||
|
|
||||||
|
h.delete_cascade = _saved_val
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test
|
||||||
|
async def test_OU_delete_cascade():
|
||||||
|
async with clean_db():
|
||||||
|
u1 = await init_achilles()
|
||||||
|
u2 = await init_patroclus()
|
||||||
|
a = await init_athena()
|
||||||
|
g1 = await init_group("achaeans", [u1, u2])
|
||||||
|
g2 = await init_group("gods", [a, u1])
|
||||||
|
h = Hackers(cl)
|
||||||
|
_saved_val = h.delete_cascade
|
||||||
|
h.delete_cascade = True
|
||||||
|
|
||||||
|
assert h.delete_cascade
|
||||||
|
|
||||||
|
await h.delete()
|
||||||
|
g2_members = [e async for e in g2.get_members()]
|
||||||
|
h_members = [e async for e in h]
|
||||||
|
|
||||||
|
assert not await u1.exists()
|
||||||
|
assert not await u2.exists()
|
||||||
|
assert h_members == []
|
||||||
|
assert not await g1.exists()
|
||||||
|
assert u1 not in g2_members
|
||||||
|
assert a in g2_members
|
||||||
|
|
||||||
|
h.delete_cascade = _saved_val
|
|
@ -1,5 +1,7 @@
|
||||||
FROM alpine:3.7
|
FROM alpine:3.7
|
||||||
|
|
||||||
|
ENV LDAPTLS_REQCERT=never
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openldap \
|
openldap \
|
||||||
openldap-back-mdb \
|
openldap-back-mdb \
|
||||||
|
|
|
@ -16,6 +16,8 @@ gen-cert:
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
|
docker rm $(CONTAINER) || true
|
||||||
|
docker rmi unit/slapd
|
||||||
rm -f key.pem cert.pem
|
rm -f key.pem cert.pem
|
||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
@ -35,6 +37,10 @@ prepare:
|
||||||
run-bg:
|
run-bg:
|
||||||
make prepare
|
make prepare
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs:
|
||||||
|
docker logs -f phi_slapd
|
||||||
|
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
stop: is-running
|
stop: is-running
|
||||||
docker stop $(CONTAINER)
|
docker stop $(CONTAINER)
|
||||||
|
|
|
@ -11,30 +11,93 @@ objectClass: organizationalUnit
|
||||||
objectClass: top
|
objectClass: top
|
||||||
ou: Hackers
|
ou: Hackers
|
||||||
|
|
||||||
dn: ou=Services,dc=unit,dc=macaomilano,dc=org
|
dn: ou=Robots,dc=unit,dc=macaomilano,dc=org
|
||||||
objectClass: top
|
objectClass: top
|
||||||
objectClass: organizationalUnit
|
objectClass: organizationalUnit
|
||||||
ou: Services
|
ou: Robots
|
||||||
|
|
||||||
dn: ou=Groups,dc=unit,dc=macaomilano,dc=org
|
dn: ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
objectClass: top
|
objectClass: top
|
||||||
objectClass: organizationalUnit
|
objectClass: organizationalUnit
|
||||||
ou: Groups
|
ou: Roles
|
||||||
|
|
||||||
dn: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
|
dn: uid=phi,ou=Robots,dc=unit,dc=macaomilano,dc=org
|
||||||
objectClass: account
|
objectClass: account
|
||||||
objectClass: simpleSecurityObject
|
objectClass: simpleSecurityObject
|
||||||
objectClass: top
|
objectClass: top
|
||||||
uid: phi
|
uid: phi
|
||||||
userPassword: {SHA}REu9CtcqSaA1c5J+sEYlTgg0H+M=
|
userPassword: {SHA}REu9CtcqSaA1c5J+sEYlTgg0H+M=
|
||||||
|
|
||||||
|
dn: uid=irc,ou=Robots,dc=unit,dc=macaomilano,dc=org
|
||||||
|
objectClass: account
|
||||||
|
objectClass: simpleSecurityObject
|
||||||
|
objectClass: top
|
||||||
|
uid: irc
|
||||||
|
userPassword: {SHA}0WvxFW9MSsesf55SOh4vnuwdkgY=
|
||||||
|
|
||||||
|
dn: uid=git,ou=Robots,dc=unit,dc=macaomilano,dc=org
|
||||||
|
objectClass: account
|
||||||
|
objectClass: simpleSecurityObject
|
||||||
|
objectClass: top
|
||||||
|
uid: git
|
||||||
|
userPassword: {SHA}RvGgvVWSovkkTKMhsSmQKga1PgM=
|
||||||
|
|
||||||
dn: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
dn: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
objectClass: inetOrgPerson
|
objectClass: inetOrgPerson
|
||||||
objectClass: organizationalPerson
|
objectClass: organizationalPerson
|
||||||
objectClass: person
|
objectClass: person
|
||||||
objectClass: top
|
objectClass: top
|
||||||
|
memberOf: cn=Admins,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
memberOf: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
cn: Raffaello
|
cn: Raffaello
|
||||||
sn: Mascetti
|
sn: Mascetti
|
||||||
mail: rmascetti@autistici.org
|
mail: rmascetti@autistici.org
|
||||||
uid: conte_mascetti
|
uid: conte_mascetti
|
||||||
userPassword: {SHA}oLY7P6V+DWaMJhix7vbMYGIfA+E=
|
userPassword: {SHA}oLY7P6V+DWaMJhix7vbMYGIfA+E=
|
||||||
|
|
||||||
|
dn: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: person
|
||||||
|
objectClass: top
|
||||||
|
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
memberOf: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
cn: Guido
|
||||||
|
sn: Necchi
|
||||||
|
mail: gnecchi@autistici.org
|
||||||
|
uid: necchi
|
||||||
|
userPassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=
|
||||||
|
|
||||||
|
dn: uid=perozzi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: person
|
||||||
|
objectClass: top
|
||||||
|
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
cn: Giorgio
|
||||||
|
sn: Perozzi
|
||||||
|
mail: gperozzi@autistici.org
|
||||||
|
uid: perozzi
|
||||||
|
userPassword: {SHA}0+CRQKqsTj1I82PHxvZ4ebbddXQ=
|
||||||
|
|
||||||
|
dn: cn=Admins,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
cn: Admins
|
||||||
|
objectClass: groupOfNames
|
||||||
|
objectClass: top
|
||||||
|
|
||||||
|
dn: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=perozzi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
cn: GitUsers
|
||||||
|
objectClass: groupOfNames
|
||||||
|
objectClass: top
|
||||||
|
|
||||||
|
dn: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
|
||||||
|
cn: IRCUsers
|
||||||
|
objectClass: groupOfNames
|
||||||
|
objectClass: top
|
||||||
|
|
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta:__legacy__"
|
|
@ -1,7 +1,11 @@
|
||||||
[aliases]
|
[aliases]
|
||||||
test=pytest
|
test=pytest
|
||||||
|
|
||||||
|
[pycodestyle]
|
||||||
|
max-line-length=88
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
|
max-line-length=88
|
||||||
exclude =
|
exclude =
|
||||||
.git,
|
.git,
|
||||||
__pycache__,
|
__pycache__,
|
||||||
|
|
43
setup.py
43
setup.py
|
@ -1,22 +1,29 @@
|
||||||
from setuptools import setup
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='phi',
|
name="phi",
|
||||||
version='0.0.1',
|
version="0.0.1",
|
||||||
|
description="Post-Human Interface",
|
||||||
description='Post-Human Interface',
|
|
||||||
# license='',
|
# license='',
|
||||||
url='https://git.abbiamoundominio.org/unit/phi',
|
url="https://git.abbiamoundominio.org/unit/phi",
|
||||||
|
author="unit",
|
||||||
author='unit',
|
author_email="unit@paranoici.org",
|
||||||
author_email='unit@paranoici.org',
|
package_dir={"": "src"},
|
||||||
|
packages=find_packages("src"),
|
||||||
package_dir={'': 'src'},
|
entry_points={
|
||||||
packages=['phi', 'phi.api', 'phi.ldap'],
|
"console_scripts": ["phid=phi.web.app:cli", "phiadm=phi.cli.adm:cli"]
|
||||||
scripts=['src/phid'],
|
},
|
||||||
|
setup_requires=["pytest-runner"],
|
||||||
setup_requires=['pytest-runner'],
|
install_requires=[
|
||||||
install_requires=['aiohttp==2.3.8', 'pyYAML', 'ldap3'],
|
"aiohttp==3.8.1",
|
||||||
tests_require=['pytest', 'pytest-aiohttp']
|
"aiohttp_session[secure]==2.11.0",
|
||||||
|
"click==8",
|
||||||
|
"pyYAML",
|
||||||
|
"bonsai==1.3.0",
|
||||||
|
"passlib==1.7.1",
|
||||||
|
"bcrypt==3.2.0",
|
||||||
|
"jinja2==3",
|
||||||
|
],
|
||||||
|
tests_require=["pytest", "pytest-aiohttp", "pytest-asyncio", "async-generator"],
|
||||||
|
python_requires=">=3.9",
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,30 +1,21 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from phi.logging import get_logger
|
from phi.logging import get_logger
|
||||||
from phi.ldap.client import Client
|
|
||||||
from phi.api.routes import api_routes
|
from phi.api.routes import api_routes
|
||||||
|
from phi.web.auth_middleware import authenticated
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def api_startup(app):
|
def api_app(store):
|
||||||
app['ldap_client'].open()
|
|
||||||
|
|
||||||
|
|
||||||
def api_shutdown(app):
|
|
||||||
app['ldap_client'].close()
|
|
||||||
|
|
||||||
|
|
||||||
def api_app(config):
|
|
||||||
log.info("Initializing API sub-app.")
|
log.info("Initializing API sub-app.")
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application(middlewares=[authenticated])
|
||||||
|
|
||||||
ldap_client = Client(**config.get('ldap', {}))
|
app["store"] = store
|
||||||
app['ldap_client'] = ldap_client
|
app["log"] = log
|
||||||
|
|
||||||
app.on_startup.append(api_startup)
|
|
||||||
app.on_shutdown.append(api_shutdown)
|
|
||||||
|
|
||||||
app.router.add_routes(api_routes)
|
app.router.add_routes(api_routes)
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,131 @@
|
||||||
from aiohttp.web import json_response, View
|
from aiohttp.web import json_response, View
|
||||||
from aiohttp.web import HTTPNotFound, HTTPUnprocessableEntity
|
from aiohttp.web import (
|
||||||
|
HTTPNotFound,
|
||||||
|
HTTPUnprocessableEntity,
|
||||||
|
HTTPServerError,
|
||||||
|
HTTPNoContent,
|
||||||
|
HTTPBadRequest,
|
||||||
|
HTTPCreated,
|
||||||
|
)
|
||||||
|
|
||||||
from phi.logging import get_logger
|
from phi.logging import get_logger
|
||||||
from phi.ldap.user import get_user_by_uid
|
# from phi.api.utils import serialize
|
||||||
from phi.api.utils import serialize
|
from phi.async_ldap.model import User
|
||||||
|
from phi.exceptions import (
|
||||||
|
PhiEntryDoesNotExist,
|
||||||
|
PhiUnexpectedRuntimeValue,
|
||||||
|
PhiCannotExecute,
|
||||||
|
)
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class User(View):
|
class UserView(View):
|
||||||
|
def get_log(self):
|
||||||
|
return self.request.app["log"]
|
||||||
|
|
||||||
|
def get_uid(self):
|
||||||
|
try:
|
||||||
|
return self.request.match_info["uid"]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPUnprocessableEntity
|
||||||
|
|
||||||
|
async def find_user(self, uid):
|
||||||
|
log = self.get_log()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await self.request.app["users"].search(uid)
|
||||||
|
log.info("Found user %s", user)
|
||||||
|
except PhiEntryDoesNotExist as e:
|
||||||
|
log.info(e)
|
||||||
|
raise HTTPNotFound
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
uid = self.request.match_info.get('uid', None)
|
log = self.get_log()
|
||||||
|
log.debug(f"{self.__class__.__name__}.get")
|
||||||
|
uid = self.get_uid()
|
||||||
|
|
||||||
if uid is None:
|
user = await self.find_user(uid)
|
||||||
return HTTPUnprocessableEntity()
|
|
||||||
|
|
||||||
client = self.request.app['ldap_client']
|
result = await user.describe()
|
||||||
user = get_user_by_uid(client, uid)
|
log.debug("Returning result %s", result)
|
||||||
|
|
||||||
if not user:
|
return json_response(result)
|
||||||
return HTTPNotFound()
|
|
||||||
|
|
||||||
return json_response(serialize(user))
|
async def post(self):
|
||||||
|
log = self.get_log()
|
||||||
|
log.debug(f"{self.__class__.__name__}.post")
|
||||||
|
uid = self.get_uid()
|
||||||
|
|
||||||
|
user = await self.find_user(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await self.request.app["users"].search(uid)
|
||||||
|
log.info("Found user %s", user)
|
||||||
|
except PhiEntryDoesNotExist as e:
|
||||||
|
log.debug(e)
|
||||||
|
raise HTTPNotFound(text=str(e))
|
||||||
|
except PhiUnexpectedRuntimeValue as e:
|
||||||
|
log.error("Exception: %s", e)
|
||||||
|
raise HTTPServerError
|
||||||
|
|
||||||
|
body = await self.request.json()
|
||||||
|
|
||||||
|
for k, v in body.items():
|
||||||
|
try:
|
||||||
|
user[k] = v
|
||||||
|
except PhiCannotExecute as e:
|
||||||
|
err = str(e)
|
||||||
|
log.error(err)
|
||||||
|
raise HTTPBadRequest(text=err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await user.modify()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Exception: %s", e)
|
||||||
|
|
||||||
|
raise HTTPNoContent
|
||||||
|
|
||||||
|
async def put(self):
|
||||||
|
log = self.get_log()
|
||||||
|
log.debug(f"{self.__class__.__name__}.put")
|
||||||
|
uid = self.get_uid()
|
||||||
|
|
||||||
|
user = User(self.request.app["ldap_client"], uid)
|
||||||
|
|
||||||
|
body = await self.request.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for k, v in body.items():
|
||||||
|
user[k] = v
|
||||||
|
|
||||||
|
log.debug(f"Saving: {user}")
|
||||||
|
await user.save()
|
||||||
|
except PhiCannotExecute as e:
|
||||||
|
err = str(e)
|
||||||
|
log.error(err)
|
||||||
|
raise HTTPBadRequest(text=err)
|
||||||
|
|
||||||
|
log.info(f"New user saved: {user}")
|
||||||
|
|
||||||
|
raise HTTPCreated
|
||||||
|
|
||||||
|
async def delete(self):
|
||||||
|
log = self.get_log()
|
||||||
|
log.debug(f"{self.__class__.__name__}.delete")
|
||||||
|
uid = self.get_uid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await self.request.app["users"].search(uid)
|
||||||
|
log.info("Found user %s", user)
|
||||||
|
await user.delete()
|
||||||
|
except PhiEntryDoesNotExist as e:
|
||||||
|
log.debug(e)
|
||||||
|
raise HTTPNotFound(text=str(e))
|
||||||
|
except PhiUnexpectedRuntimeValue as e:
|
||||||
|
log.error("Exception: %s", e)
|
||||||
|
raise HTTPServerError
|
||||||
|
|
||||||
|
raise HTTPNoContent
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
from aiohttp.web import route
|
from aiohttp.web import view
|
||||||
|
|
||||||
from phi.api.rest import User
|
|
||||||
|
|
||||||
|
from phi.api.rest import UserView
|
||||||
|
|
||||||
api_routes = [
|
api_routes = [
|
||||||
route('*', '/user', User),
|
view("/user", UserView),
|
||||||
route('*', '/user/', User),
|
view("/user/", UserView),
|
||||||
route('*', '/user/{uid}', User)
|
view("/user/{uid}", UserView),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
def serialize(d):
|
def serialize(obj):
|
||||||
return {k: (v.isoformat() if isinstance(v, datetime) else v)
|
return {
|
||||||
for k, v in d.items()}
|
k: (v.isoformat() if isinstance(v, datetime) else v)
|
||||||
|
for k, v in dict(obj).items()
|
||||||
|
}
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
from asyncio import get_event_loop
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from phi.api.app import api_app
|
|
||||||
|
|
||||||
|
|
||||||
def setup_app(config):
|
|
||||||
loop = get_event_loop()
|
|
||||||
|
|
||||||
app = web.Application(loop=loop)
|
|
||||||
app['config'] = config
|
|
||||||
|
|
||||||
api = api_app(config)
|
|
||||||
app.add_subapp('/api', api)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def run_app(app):
|
|
||||||
web.run_app(app,
|
|
||||||
host=app['config']['core']['listen'].get('host', '127.0.0.1'),
|
|
||||||
port=app['config']['core']['listen'].get('port', '8080'))
|
|
135
src/phi/async_ldap/client.py
Normal file
135
src/phi/async_ldap/client.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from bonsai import LDAPClient
|
||||||
|
|
||||||
|
from phi.logging import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_host(host):
|
||||||
|
"""
|
||||||
|
Helper function to decompose the host in the address
|
||||||
|
and the (optional) protocol and the (optional) port.
|
||||||
|
If missing, protocol defaults to "ldap" and port to 389,
|
||||||
|
in case protocol is missing or is "ldap", or 636, in case
|
||||||
|
protocol is "ldaps".
|
||||||
|
"""
|
||||||
|
if "://" not in host:
|
||||||
|
host = f"//{host}"
|
||||||
|
|
||||||
|
p = urlparse(host)
|
||||||
|
if p.scheme is not None and p.scheme != "":
|
||||||
|
proto = p.scheme
|
||||||
|
else:
|
||||||
|
proto = "ldap"
|
||||||
|
|
||||||
|
if p.port is not None:
|
||||||
|
port = p.port
|
||||||
|
else:
|
||||||
|
port = None
|
||||||
|
|
||||||
|
if port is not None:
|
||||||
|
addr = p.netloc.split(":")[0]
|
||||||
|
else:
|
||||||
|
addr = p.netloc
|
||||||
|
if proto == "ldap":
|
||||||
|
port = 389
|
||||||
|
elif proto == "ldaps":
|
||||||
|
port = 636
|
||||||
|
|
||||||
|
return proto, addr, port
|
||||||
|
|
||||||
|
|
||||||
|
def checked_port(provided, auto):
|
||||||
|
"""
|
||||||
|
Check consistency of ports given via the connection string
|
||||||
|
and the explicit parameter.
|
||||||
|
"""
|
||||||
|
_provided = provided is not None
|
||||||
|
|
||||||
|
if _provided and provided != auto:
|
||||||
|
log.warning(
|
||||||
|
"Explicitly provided port ({}) does not match "
|
||||||
|
"the automatically provided one ({}). The former prevails.".format(
|
||||||
|
provided, auto
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return provided
|
||||||
|
|
||||||
|
if _provided:
|
||||||
|
return provided
|
||||||
|
|
||||||
|
return auto
|
||||||
|
|
||||||
|
|
||||||
|
def compose_dn_username(username, base_dn, ou=None, attribute_id=None):
|
||||||
|
"""
|
||||||
|
Output the distinguished name of the user to use as login.
|
||||||
|
"""
|
||||||
|
if base_dn in username:
|
||||||
|
return username
|
||||||
|
|
||||||
|
if ou is None:
|
||||||
|
return f"{attribute_id}={username},{base_dn}"
|
||||||
|
|
||||||
|
return f"{attribute_id}={username},ou={ou},{base_dn}"
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncClient(LDAPClient):
|
||||||
|
"""
|
||||||
|
Wrapper around LDAPClient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host=None,
|
||||||
|
port=None,
|
||||||
|
encryption=None,
|
||||||
|
ciphers=None,
|
||||||
|
validate=False,
|
||||||
|
ca_cert=None,
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
base_dn=None,
|
||||||
|
attribute_id="uid",
|
||||||
|
ou=None,
|
||||||
|
method="SIMPLE",
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.proto, self.host, _port = parse_host(host)
|
||||||
|
self.port = checked_port(port, _port)
|
||||||
|
self.full_uri = "{}://{}:{}".format(self.proto, self.host, self.port)
|
||||||
|
self.base_dn = base_dn
|
||||||
|
|
||||||
|
if encryption:
|
||||||
|
self._tls = True
|
||||||
|
else:
|
||||||
|
if self.proto == "ldaps":
|
||||||
|
raise ValueError(
|
||||||
|
'Incompatible provided protocol ("%s") and encryption configuration: TLS=%s',
|
||||||
|
self.proto,
|
||||||
|
encryption,
|
||||||
|
)
|
||||||
|
self._tls = False
|
||||||
|
|
||||||
|
super().__init__(self.full_uri, self._tls)
|
||||||
|
log.info(
|
||||||
|
"Connected at %s (TLS -> %s)", self.full_uri, "ON" if self.tls else "OFF"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate:
|
||||||
|
self.set_cert_policy("never")
|
||||||
|
|
||||||
|
if ca_cert is not None:
|
||||||
|
self.set_ca_cert(ca_cert)
|
||||||
|
|
||||||
|
self.username = compose_dn_username(username, self.base_dn, ou, attribute_id)
|
||||||
|
self.password = password
|
||||||
|
self.method = method
|
||||||
|
|
||||||
|
self.set_auto_page_acquire(True)
|
||||||
|
self.set_credentials(self.method, user=self.username, password=self.password)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"AsyncClient[{self.full_uri}]<{self.username}>"
|
344
src/phi/async_ldap/mixins.py
Normal file
344
src/phi/async_ldap/mixins.py
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
# -*- 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
|
140
src/phi/async_ldap/model.py
Normal file
140
src/phi/async_ldap/model.py
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
|
from bonsai import LDAPEntry
|
||||||
|
from multidict import MultiDict
|
||||||
|
|
||||||
|
from phi.async_ldap import mixins
|
||||||
|
from phi.exceptions import (
|
||||||
|
PhiUnexpectedRuntimeValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dn(dn):
|
||||||
|
return MultiDict(e.split("=") for e in dn.split(","))
|
||||||
|
|
||||||
|
|
||||||
|
def get_dn(obj):
|
||||||
|
if isinstance(obj, mixins.Entry):
|
||||||
|
return obj.dn
|
||||||
|
elif isinstance(obj, LDAPEntry):
|
||||||
|
return obj["dn"]
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
return obj
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unacceptable input: {obj}")
|
||||||
|
|
||||||
|
|
||||||
|
class User(mixins.Member, mixins.Entry, mixins.Singleton):
|
||||||
|
object_class = [
|
||||||
|
"inetOrgPerson",
|
||||||
|
"simpleSecurityObject",
|
||||||
|
"organizationalPerson",
|
||||||
|
"person",
|
||||||
|
"top",
|
||||||
|
]
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
id_tag = "uid"
|
||||||
|
ou = "Hackers"
|
||||||
|
ldap_attributes = ["uid", "cn", "sn", "mail", "userPassword"]
|
||||||
|
|
||||||
|
async def iter_groups(self): # To be monkeypatched later
|
||||||
|
pass # pragma: no cover
|
||||||
|
|
||||||
|
async def groups(self):
|
||||||
|
return [g async for g in self.iter_groups()]
|
||||||
|
|
||||||
|
async def delete(self):
|
||||||
|
async for group in self.iter_groups():
|
||||||
|
await group.remove_member(self)
|
||||||
|
await super().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Hackers(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
ou = "Hackers"
|
||||||
|
child_class = User
|
||||||
|
|
||||||
|
|
||||||
|
class Service(mixins.Member, mixins.Entry, mixins.Singleton):
|
||||||
|
object_class = ["simpleSecurityObject", "account", "top"]
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
id_tag = "uid"
|
||||||
|
ou = "Robots"
|
||||||
|
ldap_attributes = ["uid", "ou", "userPassword"]
|
||||||
|
|
||||||
|
async def iter_groups(self): # To be monkeypatched later
|
||||||
|
raise NotImplemented
|
||||||
|
|
||||||
|
async def groups(self):
|
||||||
|
return [g async for g in self.iter_groups()]
|
||||||
|
|
||||||
|
async def delete(self):
|
||||||
|
async for group in self.iter_groups():
|
||||||
|
await group.remove_member(self)
|
||||||
|
await super().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Robots(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
ou = "Robots"
|
||||||
|
child_class = Service
|
||||||
|
|
||||||
|
|
||||||
|
class Group(mixins.Member, mixins.Entry, mixins.Singleton):
|
||||||
|
object_class = ["groupOfNames", "top"]
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
id_tag = "cn"
|
||||||
|
ou = "Roles"
|
||||||
|
ldap_attributes = ["cn", "ou", "member"]
|
||||||
|
memeber_classes = {"Hackers": User, "Robots": Service}
|
||||||
|
empty = False
|
||||||
|
|
||||||
|
async def add_member(self, member):
|
||||||
|
member_dn = get_dn(member)
|
||||||
|
self._entry["member"].append(member_dn)
|
||||||
|
await self.modify()
|
||||||
|
|
||||||
|
async def remove_member(self, member):
|
||||||
|
new_group_members = [get_dn(m) async for m in self.get_members() if member != m]
|
||||||
|
if len(new_group_members) == 0:
|
||||||
|
await self.delete()
|
||||||
|
self.empty = True
|
||||||
|
else:
|
||||||
|
self._entry["member"] = new_group_members
|
||||||
|
await self.modify()
|
||||||
|
|
||||||
|
async def get_members(self):
|
||||||
|
await self.sync()
|
||||||
|
for member in self._entry.get("member", []):
|
||||||
|
dn = parse_dn(member)
|
||||||
|
yield self.memeber_classes.get(dn["ou"])(self.client, dn["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
class Roles(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
|
||||||
|
_instances = dict() # type: ignore
|
||||||
|
ou = "Roles"
|
||||||
|
child_class = Group
|
||||||
|
|
||||||
|
|
||||||
|
# We define this async method here **after** `User`, `Service` and `Group` have been
|
||||||
|
# defined, in order to avoid definition loops that would prevent the code from running.
|
||||||
|
# Indeed, this function explicitely uses `Group` but is needed as a `User` and `Service`
|
||||||
|
# method. In turn, `Group` definition relies on both `User` and `Service` being yet
|
||||||
|
# defined.
|
||||||
|
async def iter_groups(self):
|
||||||
|
async with self.client.connect(is_async=True) as conn:
|
||||||
|
res = await conn.search(f"{self.dn}", 2, attrlist=["memberOf"])
|
||||||
|
if not res or len(res) == 0:
|
||||||
|
return
|
||||||
|
elif len(res) == 1:
|
||||||
|
for group in res[0].get("memberOf", []):
|
||||||
|
yield Group(self.client, parse_dn(get_dn(group))["cn"])
|
||||||
|
else:
|
||||||
|
raise PhiUnexpectedRuntimeValue(
|
||||||
|
"return value should be no more than one", res
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Monkeypatch
|
||||||
|
User.iter_groups = iter_groups # type: ignore
|
||||||
|
Service.iter_groups = iter_groups # type: ignore
|
0
src/phi/cli/__init__.py
Normal file
0
src/phi/cli/__init__.py
Normal file
119
src/phi/cli/adm.py
Normal file
119
src/phi/cli/adm.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
import importlib.resources
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
import re
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from phi.cli.utils import generate_from_templates
|
||||||
|
from phi.security import hash_pass
|
||||||
|
|
||||||
|
|
||||||
|
SERVICE_RE = re.compile(r"^(.+?):(.+)$")
|
||||||
|
|
||||||
|
templates = [
|
||||||
|
f.name.strip(".j2")
|
||||||
|
for f in importlib.resources.files("phi.cli.templates").iterdir()
|
||||||
|
if not f.name.endswith(".py")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def debug(ctx, out):
|
||||||
|
if ctx.obj["debug"]:
|
||||||
|
click.echo(f"DEBUG: {out}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_services(ctx, _, value):
|
||||||
|
for service in value:
|
||||||
|
if not SERVICE_RE.match(service):
|
||||||
|
click.echo(f"Unparsable service '{service}' - must match /^(.+?):(.+)$/")
|
||||||
|
ctx.exit()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(
|
||||||
|
name="phiadm", help="This cli may be used to interact with a local phid instance"
|
||||||
|
)
|
||||||
|
@click.option("-d", "--debug", is_flag=True, help="Enable debugging information")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, debug):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["debug"] = debug
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(
|
||||||
|
"generate",
|
||||||
|
help="The base name of this LDAP directory (e.g. 'dc=example,dc=com')",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-t",
|
||||||
|
"--template",
|
||||||
|
type=click.STRING,
|
||||||
|
multiple=True,
|
||||||
|
help=f"Name of the template (allowed: {', '.join(templates)};"
|
||||||
|
+ " accepts also glob-like patterns)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-s",
|
||||||
|
"--default-service",
|
||||||
|
type=click.STRING,
|
||||||
|
multiple=True,
|
||||||
|
callback=validate_services,
|
||||||
|
help="A pair <name>:<password> representing a service (and the associated password)"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--root-password",
|
||||||
|
type=click.STRING,
|
||||||
|
required=True,
|
||||||
|
help="The cleartext password for the root user",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--phi-password",
|
||||||
|
type=click.STRING,
|
||||||
|
required=True,
|
||||||
|
help="The cleartext password for the phi service",
|
||||||
|
)
|
||||||
|
@click.argument(
|
||||||
|
"base_dn",
|
||||||
|
type=click.STRING,
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def generate(ctx, template, default_service, root_password, phi_password, base_dn):
|
||||||
|
config = get_config(base_dn, phi_password, root_password)
|
||||||
|
debug(ctx, f"default_service: {default_service}")
|
||||||
|
if default_service:
|
||||||
|
add_default_services(config, default_service)
|
||||||
|
debug(ctx, f"config: {config}")
|
||||||
|
debug(ctx, f"templates: {templates}")
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
template = templates
|
||||||
|
|
||||||
|
for name, content in generate_from_templates(config):
|
||||||
|
debug(ctx, f"current template: {name}")
|
||||||
|
if any(fnmatch(name, t) for t in template):
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(base_dn, phi_password, root_password):
|
||||||
|
config = {"default_services": []}
|
||||||
|
config["base_dn"] = base_dn
|
||||||
|
config["phi_password"] = hash_pass(phi_password)
|
||||||
|
config["root_password"] = hash_pass(root_password)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_services(config, services):
|
||||||
|
for service in services:
|
||||||
|
user, password = parse_service(service)
|
||||||
|
config["default_services"].append(
|
||||||
|
{"name": user, "password": hash_pass(password)})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_service(service):
|
||||||
|
res = SERVICE_RE.search(service).groups()
|
||||||
|
if len(res) != 2:
|
||||||
|
raise ValueError(res)
|
||||||
|
return res[0], res[1]
|
23
src/phi/cli/templates/00-base.ldif.j2
Normal file
23
src/phi/cli/templates/00-base.ldif.j2
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
dn: {{ base_dn }}
|
||||||
|
objectClass: organization
|
||||||
|
objectClass: dcObject
|
||||||
|
dc: unit
|
||||||
|
o: Unit
|
||||||
|
|
||||||
|
dn: ou=Hackers,{{ base_dn }}
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
objectClass: top
|
||||||
|
ou: Hackers
|
||||||
|
|
||||||
|
dn: ou=Robots,{{ base_dn }}
|
||||||
|
objectClass: top
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: Robots
|
||||||
|
|
||||||
|
dn: ou=Roles,{{ base_dn }}
|
||||||
|
objectClass: top
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: Roles
|
||||||
|
|
17
src/phi/cli/templates/10-users.ldif.j2
Normal file
17
src/phi/cli/templates/10-users.ldif.j2
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
dn: uid=phi,ou=Robots,{{ base_dn }}
|
||||||
|
objectClass: account
|
||||||
|
objectClass: simpleSecurityObject
|
||||||
|
objectClass: top
|
||||||
|
uid: phi
|
||||||
|
userPassword: {{ phi_password }}
|
||||||
|
{%- for service in default_services %}
|
||||||
|
|
||||||
|
dn: uid={{ service.name }},ou=Robots,{{ base_dn }}
|
||||||
|
objectClass: account
|
||||||
|
objectClass: simpleSecurityObject
|
||||||
|
objectClass: top
|
||||||
|
uid={{ service.name }}
|
||||||
|
userPassword={{ service.password }}
|
||||||
|
{%- endfor %}
|
6
src/phi/cli/templates/20-roles.ldif.j2
Normal file
6
src/phi/cli/templates/20-roles.ldif.j2
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
dn: cn=Admins,ou=Roles,{{ base_dn }}
|
||||||
|
cn: Admins
|
||||||
|
objectClass: groupOfNames
|
||||||
|
objectClass: top
|
14
src/phi/cli/templates/99-acl.ldif.j2
Normal file
14
src/phi/cli/templates/99-acl.ldif.j2
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
access to dn.base=""
|
||||||
|
by * read
|
||||||
|
by group.exact="cn=Admins,{{ base_dn }}" manage
|
||||||
|
|
||||||
|
access to attrs=entry
|
||||||
|
by * read
|
||||||
|
|
||||||
|
access to attrs=userPassword
|
||||||
|
by self write
|
||||||
|
by anonymous auth
|
||||||
|
|
||||||
|
access to dn.subtree="ou=Hackers,{{ base_dn }}"
|
||||||
|
by self write
|
||||||
|
by dn.subtree="ou=Services,{{ base_dn }}" read
|
0
src/phi/cli/templates/__init__.py
Normal file
0
src/phi/cli/templates/__init__.py
Normal file
87
src/phi/cli/templates/slapd.conf.j2
Normal file
87
src/phi/cli/templates/slapd.conf.j2
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
#######################################################################
|
||||||
|
# Modules
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
include /etc/openldap/schema/core.schema
|
||||||
|
include /etc/openldap/schema/cosine.schema
|
||||||
|
include /etc/openldap/schema/corba.schema
|
||||||
|
include /etc/openldap/schema/inetorgperson.schema
|
||||||
|
include /etc/openldap/schema/nis.schema
|
||||||
|
include /etc/openldap/schema/collective.schema
|
||||||
|
include /etc/openldap/schema/openldap.schema
|
||||||
|
|
||||||
|
modulepath /usr/lib/openldap
|
||||||
|
moduleload back_mdb
|
||||||
|
moduleload refint
|
||||||
|
moduleload memberof
|
||||||
|
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Core
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
pidfile /var/slapd/slapd.pid
|
||||||
|
argsfile /var/slapd/slapd.args
|
||||||
|
loglevel conns
|
||||||
|
|
||||||
|
serverID 0
|
||||||
|
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Security
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
#TLSCACertificateFile /var/slapd/fullchain.pem
|
||||||
|
TLSCertificateFile /var/slapd/cert.pem
|
||||||
|
TLSCertificateKeyFile /var/slapd/key.pem
|
||||||
|
TLSCipherSuite HIGH
|
||||||
|
|
||||||
|
# Sample security restrictions
|
||||||
|
# Define global ACLs to disable default read access.
|
||||||
|
# Require integrity protection (prevent hijacking)
|
||||||
|
# Require 112-bit (3DES or better) encryption for updates
|
||||||
|
# Require 63-bit encryption for simple bind
|
||||||
|
security ssf=1 simple_bind=256 update_ssf=256
|
||||||
|
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# MDB database definitions
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
database mdb
|
||||||
|
maxsize 1073741824
|
||||||
|
suffix "{{ base_dn }}"
|
||||||
|
|
||||||
|
# Overlays to be loaded for the database.
|
||||||
|
overlay memberof
|
||||||
|
|
||||||
|
# Cleartext passwords, especially for the rootdn, should
|
||||||
|
# be avoid. See slappasswd(8) and slapd.conf(5) for details.
|
||||||
|
# Use of strong authentication encouraged.
|
||||||
|
rootdn "cn=root,{{ base_dn }}"
|
||||||
|
rootpw {{ root_password }}
|
||||||
|
|
||||||
|
# The database directory MUST exist prior to running slapd AND
|
||||||
|
# should only be accessible by the slapd and slap tools.
|
||||||
|
# Mode 700 recommended.
|
||||||
|
directory /var/slapd
|
||||||
|
mode 0700
|
||||||
|
|
||||||
|
password-hash {CRYPT}
|
||||||
|
password-crypt-salt-format "$6$%.16s"
|
||||||
|
|
||||||
|
# Indices to maintain
|
||||||
|
index objectClass pres,eq
|
||||||
|
index uid,cn,sn,mail eq,sub
|
||||||
|
index memberof pres,eq
|
||||||
|
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# MemberOf configuration
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
memberof-group-oc groupOfNames
|
||||||
|
memberof-memberof-ad memberOf
|
||||||
|
memberof-member-ad member
|
||||||
|
memberof-dangling error
|
||||||
|
memberof-refint true
|
25
src/phi/cli/utils.py
Normal file
25
src/phi/cli/utils.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader
|
||||||
|
|
||||||
|
|
||||||
|
def get_jinja_env():
|
||||||
|
pkg_path_templates = os.path.realpath(os.path.join(__name__, "./templates"))
|
||||||
|
loaders = [
|
||||||
|
FileSystemLoader(pkg_path_templates),
|
||||||
|
PackageLoader("phi.cli", package_path="templates"),
|
||||||
|
]
|
||||||
|
return Environment(loader=ChoiceLoader(loaders))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_from_templates(config):
|
||||||
|
env = get_jinja_env()
|
||||||
|
|
||||||
|
for t in env.list_templates(extensions="j2"):
|
||||||
|
tmpl = env.get_template(t)
|
||||||
|
content = tmpl.render(**config)
|
||||||
|
# This is loaded from a resource and won't be None
|
||||||
|
name = tmpl.name.strip(".j2")
|
||||||
|
# name = os.path.basename(tmpl.filename).strip(".j2")
|
||||||
|
yield (name, content)
|
|
@ -1,27 +1,80 @@
|
||||||
import os.path
|
import os.path
|
||||||
|
import pkg_resources
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
NAME = "phi"
|
||||||
|
|
||||||
NAME = 'phi'
|
DEFAULT_CONFIG = {
|
||||||
|
"core": {"listen": {"host": "localhost", "port": 8080}, "cookiestore_secret": None},
|
||||||
|
"ldap": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 389,
|
||||||
|
"encryption": "TLSv1.2",
|
||||||
|
"ciphers": "HIGH",
|
||||||
|
"validate": True,
|
||||||
|
"ca_certs": pkg_resources.resource_filename(NAME, "openldap/cert.pem"),
|
||||||
|
"base_dn": None,
|
||||||
|
"attribute_mail": "mail",
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"version": 1,
|
||||||
|
"formatters": {"default": {"format": "[%(name)s %(levelname)s] %(message)s"}},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"filename": "phi.log",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"phi": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
},
|
||||||
|
"aiohttp": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
},
|
||||||
|
"bonsai": {
|
||||||
|
"level": "WARNING",
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG_FILE = 'config.yml'
|
DUMMY_CONFIG = {"core": {}, "ldap": {}, "logging": {}}
|
||||||
CONFIG_PATHS = ['./',
|
|
||||||
'~/.config/' + NAME + '/',
|
CONFIG_FILE = "config.yml"
|
||||||
'/usr/local/etc/' + NAME + '/',
|
CONFIG_PATHS = [
|
||||||
'/etc/' + NAME + '/']
|
"./",
|
||||||
CONFIG_FILES = [os.path.join(p, CONFIG_FILE)
|
"~/.config/" + NAME + "/",
|
||||||
for p in CONFIG_PATHS]
|
"/usr/local/etc/" + NAME + "/",
|
||||||
|
"/etc/" + NAME + "/",
|
||||||
|
]
|
||||||
|
CONFIG_FILES = [os.path.join(p, CONFIG_FILE) for p in CONFIG_PATHS]
|
||||||
|
|
||||||
|
|
||||||
def get_config():
|
def get_config(config_path=None):
|
||||||
"""Return the path of the found configuration file and its content
|
"""Return the path of the found configuration file and its content
|
||||||
|
|
||||||
|
:param config_path: optional path to a config file.
|
||||||
|
|
||||||
:returns: (path, config)
|
:returns: (path, config)
|
||||||
:rtype: (str, dict)
|
:rtype: (str, dict)
|
||||||
"""
|
"""
|
||||||
|
if config_path:
|
||||||
|
with open(config_path) as c:
|
||||||
|
config = yaml.safe_load(c)
|
||||||
|
return config_path, config
|
||||||
for f in CONFIG_FILES:
|
for f in CONFIG_FILES:
|
||||||
try:
|
try:
|
||||||
with open(f, 'r') as c:
|
with open(f, "r") as c:
|
||||||
config = yaml.safe_load(c)
|
config = yaml.safe_load(c)
|
||||||
return (f, config)
|
return (f, config)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
@ -30,6 +83,62 @@ def get_config():
|
||||||
# accessible or if the file is not present at all
|
# accessible or if the file is not present at all
|
||||||
# in any of CONFIG_PATHS.
|
# in any of CONFIG_PATHS.
|
||||||
pass
|
pass
|
||||||
else:
|
return None, DUMMY_CONFIG
|
||||||
raise FileNotFoundError("Could not find {} in any of {}."
|
|
||||||
.format(CONFIG_FILE, ', '.join(CONFIG_PATHS)))
|
|
||||||
|
def merge_config(cli_config, file_config):
|
||||||
|
"""
|
||||||
|
Merge the cli-provided and file-provided config.
|
||||||
|
"""
|
||||||
|
return recursive_merge(cli_config, file_config)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_with_shape_of(element):
|
||||||
|
if isinstance(element, dict):
|
||||||
|
return {}
|
||||||
|
elif isinstance(element, list):
|
||||||
|
return []
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def recursive_merge(main_config, aux_config):
|
||||||
|
def _recursive_merge(main, aux, default, key, _ret_config):
|
||||||
|
if isinstance(default, dict):
|
||||||
|
_sub_conf = {}
|
||||||
|
for k, v in default.items():
|
||||||
|
_main = main[k] if k in main else _init_with_shape_of(v)
|
||||||
|
_aux = aux[k] if k in aux else _init_with_shape_of(v)
|
||||||
|
_recursive_merge(_main, _aux, v, k, _sub_conf)
|
||||||
|
_ret_config[key] = _sub_conf
|
||||||
|
elif isinstance(default, list):
|
||||||
|
_main = main.copy()
|
||||||
|
if aux is not None:
|
||||||
|
_main.extend(aux)
|
||||||
|
_ret_config[key] = list(set(_main))
|
||||||
|
elif isinstance(default, bool):
|
||||||
|
_ret_config[key] = default and aux and main
|
||||||
|
else:
|
||||||
|
if main is not None:
|
||||||
|
_ret_config[key] = main
|
||||||
|
elif aux is not None:
|
||||||
|
_ret_config[key] = aux
|
||||||
|
else:
|
||||||
|
_ret_config[key] = default
|
||||||
|
|
||||||
|
_config = {}
|
||||||
|
_recursive_merge(main_config, aux_config, DEFAULT_CONFIG, "ROOT", _config)
|
||||||
|
|
||||||
|
return _config["ROOT"]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_secret(config):
|
||||||
|
try:
|
||||||
|
secret = config["core"]["cookiestore_secret"].encode("utf-8")
|
||||||
|
if len(secret) != 32:
|
||||||
|
raise ValueError(
|
||||||
|
"The provided core.cookiestore_secret must be 32 bytes long"
|
||||||
|
)
|
||||||
|
|
||||||
|
return secret
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError("You must provide a core.cookiestore_secret")
|
||||||
|
|
54
src/phi/exceptions.py
Normal file
54
src/phi/exceptions.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
class PhiEntryExists(Exception):
|
||||||
|
def __init__(self, dn):
|
||||||
|
self.dn = dn
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Entry exists yet ({self.dn})"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"PhiEntryExists({self.dn})"
|
||||||
|
|
||||||
|
|
||||||
|
class PhiEntryDoesNotExist(Exception):
|
||||||
|
def __init__(self, dn):
|
||||||
|
self.dn = dn
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Entry does not exist ({self.dn})"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"PhiEntryDoesNotExist({self.dn})"
|
||||||
|
|
||||||
|
|
||||||
|
class PhiAttributeMissing(Exception):
|
||||||
|
def __init__(self, dn, attr):
|
||||||
|
self.dn = dn
|
||||||
|
self.attr = attr
|
||||||
|
|
||||||
|
|
||||||
|
class PhiUnauthorized(Exception):
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
|
||||||
|
class PhiUnexpectedRuntimeValue(RuntimeWarning):
|
||||||
|
def __init__(self, msg, result):
|
||||||
|
super().__init__(self)
|
||||||
|
self.msg = msg
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
|
||||||
|
class PhiCannotExecute(RuntimeWarning):
|
||||||
|
def __init__(self, msg):
|
||||||
|
super().__init__(self)
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Cannot execute: {self.msg}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"PhiCannotExecute({self.msg})"
|
|
@ -1,59 +0,0 @@
|
||||||
from threading import Lock
|
|
||||||
from ldap3.utils.log import set_library_log_detail_level, PROTOCOL
|
|
||||||
|
|
||||||
from phi.logging import get_logger
|
|
||||||
from phi.ldap.connection import make_connection
|
|
||||||
from phi.ldap.connection import open_connection, close_connection
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
set_library_log_detail_level(PROTOCOL)
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self,
|
|
||||||
host=None, port=389,
|
|
||||||
encryption=None, ciphers=None, validate=False, ca_certs=None,
|
|
||||||
username=None, password=None,
|
|
||||||
base_dn=None,
|
|
||||||
attribute_id='uid', attribute_mail='mail'):
|
|
||||||
log.info("Initializing LDAP Client.")
|
|
||||||
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
|
|
||||||
self.encryption = encryption
|
|
||||||
self.ciphers = ciphers
|
|
||||||
self.validate = validate
|
|
||||||
self.ca_certs = ca_certs
|
|
||||||
|
|
||||||
self.username = username
|
|
||||||
self.password = password
|
|
||||||
|
|
||||||
self.base_dn = base_dn
|
|
||||||
|
|
||||||
self.attribute_id = attribute_id
|
|
||||||
self.attribute_mail = attribute_mail
|
|
||||||
|
|
||||||
self.connection_lock = Lock()
|
|
||||||
self.connection = make_connection(host=self.host, port=self.port,
|
|
||||||
encryption=self.encryption,
|
|
||||||
ciphers=self.ciphers,
|
|
||||||
validate=self.validate,
|
|
||||||
ca_certs=self.ca_certs,
|
|
||||||
username=self.username,
|
|
||||||
password=self.password)
|
|
||||||
|
|
||||||
def open(self):
|
|
||||||
self.connection_lock.acquire()
|
|
||||||
if self.connection.closed is True:
|
|
||||||
open_connection(self.connection)
|
|
||||||
self.connection_lock.release()
|
|
||||||
else:
|
|
||||||
self.connection_lock.release()
|
|
||||||
raise Exception("Trying to open a connection, "
|
|
||||||
"but it is already open.")
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.connection_lock.acquire()
|
|
||||||
close_connection(self.connection)
|
|
||||||
self.connection_lock.release()
|
|
|
@ -1,59 +0,0 @@
|
||||||
from ssl import CERT_REQUIRED, PROTOCOL_TLSv1_2
|
|
||||||
from ldap3 import Tls, Server, Connection, ASYNC
|
|
||||||
|
|
||||||
from phi.logging import get_logger
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def make_connection(host=None, port=389,
|
|
||||||
encryption=None, ciphers=None, validate=False,
|
|
||||||
ca_certs=None, username=None, password=None):
|
|
||||||
# TLSv1.2 is supported since Python 3.4
|
|
||||||
if encryption is None:
|
|
||||||
log.warning("The connection to the LDAP server will not be encrypted.")
|
|
||||||
tls = None
|
|
||||||
elif encryption == "TLSv1.2":
|
|
||||||
log.info("The connection to the LDAP server will use TLSv1.2.")
|
|
||||||
tls = Tls(version=PROTOCOL_TLSv1_2)
|
|
||||||
else:
|
|
||||||
raise NotImplementedError("Sorry, use TLSv1.2.")
|
|
||||||
|
|
||||||
if encryption is not None and ciphers is not None:
|
|
||||||
log.info("The connection to the LDAP server will use the "
|
|
||||||
"following ciphers: {}".format(ciphers))
|
|
||||||
tls.ciphers = ciphers
|
|
||||||
|
|
||||||
if encryption is not None and validate is True:
|
|
||||||
log.info("The certificate hostname will be checked to match the "
|
|
||||||
"remote hostname.")
|
|
||||||
tls.validate = CERT_REQUIRED
|
|
||||||
|
|
||||||
if encryption is not None and validate is True and ca_certs is not None:
|
|
||||||
log.info("Using the following CA certificates: {}"
|
|
||||||
.format(ca_certs))
|
|
||||||
tls.ca_certs_file = ca_certs
|
|
||||||
|
|
||||||
server = Server(host=host, port=port, tls=tls)
|
|
||||||
connection = Connection(server, user=username, password=password,
|
|
||||||
client_strategy=ASYNC)
|
|
||||||
|
|
||||||
return connection
|
|
||||||
|
|
||||||
|
|
||||||
def open_connection(connection):
|
|
||||||
log.info("Opening connection to LDAP server.")
|
|
||||||
connection.open()
|
|
||||||
|
|
||||||
if connection.server.tls is not None and connection.server.ssl is False:
|
|
||||||
log.info("Issuing StartTLS command.")
|
|
||||||
connection.start_tls()
|
|
||||||
|
|
||||||
log.info("Issuing BIND command.")
|
|
||||||
connection.bind()
|
|
||||||
|
|
||||||
|
|
||||||
def close_connection(connection):
|
|
||||||
log.info("Closing connection to LDAP server.")
|
|
||||||
log.info("Issuing UNBIND command.")
|
|
||||||
connection.unbind()
|
|
|
@ -1,36 +0,0 @@
|
||||||
from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES
|
|
||||||
|
|
||||||
from phi.logging import get_logger
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_entry_by_uid(client, uid):
|
|
||||||
log.info("Searching entry with identifier: {}".format(uid))
|
|
||||||
|
|
||||||
filter_ = "({}={})".format(client.attribute_id, uid)
|
|
||||||
log.debug("Search filter: {}".format(filter_))
|
|
||||||
|
|
||||||
response_id = client.connection.search(
|
|
||||||
client.base_dn, filter_,
|
|
||||||
search_scope='SUBTREE',
|
|
||||||
attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
|
||||||
)
|
|
||||||
|
|
||||||
response, result, request = client.connection.get_response(
|
|
||||||
response_id, get_request=True
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug("Request: {}".format(request))
|
|
||||||
log.debug("Response: {}".format(response))
|
|
||||||
log.debug("Result: {}".format(result))
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if response[1:]:
|
|
||||||
log.erorr("Looking for exactly one result but server gave {}. "
|
|
||||||
"Taking the first and ignoring the rest."
|
|
||||||
.format(len(response)))
|
|
||||||
|
|
||||||
return response[0]
|
|
|
@ -1,26 +0,0 @@
|
||||||
from phi.ldap.entry import get_entry_by_uid
|
|
||||||
from phi.ldap.utils import flatten_attributes
|
|
||||||
|
|
||||||
|
|
||||||
def user_attributes_mapping(client):
|
|
||||||
return {
|
|
||||||
client.attribute_id: 'uid',
|
|
||||||
client.attribute_mail: 'mail',
|
|
||||||
'createTimestamp': 'created_at',
|
|
||||||
'modifyTimestamp': 'modified_at'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_uid(client, uid):
|
|
||||||
entry = get_entry_by_uid(client, uid)
|
|
||||||
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mapping = user_attributes_mapping(client)
|
|
||||||
|
|
||||||
user = {mapping[k]: v
|
|
||||||
for k, v in entry['attributes'].items()
|
|
||||||
if k in mapping.keys()}
|
|
||||||
|
|
||||||
return flatten_attributes(user)
|
|
|
@ -1,3 +0,0 @@
|
||||||
def flatten_attributes(d):
|
|
||||||
return {k: (v[0] if isinstance(v, list) else v)
|
|
||||||
for k, v in d.items()}
|
|
29
src/phi/security.py
Normal file
29
src/phi/security.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
from bonsai.ldapvaluelist import LDAPValueList
|
||||||
|
from passlib.hash import (
|
||||||
|
ldap_sha1,
|
||||||
|
ldap_bcrypt,
|
||||||
|
ldap_sha256_crypt,
|
||||||
|
ldap_sha512_crypt,
|
||||||
|
ldap_pbkdf2_sha256,
|
||||||
|
ldap_pbkdf2_sha512,
|
||||||
|
)
|
||||||
|
|
||||||
|
HASH_CALLABLE = {
|
||||||
|
"sha1": ldap_sha1.hash,
|
||||||
|
"bcrypt": ldap_bcrypt.hash,
|
||||||
|
"sha256_crypt": ldap_sha256_crypt.hash,
|
||||||
|
"sha512_crypt": ldap_sha512_crypt.hash,
|
||||||
|
"pbkdf2_sha256": ldap_pbkdf2_sha256.hash,
|
||||||
|
"pbkdf2_sha512": ldap_pbkdf2_sha512.hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hash_pass(password, method="sha1"):
|
||||||
|
return HASH_CALLABLE[method](password)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_password(password, method="sha1"):
|
||||||
|
if isinstance(password, LDAPValueList):
|
||||||
|
return password
|
||||||
|
return hash_pass(password, method)
|
0
src/phi/web/__init__.py
Normal file
0
src/phi/web/__init__.py
Normal file
213
src/phi/web/app.py
Normal file
213
src/phi/web/app.py
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp_session import setup
|
||||||
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||||
|
import click
|
||||||
|
from pprint import pformat as pp
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from phi.config import get_config, merge_config, extract_secret
|
||||||
|
from phi.logging import setup_logging, get_logger
|
||||||
|
from phi.api.app import api_app
|
||||||
|
from phi.web.client_store import ClientStore
|
||||||
|
from phi.web.login import login
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_ROUTE = "/login"
|
||||||
|
COOKIE_NAME = "PHI_COOKIE"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_app(config):
|
||||||
|
app = web.Application()
|
||||||
|
setup(app, EncryptedCookieStorage(extract_secret(config), cookie_name=COOKIE_NAME))
|
||||||
|
|
||||||
|
app["config"] = config
|
||||||
|
app["store"] = ClientStore(LOGIN_ROUTE)
|
||||||
|
app["log"] = log
|
||||||
|
|
||||||
|
app.add_routes([web.post(LOGIN_ROUTE, login)])
|
||||||
|
|
||||||
|
api = api_app(app["store"])
|
||||||
|
app.add_subapp("/api", api)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_app(app):
|
||||||
|
web.run_app(
|
||||||
|
app,
|
||||||
|
host=app["config"]["core"]["listen"].get("host", "127.0.0.1"),
|
||||||
|
port=app["config"]["core"]["listen"].get("port", "8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(help="phid is the main application daemon.")
|
||||||
|
@click.option(
|
||||||
|
"--config",
|
||||||
|
"config_path",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Path to a valid config file.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-H",
|
||||||
|
"--host",
|
||||||
|
type=click.STRING,
|
||||||
|
default="localhost",
|
||||||
|
help='Address to which the application bounds. Defaults to "localhost".',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-p",
|
||||||
|
"--port",
|
||||||
|
type=click.INT,
|
||||||
|
default=8080,
|
||||||
|
help="Port to which the application bounds. Defaults to 8080.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-host",
|
||||||
|
"ldap_host",
|
||||||
|
type=click.STRING,
|
||||||
|
default="localhost",
|
||||||
|
help='Address of the LDAP server to connect to. Defaults to "localhost".',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-port",
|
||||||
|
"ldap_port",
|
||||||
|
type=click.INT,
|
||||||
|
default=389,
|
||||||
|
help="Port where is exposed the LDAP server to connect to. Defaults to 389.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-crypt",
|
||||||
|
"ldap_crypt",
|
||||||
|
is_flag=True,
|
||||||
|
default=True,
|
||||||
|
help="Connect to the LDAP server using TLSv1.2. Defaults to True.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-tls-do-not-validate",
|
||||||
|
"ldap_tls_validate",
|
||||||
|
is_flag=True,
|
||||||
|
default=True,
|
||||||
|
help="Toggle checking of TLS cert against the provided name. Defaults to True.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-tls-ca",
|
||||||
|
"ldap_tls_ca",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Toggle checking of TLS cert against the provided name. Defaults to True.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-base-dn", "ldap_base_dn", type=click.STRING, help="The LDAP base_dn to use."
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-username",
|
||||||
|
"ldap_username",
|
||||||
|
type=click.STRING,
|
||||||
|
help="The username to use to connect to the LDAP server.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ldap-password",
|
||||||
|
"ldap_password",
|
||||||
|
type=click.STRING,
|
||||||
|
help="The password to use to connect to the LDAP server. "
|
||||||
|
"THIS CAN BE READ BY OTHER PROCESSES. NEVER USE IN PRODUCTION!",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--log-conf",
|
||||||
|
"log_conf",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Path to a yaml configuration for the logger.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--debug",
|
||||||
|
"debug",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Set the log level to debug.",
|
||||||
|
)
|
||||||
|
def cli(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
config_path=None,
|
||||||
|
ldap_host=None,
|
||||||
|
ldap_port=None,
|
||||||
|
ldap_crypt=True,
|
||||||
|
ldap_tls_validate=True,
|
||||||
|
ldap_tls_ca=None,
|
||||||
|
ldap_base_dn=None,
|
||||||
|
ldap_username=None,
|
||||||
|
ldap_password=None,
|
||||||
|
log_conf=None,
|
||||||
|
debug=False,
|
||||||
|
):
|
||||||
|
cli_config = prepare_config_from_cli(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
ldap_host,
|
||||||
|
ldap_port,
|
||||||
|
ldap_crypt,
|
||||||
|
ldap_tls_validate,
|
||||||
|
ldap_tls_ca,
|
||||||
|
ldap_base_dn,
|
||||||
|
ldap_username,
|
||||||
|
ldap_password,
|
||||||
|
log_conf,
|
||||||
|
debug,
|
||||||
|
)
|
||||||
|
config_file, file_config = get_config(config_path)
|
||||||
|
log.debug(f"FILE: {pp(file_config)}")
|
||||||
|
config = merge_config(cli_config, file_config)
|
||||||
|
if debug:
|
||||||
|
set_to_debug(config)
|
||||||
|
# Beware that everything happened until now
|
||||||
|
# could not possibly get logged.
|
||||||
|
setup_logging(config.get("logging", {}))
|
||||||
|
if config_file:
|
||||||
|
log.debug("Config file found at: %s", config_file)
|
||||||
|
log.debug("{}".format(pp(file_config)))
|
||||||
|
log.debug("CLI config:\n{}".format(pp(cli_config)))
|
||||||
|
log.info("Starting app with config:\n{}".format(pp(config)))
|
||||||
|
|
||||||
|
app = setup_app(config)
|
||||||
|
run_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_config_from_cli(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
ldap_host=None,
|
||||||
|
ldap_port=None,
|
||||||
|
ldap_crypt=True,
|
||||||
|
ldap_tls_validate=True,
|
||||||
|
ldap_tls_ca=None,
|
||||||
|
ldap_base_dn=None,
|
||||||
|
ldap_username=None,
|
||||||
|
ldap_password=None,
|
||||||
|
log_conf=None,
|
||||||
|
debug=False,
|
||||||
|
):
|
||||||
|
_core = {"listen": {"host": host, "port": port}}
|
||||||
|
_ldap = {
|
||||||
|
"host": ldap_host,
|
||||||
|
"port": ldap_port,
|
||||||
|
"encryption": "TLSv1.2" if ldap_crypt else None,
|
||||||
|
"validate": ldap_tls_validate,
|
||||||
|
"ca_certs": ldap_tls_ca,
|
||||||
|
"username": ldap_username,
|
||||||
|
"password": ldap_password,
|
||||||
|
"base_dn": ldap_base_dn,
|
||||||
|
}
|
||||||
|
_logging = {}
|
||||||
|
if log_conf:
|
||||||
|
with open(log_conf) as log_conf_fd:
|
||||||
|
_logging = yaml.safe_load(log_conf_fd)
|
||||||
|
|
||||||
|
return {"core": _core, "ldap": _ldap, "logging": _logging}
|
||||||
|
|
||||||
|
|
||||||
|
def set_to_debug(conf):
|
||||||
|
for logger, log_conf in conf["logging"]["loggers"].items():
|
||||||
|
log_conf["level"] = "DEBUG"
|
||||||
|
conf["logging"]["loggers"][logger] = log_conf
|
23
src/phi/web/auth_middleware.py
Normal file
23
src/phi/web/auth_middleware.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
from aiohttp.web import middleware, HTTPFound
|
||||||
|
|
||||||
|
from phi.web import app
|
||||||
|
from phi.async_ldap.model import Hackers, Robots, Roles
|
||||||
|
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def authenticated(request, handler):
|
||||||
|
try:
|
||||||
|
store = request.app["store"]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPFound(app.LOGIN_ROUTE)
|
||||||
|
|
||||||
|
client = await store.get_client(request)
|
||||||
|
|
||||||
|
request.app["ldap_client"] = client
|
||||||
|
request.app["users"] = Hackers(client)
|
||||||
|
request.app["services"] = Robots(client)
|
||||||
|
request.app["groups"] = Roles(client)
|
||||||
|
resp = await handler(request)
|
||||||
|
|
||||||
|
return resp
|
35
src/phi/web/client_store.py
Normal file
35
src/phi/web/client_store.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from aiohttp.web import HTTPFound
|
||||||
|
from aiohttp_session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
class ClientStore(dict):
|
||||||
|
"""
|
||||||
|
This class is responsible to hold the clients used by the active connections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, login_route):
|
||||||
|
self.login_route = login_route
|
||||||
|
self.store = dict()
|
||||||
|
|
||||||
|
async def get_client(self, request):
|
||||||
|
session = await get_session(request)
|
||||||
|
client_id = session.get("client_id")
|
||||||
|
if client_id is None:
|
||||||
|
raise HTTPFound(self.login_route)
|
||||||
|
|
||||||
|
client = self.store.get(client_id)
|
||||||
|
if client is None:
|
||||||
|
raise HTTPFound(self.login_route)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def set_client(self, request, client):
|
||||||
|
session = await get_session(request)
|
||||||
|
|
||||||
|
client_id = secrets.token_hex(16)
|
||||||
|
|
||||||
|
self.store[client_id] = client
|
||||||
|
session["client_id"] = client_id
|
38
src/phi/web/login.py
Normal file
38
src/phi/web/login.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
from aiohttp.web import HTTPBadRequest, HTTPOk
|
||||||
|
|
||||||
|
from phi.async_ldap.client import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def login(request):
|
||||||
|
log = request.app["log"]
|
||||||
|
log.debug("login")
|
||||||
|
|
||||||
|
store = request.app["store"]
|
||||||
|
config = request.app["config"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
tag = body.get("tag", "uid")
|
||||||
|
|
||||||
|
ou = body.get("ou")
|
||||||
|
if ou is not None:
|
||||||
|
config["ldap"]["ou"] = ou
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = body["user"]
|
||||||
|
password = body["password"]
|
||||||
|
except KeyError as e:
|
||||||
|
text = f"Missing key: {e}"
|
||||||
|
log.warn(text)
|
||||||
|
raise HTTPBadRequest(text=text)
|
||||||
|
|
||||||
|
client = AsyncClient(
|
||||||
|
attribute_id=tag, username=user, password=password, **config.get("ldap")
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug(f"Client: {client}")
|
||||||
|
|
||||||
|
await store.set_client(request, client)
|
||||||
|
|
||||||
|
raise HTTPOk
|
22
src/phid
22
src/phid
|
@ -1,22 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
from pprint import pformat as pp
|
|
||||||
|
|
||||||
from phi.config import get_config
|
|
||||||
from phi.logging import setup_logging, get_logger
|
|
||||||
from phi.app import setup_app, run_app
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
config_file, config = get_config()
|
|
||||||
|
|
||||||
# Beware that everything happened until now
|
|
||||||
# could not possibly get logged.
|
|
||||||
setup_logging(config.get('logging', {}))
|
|
||||||
|
|
||||||
log.info("Found configuration at '{}':\n{}"
|
|
||||||
.format(config_file, pp(config)))
|
|
||||||
|
|
||||||
app = setup_app(config)
|
|
||||||
run_app(app)
|
|
159
test/aux_async_model.py
Normal file
159
test/aux_async_model.py
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pprint import pprint as pp
|
||||||
|
|
||||||
|
from phi.async_ldap.model import (
|
||||||
|
Hackers,
|
||||||
|
User,
|
||||||
|
Robots,
|
||||||
|
Service,
|
||||||
|
Group,
|
||||||
|
Roles,
|
||||||
|
)
|
||||||
|
from phi.async_ldap.mixins import build_heritage
|
||||||
|
from phi.async_ldap.client import AsyncClient
|
||||||
|
import phi.exceptions as e
|
||||||
|
|
||||||
|
|
||||||
|
async def dlv(h, cls):
|
||||||
|
return [el async for el in build_heritage(h, cls)]
|
||||||
|
|
||||||
|
|
||||||
|
cl = AsyncClient(
|
||||||
|
"ldap://localhost",
|
||||||
|
port=389,
|
||||||
|
encryption=True,
|
||||||
|
# validate=True,
|
||||||
|
ca_cert="/home/leo/Documents/coding/phi/openldap/cert.pem",
|
||||||
|
username="root",
|
||||||
|
password="root",
|
||||||
|
base_dn="dc=unit,dc=macaomilano,dc=org",
|
||||||
|
attribute_id="cn",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_children():
|
||||||
|
h = Hackers(cl)
|
||||||
|
r = Robots(cl)
|
||||||
|
g = Roles(cl)
|
||||||
|
|
||||||
|
hackers = await dlv(h, User)
|
||||||
|
robots = await dlv(r, Service)
|
||||||
|
groups = await dlv(g, Group)
|
||||||
|
|
||||||
|
return (hackers, robots, groups)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_members(group):
|
||||||
|
return [el async for el in group]
|
||||||
|
|
||||||
|
|
||||||
|
async def print_async(label, awaitable):
|
||||||
|
print(label)
|
||||||
|
result = await awaitable
|
||||||
|
pp(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def describe(obj):
|
||||||
|
return await obj.describe()
|
||||||
|
|
||||||
|
|
||||||
|
async def _await(awaitable):
|
||||||
|
return await awaitable
|
||||||
|
|
||||||
|
|
||||||
|
def sync_await(awaitable):
|
||||||
|
return asyncio.run(_await(awaitable))
|
||||||
|
|
||||||
|
|
||||||
|
h = Hackers(cl)
|
||||||
|
r = Robots(cl)
|
||||||
|
c = Roles(cl)
|
||||||
|
|
||||||
|
|
||||||
|
# asyncio.run(print_async("hackers:", describe(h)))
|
||||||
|
# asyncio.run(print_async("conte_mascetti:", describe(User(cl, "conte_mascetti"))))
|
||||||
|
# asyncio.run(print_async("robots:", describe(r)))
|
||||||
|
# asyncio.run(print_async("phi:", describe(Service(cl, "phi"))))
|
||||||
|
# asyncio.run(print_async("congregations:", describe(c)))
|
||||||
|
# asyncio.run(print_async("GitUsers:", describe(Group(cl, "GitUsers"))))
|
||||||
|
|
||||||
|
# asyncio.run(print_async("Hackers members:", get_members(h)))
|
||||||
|
# asyncio.run(print_async("Robots members:", get_members(r)))
|
||||||
|
# asyncio.run(print_async("Roles members:", get_members(c)))
|
||||||
|
|
||||||
|
#
|
||||||
|
async def add_new(obj, name, **kw):
|
||||||
|
try:
|
||||||
|
_new = obj(cl, name, **kw)
|
||||||
|
await _new.save()
|
||||||
|
except e.PhiEntryExists as err:
|
||||||
|
print(f"Failed add: {repr(err)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_search(group, name):
|
||||||
|
try:
|
||||||
|
res = await group.search(name)
|
||||||
|
print("Search result:", res)
|
||||||
|
return res
|
||||||
|
except e.PhiEntryDoesNotExist as err:
|
||||||
|
print(f"Failed search: {repr(err)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_delete(obj, cascade=None):
|
||||||
|
try:
|
||||||
|
if cascade:
|
||||||
|
obj.delete_cascade = cascade
|
||||||
|
await obj.delete()
|
||||||
|
except Exception as err:
|
||||||
|
print(f"Failed delete: {repr(err)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member(group, member):
|
||||||
|
await group.add_member(member)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_member(group, member):
|
||||||
|
await group.remove_member(member)
|
||||||
|
|
||||||
|
|
||||||
|
# asyncio.run(safe_search(h, "pippo"))
|
||||||
|
# asyncio.run(
|
||||||
|
# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.info")
|
||||||
|
# )
|
||||||
|
# asyncio.run(safe_search(h, "pippo"))
|
||||||
|
# asyncio.run(
|
||||||
|
# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.net")
|
||||||
|
# )
|
||||||
|
# asyncio.run(safe_delete(asyncio.run(safe_search(h, "pippo"))))
|
||||||
|
# asyncio.run(print_async("Hackers members:", get_members(h)))
|
||||||
|
# asyncio.run(safe_delete(h))
|
||||||
|
# asyncio.run(print_async("Hackers members:", get_members(h)))
|
||||||
|
# asyncio.run(safe_delete(h, True))
|
||||||
|
# asyncio.run(print_async("Hackers members:", get_members(h)))
|
||||||
|
|
||||||
|
# asyncio.run(safe_search(r, "phi"))
|
||||||
|
# asyncio.run(print_async("Robots members:", get_members(r)))
|
||||||
|
# asyncio.run(add_new(Service, "db", userPassword="lolpassword"))
|
||||||
|
# asyncio.run(print_async("Robots members:", get_members(r)))
|
||||||
|
|
||||||
|
asyncio.run(safe_search(c, "GitUsers"))
|
||||||
|
asyncio.run(print_async("Roles members:", get_members(c)))
|
||||||
|
asyncio.run(
|
||||||
|
add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")])
|
||||||
|
)
|
||||||
|
asyncio.run(print_async("Roles members:", get_members(c)))
|
||||||
|
asyncio.run(safe_delete(Group(cl, "naughty")))
|
||||||
|
asyncio.run(print_async("Roles members:", get_members(c)))
|
||||||
|
asyncio.run(
|
||||||
|
add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")])
|
||||||
|
)
|
||||||
|
asyncio.run(print_async("Roles members:", get_members(c)))
|
||||||
|
print("==> HERE <==")
|
||||||
|
naughty = sync_await(Group(cl, "naughty").sync())
|
||||||
|
print("NAUGHTY =>>", [m for m in naughty.get_members()])
|
||||||
|
asyncio.run(add_member(naughty, User(cl, "perozzi")))
|
||||||
|
print("NAUGHTY =>>", [m for m in naughty.get_members()])
|
||||||
|
asyncio.run(remove_member(naughty, User(cl, "conte_mascetti")))
|
||||||
|
print("NAUGHTY =>>", [m for m in naughty.get_members()])
|
Loading…
Reference in New Issue
Block a user