feat: add directory adapter
LDAPS directory adapter with certificate verification, allow-listed reads and writes, escaped filters, password verification and change, password policy and account state, plus an in-memory fake. Adds the LDAP_* configuration, python-ldap with its image libraries, the CA file mount and an opt-in live test for the test OU. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -3,8 +3,17 @@ import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def valid_env(tmp_path):
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text("test ca")
|
||||
return {
|
||||
"SECRET_KEY": "x" * 48,
|
||||
"ALLOWED_HOSTS": "testserver",
|
||||
"DATA_DIR": str(tmp_path),
|
||||
"LDAP_SERVER": "dc.example.test",
|
||||
"LDAP_USER_OU_DN": "OU=Users,DC=example,DC=test",
|
||||
"LDAP_BIND_DN": "svc@example.test",
|
||||
"LDAP_BIND_PASSWORD": "service-password",
|
||||
"LDAP_CA_FILE": str(ca_file),
|
||||
"LDAP_PUBLISHER_GROUP_DN": "CN=Publishers,OU=Users,DC=example,DC=test",
|
||||
"LDAP_ADMIN_GROUP_DN": "CN=Admins,OU=Users,DC=example,DC=test",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""A stand-in for the `ldap` module that records calls. Real python-ldap needs a C library."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class LDAPError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SERVER_DOWN(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class INVALID_CREDENTIALS(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class INSUFFICIENT_ACCESS(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class CONSTRAINT_VIOLATION(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class UNWILLING_TO_PERFORM(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class INVALID_SYNTAX(LDAPError): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
def escape_filter_chars(value):
|
||||
table = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00"}
|
||||
return "".join(table.get(c, c) for c in value)
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, world, url):
|
||||
self.world = world
|
||||
self.url = url
|
||||
self.options = {}
|
||||
self.bound = None
|
||||
|
||||
def set_option(self, name, value):
|
||||
self.options[name] = value
|
||||
|
||||
def simple_bind_s(self, dn, password):
|
||||
if self.world.down:
|
||||
raise SERVER_DOWN()
|
||||
if self.world.passwords.get(dn) != password:
|
||||
raise INVALID_CREDENTIALS()
|
||||
self.bound = dn
|
||||
|
||||
def unbind_s(self):
|
||||
pass
|
||||
|
||||
def search_s(self, base, scope, flt, attrs):
|
||||
self.world.searches.append((base, scope, flt, tuple(attrs)))
|
||||
return self.world.respond(base, scope, flt)
|
||||
|
||||
def modify_s(self, dn, mods):
|
||||
self.world.modifications.append((dn, mods))
|
||||
if self.world.modify_error:
|
||||
raise self.world.modify_error
|
||||
|
||||
|
||||
class World:
|
||||
"""The server side: canned search answers and the connections the adapter opened."""
|
||||
|
||||
def __init__(self):
|
||||
self.down = False
|
||||
self.passwords = {"svc@example.test": "service-password"}
|
||||
self.searches = []
|
||||
self.modifications = []
|
||||
self.modify_error = None
|
||||
self.connections = []
|
||||
self.entries = {} # (base, scope) -> list of (dn, attrs)
|
||||
|
||||
def respond(self, base, scope, flt):
|
||||
return self.entries.get((base, scope), [])
|
||||
|
||||
|
||||
def build(world):
|
||||
def initialize(url):
|
||||
conn = Connection(world, url)
|
||||
world.connections.append(conn)
|
||||
return conn
|
||||
|
||||
return SimpleNamespace(
|
||||
initialize=initialize,
|
||||
LDAPError=LDAPError,
|
||||
SERVER_DOWN=SERVER_DOWN,
|
||||
INVALID_CREDENTIALS=INVALID_CREDENTIALS,
|
||||
INSUFFICIENT_ACCESS=INSUFFICIENT_ACCESS,
|
||||
CONSTRAINT_VIOLATION=CONSTRAINT_VIOLATION,
|
||||
UNWILLING_TO_PERFORM=UNWILLING_TO_PERFORM,
|
||||
INVALID_SYNTAX=INVALID_SYNTAX,
|
||||
filter=SimpleNamespace(escape_filter_chars=escape_filter_chars),
|
||||
SCOPE_BASE=0,
|
||||
SCOPE_SUBTREE=2,
|
||||
MOD_ADD=0,
|
||||
MOD_DELETE=1,
|
||||
MOD_REPLACE=2,
|
||||
OPT_PROTOCOL_VERSION="protocol",
|
||||
OPT_REFERRALS="referrals",
|
||||
OPT_NETWORK_TIMEOUT="network_timeout",
|
||||
OPT_TIMEOUT="timeout",
|
||||
OPT_X_TLS_CACERTFILE="cacertfile",
|
||||
OPT_X_TLS_REQUIRE_CERT="require_cert",
|
||||
OPT_X_TLS_DEMAND="demand",
|
||||
OPT_X_TLS_NEWCTX="newctx",
|
||||
)
|
||||
@@ -70,3 +70,38 @@ def test_process_exits_nonzero_naming_both_variables(tmp_path):
|
||||
assert "SECRET_KEY" in result.stderr
|
||||
assert "LOG_LEVEL" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"LDAP_SERVER",
|
||||
"LDAP_USER_OU_DN",
|
||||
"LDAP_BIND_DN",
|
||||
"LDAP_BIND_PASSWORD",
|
||||
"LDAP_CA_FILE",
|
||||
"LDAP_PUBLISHER_GROUP_DN",
|
||||
"LDAP_ADMIN_GROUP_DN",
|
||||
],
|
||||
)
|
||||
def test_each_directory_variable_is_required(valid_env, name):
|
||||
del valid_env[name]
|
||||
with pytest.raises(ConfigError, match=name):
|
||||
load_config(valid_env)
|
||||
|
||||
|
||||
def test_directory_settings_are_loaded_and_the_password_is_not_printed(valid_env):
|
||||
directory = load_config(valid_env).directory
|
||||
assert directory.server == "dc.example.test"
|
||||
assert directory.user_ou_dn == "OU=Users,DC=example,DC=test"
|
||||
assert "service-password" not in repr(directory)
|
||||
|
||||
|
||||
def test_ldap_server_must_not_be_a_url(valid_env):
|
||||
with pytest.raises(ConfigError, match="LDAP_SERVER"):
|
||||
load_config({**valid_env, "LDAP_SERVER": "ldap://dc.example.test"})
|
||||
|
||||
|
||||
def test_a_missing_ca_file_is_reported(valid_env, tmp_path):
|
||||
with pytest.raises(ConfigError, match="LDAP_CA_FILE: must be an existing file"):
|
||||
load_config({**valid_env, "LDAP_CA_FILE": str(tmp_path / "missing.pem")})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Contract tests for the in-memory directory that the other tickets build against."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.adapters.directory import (
|
||||
WRITABLE_ATTRIBUTES,
|
||||
DirectoryUnavailable,
|
||||
FakeDirectory,
|
||||
Forbidden,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def directory():
|
||||
d = FakeDirectory()
|
||||
d.add_user("Alice", given_name="Alice", mail="alice@example.test", is_publisher=True)
|
||||
d.add_user("gone", enabled=False)
|
||||
d.add_user("locked", locked=True)
|
||||
d.add_user("expired", password_last_set=None)
|
||||
return d
|
||||
|
||||
|
||||
def test_find_user_is_case_insensitive_and_keeps_the_exact_name(directory):
|
||||
assert directory.find_user("alice").sam == "Alice"
|
||||
assert directory.find_user("nobody") is None
|
||||
assert directory.find_user("") is None
|
||||
|
||||
|
||||
def test_account_state_is_reported(directory):
|
||||
assert directory.find_user("gone").enabled is False
|
||||
assert directory.find_user("locked").locked is True
|
||||
assert directory.find_user("expired").must_change_password is True
|
||||
assert directory.find_user("Alice").is_publisher is True
|
||||
assert directory.find_user("Alice").is_admin is False
|
||||
|
||||
|
||||
def test_verify_password(directory):
|
||||
assert directory.verify_password("alice", "Correct-Horse-9") is True
|
||||
assert directory.verify_password("alice", "wrong") is False
|
||||
assert directory.verify_password("nobody", "Correct-Horse-9") is False
|
||||
|
||||
|
||||
def test_an_empty_password_is_never_valid(directory):
|
||||
directory.add_user("blank", password="")
|
||||
assert directory.verify_password("blank", "") is False
|
||||
|
||||
|
||||
def test_allowed_attributes_can_be_written_and_cleared(directory):
|
||||
directory.update_attributes("alice", {"mobile": "+49 1", "givenName": "Al"})
|
||||
user = directory.find_user("alice")
|
||||
assert (user.mobile, user.given_name) == ("+49 1", "Al")
|
||||
directory.update_attributes("alice", {"mobile": None})
|
||||
assert directory.find_user("alice").mobile == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["description", "memberOf", "unicodePwd", "sAMAccountName", "userAccountControl"]
|
||||
)
|
||||
def test_other_attributes_are_refused_before_anything_is_written(directory, name):
|
||||
with pytest.raises(Forbidden):
|
||||
directory.update_attributes("alice", {"givenName": "Changed", name: "x"})
|
||||
assert directory.find_user("alice").given_name == "Alice"
|
||||
|
||||
|
||||
def test_writing_an_unknown_user_fails(directory):
|
||||
with pytest.raises(ValidationFailed):
|
||||
directory.update_attributes("nobody", {"mobile": "1"})
|
||||
|
||||
|
||||
def test_writable_list_is_exactly_the_agreed_six():
|
||||
assert set(WRITABLE_ATTRIBUTES) == {
|
||||
"givenName",
|
||||
"sn",
|
||||
"displayName",
|
||||
"mail",
|
||||
"telephoneNumber",
|
||||
"mobile",
|
||||
}
|
||||
|
||||
|
||||
def test_password_policy_is_returned(directory):
|
||||
policy = directory.password_policy("alice")
|
||||
assert policy.min_length == 8
|
||||
assert policy.min_age == timedelta(0)
|
||||
|
||||
|
||||
def test_password_change_needs_the_current_password(directory):
|
||||
with pytest.raises(PasswordRejected) as exc:
|
||||
directory.change_password("alice", "wrong", "Another-Pass-7!")
|
||||
assert exc.value.reason == "wrong_current"
|
||||
directory.change_password("alice", "Correct-Horse-9", "Another-Pass-7!")
|
||||
assert directory.verify_password("alice", "Another-Pass-7!") is True
|
||||
assert directory.find_user("alice").password_last_set > datetime.now(UTC) - timedelta(minutes=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("new", ["short1A", "alllowercase-long", "Correct-Horse-9"])
|
||||
def test_policy_violations_are_reported_as_policy(directory, new):
|
||||
with pytest.raises(PasswordRejected) as exc:
|
||||
directory.change_password("alice", "Correct-Horse-9", new)
|
||||
assert exc.value.reason == "policy"
|
||||
|
||||
|
||||
def test_an_outage_is_a_typed_error(directory):
|
||||
directory.unavailable = True
|
||||
with pytest.raises(DirectoryUnavailable):
|
||||
directory.find_user("alice")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Opt-in check against the real test OU. Skipped unless PORTAL_LIVE_LDAP=1.
|
||||
|
||||
Set LIVE_LDAP_SERVER, LIVE_LDAP_USER_OU_DN, LIVE_LDAP_BIND_DN, LIVE_LDAP_BIND_PASSWORD,
|
||||
LIVE_LDAP_CA_FILE, LIVE_LDAP_PUBLISHER_GROUP_DN, LIVE_LDAP_ADMIN_GROUP_DN and
|
||||
LIVE_LDAP_TEST_PASSWORD (the current password of ptest-user1). Needs python-ldap (Linux).
|
||||
Only ever point this at the test OU. It restores what it writes and never changes a password.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.adapters.directory import Forbidden, LdapDirectory
|
||||
from portal.config.env import DirectoryConfig
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("PORTAL_LIVE_LDAP") != "1", reason="set PORTAL_LIVE_LDAP=1 to run"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live():
|
||||
pytest.importorskip("ldap")
|
||||
env = os.environ
|
||||
config = DirectoryConfig(
|
||||
server=env["LIVE_LDAP_SERVER"],
|
||||
user_ou_dn=env["LIVE_LDAP_USER_OU_DN"],
|
||||
bind_dn=env["LIVE_LDAP_BIND_DN"],
|
||||
bind_password=env["LIVE_LDAP_BIND_PASSWORD"],
|
||||
ca_file=Path(env["LIVE_LDAP_CA_FILE"]),
|
||||
publisher_group_dn=env["LIVE_LDAP_PUBLISHER_GROUP_DN"],
|
||||
admin_group_dn=env["LIVE_LDAP_ADMIN_GROUP_DN"],
|
||||
)
|
||||
return LdapDirectory(config)
|
||||
|
||||
|
||||
def test_reads_state_and_roles(live):
|
||||
user = live.find_user("ptest-user1")
|
||||
assert user is not None and user.enabled and not user.is_publisher
|
||||
assert live.find_user("ptest-publisher").is_publisher
|
||||
assert live.find_user("ptest-admin").is_admin
|
||||
assert live.find_user("ptest-disabled").enabled is False
|
||||
assert live.find_user("ptest-expired").must_change_password is True
|
||||
assert live.find_user("does-not-exist") is None
|
||||
|
||||
|
||||
def test_users_outside_the_ou_are_not_found(live):
|
||||
assert live.find_user("Administrator") is None
|
||||
|
||||
|
||||
def test_allowed_attributes_are_written_and_restored(live):
|
||||
before = live.find_user("ptest-user1")
|
||||
try:
|
||||
live.update_attributes("ptest-user1", {"mobile": "+49 000 0000", "displayName": "Test X"})
|
||||
after = live.find_user("ptest-user1")
|
||||
assert (after.mobile, after.display_name) == ("+49 000 0000", "Test X")
|
||||
finally:
|
||||
live.update_attributes(
|
||||
"ptest-user1", {"mobile": before.mobile, "displayName": before.display_name}
|
||||
)
|
||||
assert live.find_user("ptest-user1").mobile == before.mobile
|
||||
|
||||
|
||||
def test_other_attributes_are_refused(live):
|
||||
with pytest.raises(Forbidden):
|
||||
live.update_attributes("ptest-user1", {"description": "x"})
|
||||
|
||||
|
||||
def test_password_policy_is_returned(live):
|
||||
policy = live.password_policy("ptest-user1")
|
||||
assert policy.min_length >= 0
|
||||
|
||||
|
||||
def test_verify_password(live):
|
||||
assert live.verify_password("ptest-user1", os.environ["LIVE_LDAP_TEST_PASSWORD"]) is True
|
||||
assert live.verify_password("ptest-user1", "definitely-not-the-password") is False
|
||||
assert live.verify_password("ptest-user1", "") is False
|
||||
@@ -0,0 +1,298 @@
|
||||
"""The LDAPS adapter against a stand-in `ldap` module: what it sends and how it maps errors."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.adapters.directory import (
|
||||
DirectoryUnavailable,
|
||||
Forbidden,
|
||||
LdapDirectory,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
)
|
||||
from portal.tests import ldap_stub
|
||||
|
||||
OU = "OU=Users,DC=example,DC=test"
|
||||
DOMAIN = "DC=example,DC=test"
|
||||
USER_DN = "CN=Alice A,OU=Users,DC=example,DC=test"
|
||||
SVC = "svc@example.test"
|
||||
EPOCH = datetime(1601, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def filetime(moment):
|
||||
return (moment - EPOCH) // timedelta(microseconds=1) * 10
|
||||
|
||||
|
||||
def b(**attrs):
|
||||
return {k: [v.encode()] for k, v in attrs.items()}
|
||||
|
||||
|
||||
def config(tmp_path, server="dc.example.test"):
|
||||
ca = tmp_path / "ca.pem"
|
||||
ca.write_text("ca")
|
||||
return SimpleNamespace(
|
||||
server=server,
|
||||
user_ou_dn=OU,
|
||||
bind_dn=SVC,
|
||||
bind_password="service-password",
|
||||
ca_file=ca,
|
||||
publisher_group_dn="CN=Publishers,OU=Users,DC=example,DC=test",
|
||||
admin_group_dn="CN=Admins,OU=Users,DC=example,DC=test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world():
|
||||
w = ldap_stub.World()
|
||||
w.passwords[USER_DN] = "Alice-Pass-1!"
|
||||
w.entries[(OU, 2)] = [
|
||||
(
|
||||
USER_DN,
|
||||
{
|
||||
**b(
|
||||
sAMAccountName="Alice",
|
||||
givenName="Alice",
|
||||
sn="A",
|
||||
mail="a@example.test",
|
||||
userAccountControl="512",
|
||||
pwdLastSet=str(filetime(datetime(2026, 1, 1, tzinfo=UTC))),
|
||||
),
|
||||
"memberOf": [
|
||||
b"cn=publishers, OU=Users,DC=example,DC=test",
|
||||
b"CN=Other,DC=example,DC=test",
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
w.entries[("", 0)] = [("", b(defaultNamingContext=DOMAIN))]
|
||||
w.entries[(DOMAIN, 0)] = [
|
||||
(
|
||||
DOMAIN,
|
||||
b(
|
||||
minPwdLength="12",
|
||||
pwdProperties="1",
|
||||
pwdHistoryLength="24",
|
||||
minPwdAge=str(-864000000000),
|
||||
maxPwdAge=str(-77760000000000),
|
||||
lockoutDuration=str(-18000000000),
|
||||
),
|
||||
)
|
||||
]
|
||||
return w
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def directory(world, tmp_path):
|
||||
return LdapDirectory(config(tmp_path), ldap_module=ldap_stub.build(world))
|
||||
|
||||
|
||||
def user_attrs(world):
|
||||
return world.entries[(OU, 2)][0][1]
|
||||
|
||||
|
||||
def test_connection_is_ldaps_with_certificate_verification(directory, world, tmp_path):
|
||||
directory.find_user("alice")
|
||||
conn = world.connections[0]
|
||||
assert conn.url == "ldaps://dc.example.test:636"
|
||||
assert conn.options["require_cert"] == "demand"
|
||||
assert conn.options["cacertfile"] == str(tmp_path / "ca.pem")
|
||||
assert conn.options["newctx"] == 0
|
||||
|
||||
|
||||
def test_an_explicit_port_is_kept(world, tmp_path):
|
||||
d = LdapDirectory(config(tmp_path, "dc.example.test:3269"), ldap_module=ldap_stub.build(world))
|
||||
d.find_user("alice")
|
||||
assert world.connections[0].url == "ldaps://dc.example.test:3269"
|
||||
|
||||
|
||||
def test_find_user_reads_state_and_roles(directory):
|
||||
user = directory.find_user("alice")
|
||||
assert (user.sam, user.given_name, user.surname, user.mail) == (
|
||||
"Alice",
|
||||
"Alice",
|
||||
"A",
|
||||
"a@example.test",
|
||||
)
|
||||
assert user.enabled and not user.locked
|
||||
assert user.is_publisher and not user.is_admin
|
||||
assert user.password_last_set == datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_the_lookup_is_confined_to_the_ou_and_asks_only_for_listed_attributes(directory, world):
|
||||
directory.find_user("alice")
|
||||
base, scope, _flt, attrs = world.searches[0]
|
||||
assert (base, scope) == (OU, 2)
|
||||
assert "unicodePwd" not in attrs and "description" not in attrs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("evil", "escaped"),
|
||||
[
|
||||
("*", r"\2a"),
|
||||
("a)(objectClass=*", r"a\29\28objectClass=\2a"),
|
||||
("a*)(|(sAMAccountName=*", r"a\2a\29\28|\28sAMAccountName=\2a"),
|
||||
("back\\slash", r"back\5cslash"),
|
||||
],
|
||||
)
|
||||
def test_filter_input_is_escaped(directory, world, evil, escaped):
|
||||
directory.find_user(evil)
|
||||
flt = world.searches[0][2]
|
||||
assert flt == f"(&(objectClass=user)(objectCategory=person)(sAMAccountName={escaped}))"
|
||||
|
||||
|
||||
def test_a_disabled_account_and_a_lock_are_reported(directory, world):
|
||||
user_attrs(world)["userAccountControl"] = [b"514"]
|
||||
user_attrs(world)["msDS-User-Account-Control-Computed"] = [b"16"]
|
||||
user = directory.find_user("alice")
|
||||
assert not user.enabled and user.locked
|
||||
|
||||
|
||||
def test_lockout_falls_back_to_the_time_when_the_computed_attribute_is_missing(directory, world):
|
||||
user_attrs(world)["lockoutTime"] = [str(filetime(datetime.now(UTC))).encode()]
|
||||
assert directory.find_user("alice").locked is True # the lockout duration is 30 minutes
|
||||
user_attrs(world)["lockoutTime"] = [str(filetime(datetime(2026, 1, 1, tzinfo=UTC))).encode()]
|
||||
assert directory.find_user("alice").locked is False
|
||||
|
||||
|
||||
def test_pwd_last_set_zero_means_the_user_must_change_it(directory, world):
|
||||
user_attrs(world)["pwdLastSet"] = [b"0"]
|
||||
assert directory.find_user("alice").must_change_password is True
|
||||
|
||||
|
||||
def test_an_unknown_or_ambiguous_user_is_not_found(directory, world):
|
||||
world.entries[(OU, 2)] = world.entries[(OU, 2)] * 2
|
||||
assert directory.find_user("alice") is None
|
||||
world.entries[(OU, 2)] = []
|
||||
assert directory.find_user("alice") is None
|
||||
|
||||
|
||||
def test_a_refused_service_account_is_an_outage(world, tmp_path):
|
||||
world.passwords[SVC] = "changed"
|
||||
d = LdapDirectory(config(tmp_path), ldap_module=ldap_stub.build(world))
|
||||
with pytest.raises(DirectoryUnavailable):
|
||||
d.find_user("alice")
|
||||
|
||||
|
||||
def test_a_server_that_is_down_is_an_outage(directory, world):
|
||||
world.down = True
|
||||
with pytest.raises(DirectoryUnavailable):
|
||||
directory.find_user("alice")
|
||||
|
||||
|
||||
def test_verify_password_binds_as_the_found_user(directory, world):
|
||||
assert directory.verify_password("alice", "Alice-Pass-1!") is True
|
||||
assert world.connections[-1].bound == USER_DN
|
||||
assert directory.verify_password("alice", "nope") is False
|
||||
assert directory.verify_password("nobody", "x") is False
|
||||
|
||||
|
||||
def test_an_empty_password_is_refused_without_contacting_the_server(directory, world):
|
||||
assert directory.verify_password("alice", "") is False
|
||||
assert world.connections == []
|
||||
|
||||
|
||||
def test_password_policy_converts_the_directory_units(directory):
|
||||
policy = directory.password_policy("alice")
|
||||
assert policy.min_length == 12
|
||||
assert policy.complexity is True
|
||||
assert policy.history_length == 24
|
||||
assert policy.min_age == timedelta(days=1)
|
||||
assert policy.max_age == timedelta(days=90)
|
||||
assert policy.fine_grained is False
|
||||
|
||||
|
||||
def test_a_fine_grained_policy_is_used_when_readable(directory, world):
|
||||
pso = "CN=PSO,CN=Password Settings Container,CN=System,DC=example,DC=test"
|
||||
user_attrs(world)["msDS-ResultantPSO"] = [pso.encode()]
|
||||
world.entries[(pso, 0)] = [
|
||||
(
|
||||
pso,
|
||||
b(
|
||||
**{
|
||||
"msDS-MinimumPasswordLength": "20",
|
||||
"msDS-PasswordComplexityEnabled": "FALSE",
|
||||
"msDS-PasswordHistoryLength": "5",
|
||||
"msDS-MinimumPasswordAge": "0",
|
||||
"msDS-MaximumPasswordAge": str(-(2**63)),
|
||||
}
|
||||
),
|
||||
)
|
||||
]
|
||||
policy = directory.password_policy("alice")
|
||||
assert (policy.min_length, policy.complexity, policy.history_length) == (20, False, 5)
|
||||
assert policy.max_age is None and policy.fine_grained is True
|
||||
|
||||
|
||||
def test_an_unreadable_fine_grained_policy_is_flagged_not_hidden(directory, world, monkeypatch):
|
||||
pso = "CN=PSO,CN=System,DC=example,DC=test"
|
||||
user_attrs(world)["msDS-ResultantPSO"] = [pso.encode()]
|
||||
original = world.respond
|
||||
|
||||
def respond(base, scope, flt):
|
||||
if base == pso:
|
||||
raise ldap_stub.INSUFFICIENT_ACCESS()
|
||||
return original(base, scope, flt)
|
||||
|
||||
monkeypatch.setattr(world, "respond", respond)
|
||||
policy = directory.password_policy("alice")
|
||||
assert policy.fine_grained is True and policy.fine_grained_readable is False
|
||||
assert policy.min_length == 12
|
||||
|
||||
|
||||
def test_update_sends_a_replace_for_allowed_attributes(directory, world):
|
||||
directory.update_attributes("alice", {"mobile": "+49 1", "sn": None})
|
||||
dn, mods = world.modifications[0]
|
||||
assert dn == USER_DN
|
||||
assert mods == [(2, "mobile", [b"+49 1"]), (2, "sn", [])]
|
||||
|
||||
|
||||
def test_update_refuses_other_attributes_without_connecting(directory, world):
|
||||
with pytest.raises(Forbidden):
|
||||
directory.update_attributes("alice", {"description": "x"})
|
||||
assert world.connections == [] and world.modifications == []
|
||||
|
||||
|
||||
def test_update_maps_a_server_refusal_and_missing_rights(directory, world):
|
||||
world.modify_error = ldap_stub.CONSTRAINT_VIOLATION()
|
||||
with pytest.raises(ValidationFailed):
|
||||
directory.update_attributes("alice", {"mail": "x"})
|
||||
world.modify_error = ldap_stub.INSUFFICIENT_ACCESS()
|
||||
with pytest.raises(Forbidden):
|
||||
directory.update_attributes("alice", {"mail": "x"})
|
||||
|
||||
|
||||
def test_change_password_sends_delete_old_and_add_new_as_utf16(directory, world):
|
||||
directory.change_password("alice", "Old-Pass-1!", "New-Pass-2@")
|
||||
dn, mods = world.modifications[0]
|
||||
assert dn == USER_DN
|
||||
assert mods == [
|
||||
(1, "unicodePwd", ['"Old-Pass-1!"'.encode("utf-16-le")]),
|
||||
(0, "unicodePwd", ['"New-Pass-2@"'.encode("utf-16-le")]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("info", "reason"),
|
||||
[
|
||||
("0000052D: AtrErr: DSID-03191077, problem 1005 (CONSTRAINT_ATT_TYPE)", "policy"),
|
||||
("00000056: AtrErr: DSID-03190F80, problem 1005 (CONSTRAINT_ATT_TYPE)", "wrong_current"),
|
||||
("something unexpected", "policy"),
|
||||
],
|
||||
)
|
||||
def test_password_refusals_are_told_apart_by_the_directory_error_code(
|
||||
directory, world, info, reason
|
||||
):
|
||||
world.modify_error = ldap_stub.CONSTRAINT_VIOLATION({"info": info})
|
||||
with pytest.raises(PasswordRejected) as exc:
|
||||
directory.change_password("alice", "Old-Pass-1!", "New-Pass-2@")
|
||||
assert exc.value.reason == reason
|
||||
|
||||
|
||||
def test_a_refused_password_change_does_not_carry_the_passwords(directory, world):
|
||||
world.modify_error = ldap_stub.CONSTRAINT_VIOLATION({"info": "0000052D: x"})
|
||||
with pytest.raises(PasswordRejected) as exc:
|
||||
directory.change_password("alice", "Old-Pass-1!", "New-Pass-2@")
|
||||
text = repr(exc.value) + str(exc.value)
|
||||
assert "Old-Pass-1!" not in text and "New-Pass-2@" not in text
|
||||
Reference in New Issue
Block a user