Skip to content

Commit

Permalink
Merge pull request #18 from alan-turing-institute/16-validate-ldap
Browse files Browse the repository at this point in the history
Validate LDAP data
  • Loading branch information
jemrobinson authored Feb 23, 2024
2 parents df5f91d + 37ffb50 commit 4115fc1
Show file tree
Hide file tree
Showing 15 changed files with 285 additions and 48 deletions.
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ WORKDIR /app
RUN apk add --update --no-cache \
gcc libc-dev libffi-dev

# Upload and install Python package and dependencies
COPY ./apricot apricot
# Install Python dependencies
COPY ./pyproject.toml .
COPY ./README.md .
RUN pip install --upgrade hatch pip
# Copy code and README into the container
COPY ./README.md .
COPY ./apricot apricot
# Initialise environment with hatch
RUN hatch run true

Expand Down
6 changes: 0 additions & 6 deletions apricot/ldap/oauth_ldap_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,6 @@ def oauth_client(self) -> OAuthClient:
raise TypeError(msg)
return self.oauth_client_

@property
def username(self) -> str:
username = self.dn.split()[0].getText().split("CN=")[1]
domain = self.dn.getDomainName()
return f"{username}@{domain}"

def add_child(
self, rdn: RelativeDistinguishedName | str, attributes: LDAPAttributeDict
) -> "OAuthLDAPEntry":
Expand Down
8 changes: 4 additions & 4 deletions apricot/ldap/oauth_ldap_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ def __init__(self, oauth_client: OAuthClient) -> None:
"OU=users", {"ou": ["users"], "objectClass": ["organizationalUnit"]}
)
# Add groups to the groups OU
for group_attrs in self.oauth_client.groups():
groups_ou.add_child(f"CN={group_attrs['name'][0]}", group_attrs)
for group_attrs in self.oauth_client.validated_groups():
groups_ou.add_child(f"CN={group_attrs['cn'][0]}", group_attrs)
# Add users to the users OU
for user_attrs in self.oauth_client.users():
users_ou.add_child(f"CN={user_attrs['name'][0]}", user_attrs)
for user_attrs in self.oauth_client.validated_users():
users_ou.add_child(f"CN={user_attrs['cn'][0]}", user_attrs)

def __repr__(self) -> str:
return f"{self.__class__.__name__} with backend {self.oauth_client.__class__.__name__}"
Expand Down
40 changes: 30 additions & 10 deletions apricot/ldap/read_only_ldap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ def getRootDSE( # noqa: N802
request: LDAPBindRequest,
reply: Callable[[LDAPSearchResultEntry], None] | None,
) -> LDAPSearchResultDone:
"""Handle an LDAP Root RSE request"""
"""
Handle an LDAP Root RSE request
"""
return super().getRootDSE(request, reply)

def handle_LDAPAddRequest( # noqa: N802
Expand All @@ -27,7 +29,9 @@ def handle_LDAPAddRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Refuse to handle an LDAP add request"""
"""
Refuse to handle an LDAP add request
"""
id((request, controls, reply)) # ignore unused arguments
msg = "ReadOnlyLDAPServer will not handle LDAP add requests"
raise LDAPProtocolError(msg)
Expand All @@ -38,7 +42,9 @@ def handle_LDAPBindRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Handle an LDAP bind request"""
"""
Handle an LDAP bind request
"""
return super().handle_LDAPBindRequest(request, controls, reply)

def handle_LDAPCompareRequest( # noqa: N802
Expand All @@ -47,7 +53,9 @@ def handle_LDAPCompareRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Handle an LDAP compare request"""
"""
Handle an LDAP compare request
"""
return super().handle_LDAPCompareRequest(request, controls, reply)

def handle_LDAPDelRequest( # noqa: N802
Expand All @@ -56,7 +64,9 @@ def handle_LDAPDelRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Refuse to handle an LDAP delete request"""
"""
Refuse to handle an LDAP delete request
"""
id((request, controls, reply)) # ignore unused arguments
msg = "ReadOnlyLDAPServer will not handle LDAP delete requests"
raise LDAPProtocolError(msg)
Expand All @@ -67,7 +77,9 @@ def handle_LDAPExtendedRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Handle an LDAP extended request"""
"""
Handle an LDAP extended request
"""
return super().handle_LDAPExtendedRequest(request, controls, reply)

def handle_LDAPModifyDNRequest( # noqa: N802
Expand All @@ -76,7 +88,9 @@ def handle_LDAPModifyDNRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Refuse to handle an LDAP modify DN request"""
"""
Refuse to handle an LDAP modify DN request
"""
id((request, controls, reply)) # ignore unused arguments
msg = "ReadOnlyLDAPServer will not handle LDAP modify DN requests"
raise LDAPProtocolError(msg)
Expand All @@ -87,7 +101,9 @@ def handle_LDAPModifyRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Refuse to handle an LDAP modify request"""
"""
Refuse to handle an LDAP modify request
"""
id((request, controls, reply)) # ignore unused arguments
msg = "ReadOnlyLDAPServer will not handle LDAP modify requests"
raise LDAPProtocolError(msg)
Expand All @@ -98,7 +114,9 @@ def handle_LDAPUnbindRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[..., None] | None,
) -> None:
"""Handle an LDAP unbind request"""
"""
Handle an LDAP unbind request
"""
super().handle_LDAPUnbindRequest(request, controls, reply)

def handle_LDAPSearchRequest( # noqa: N802
Expand All @@ -107,5 +125,7 @@ def handle_LDAPSearchRequest( # noqa: N802
controls: LDAPControl | None,
reply: Callable[[LDAPSearchResultEntry], None] | None,
) -> defer.Deferred[ILDAPEntry]:
"""Handle an LDAP search request"""
"""
Handle an LDAP search request
"""
return super().handle_LDAPSearchRequest(request, controls, reply)
15 changes: 15 additions & 0 deletions apricot/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from .ldap_group_of_names import LdapGroupOfNames
from .ldap_inetorgperson import LdapInetOrgPerson
from .ldap_inetuser import LdapInetUser
from .ldap_person import LdapPerson
from .ldap_posix_account import LdapPosixAccount
from .ldap_posix_group import LdapPosixGroup

__all__ = [
"LdapGroupOfNames",
"LdapInetOrgPerson",
"LdapInetUser",
"LdapPerson",
"LdapPosixAccount",
"LdapPosixGroup",
]
7 changes: 7 additions & 0 deletions apricot/models/ldap_group_of_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel


class LdapGroupOfNames(BaseModel):
cn: str
description: str
member: list[str]
9 changes: 9 additions & 0 deletions apricot/models/ldap_inetorgperson.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import BaseModel


class LdapInetOrgPerson(BaseModel):
cn: str
description: str
displayName: str # noqa: N815
givenName: str # noqa: N815
sn: str
6 changes: 6 additions & 0 deletions apricot/models/ldap_inetuser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pydantic import BaseModel


class LdapInetUser(BaseModel):
memberOf: list[str] # noqa: N815
uid: str
6 changes: 6 additions & 0 deletions apricot/models/ldap_person.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pydantic import BaseModel


class LdapPerson(BaseModel):
cn: str
sn: str
40 changes: 40 additions & 0 deletions apricot/models/ldap_posix_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import re

from pydantic import BaseModel, StringConstraints, validator
from typing_extensions import Annotated

ID_MIN = 2000
ID_MAX = 60000


class LdapPosixAccount(BaseModel):
cn: str
gidNumber: int # noqa: N815
homeDirectory: Annotated[ # noqa: N815
str, StringConstraints(strip_whitespace=True, to_lower=True)
]
uid: str
uidNumber: int # noqa: N815

@validator("gidNumber") # type: ignore[misc]
@classmethod
def validate_gid_number(cls, gid_number: int) -> int:
"""Avoid conflicts with existing users"""
if not ID_MIN <= gid_number <= ID_MAX:
msg = f"Must be in range {ID_MIN} to {ID_MAX}."
raise ValueError(msg)
return gid_number

@validator("homeDirectory") # type: ignore[misc]
@classmethod
def validate_home_directory(cls, home_directory: str) -> str:
return re.sub(r"\s+", "-", home_directory)

@validator("uidNumber") # type: ignore[misc]
@classmethod
def validate_uid_number(cls, uid_number: int) -> int:
"""Avoid conflicts with existing users"""
if not ID_MIN <= uid_number <= ID_MAX:
msg = f"Must be in range {ID_MIN} to {ID_MAX}."
raise ValueError(msg)
return uid_number
19 changes: 19 additions & 0 deletions apricot/models/ldap_posix_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import BaseModel, validator

ID_MIN = 2000
ID_MAX = 4294967295


class LdapPosixGroup(BaseModel):
description: str
gidNumber: int # noqa: N815
memberUid: list[str] # noqa: N815

@validator("gidNumber") # type: ignore[misc]
@classmethod
def validate_gid_number(cls, gid_number: int) -> int:
"""Avoid conflicts with existing groups"""
if not ID_MIN <= gid_number <= ID_MAX:
msg = f"Must be in range {ID_MIN} to {ID_MAX}."
raise ValueError(msg)
return gid_number
62 changes: 40 additions & 22 deletions apricot/oauth/microsoft_entra_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Any
from typing import Any, cast

from .oauth_client import OAuthClient
from .types import JSONDict, LDAPAttributeDict
from .types import JSONDict


class MicrosoftEntraClient(OAuthClient):
Expand All @@ -25,42 +25,65 @@ def __init__(
def extract_token(self, json_response: JSONDict) -> str:
return str(json_response["access_token"])

def groups(self) -> list[LDAPAttributeDict]:
def groups(self) -> list[dict[str, Any]]:
output = []
try:
group_data = self.query("https://graph.microsoft.com/v1.0/groups/")
for group_dict in group_data["value"]:
attributes = {k: [v if v else ""] for k, v in dict(group_dict).items()}
attributes["objectclass"] = ["top", "group"]
attributes["name"] = [str(group_dict["displayName"]).split("@")[0]]
for group_dict in cast(list[dict[str, Any]], group_data["value"]):
attributes = {}
attributes["cn"] = group_dict.get("displayName", None)
attributes["description"] = group_dict.get("id", None)
# As we cannot manually set any attributes we take the last part of the securityIdentifier
attributes["gidNumber"] = str(
group_dict.get("securityIdentifier", "")
).split("-")[-1]
# Add membership attributes
members = self.query(
f"https://graph.microsoft.com/v1.0/groups/{group_dict['id']}/members"
)
attributes["memberUid"] = [
str(user["userPrincipalName"]).split("@")[0]
for user in members["value"]
if user["userPrincipalName"]
]
attributes["member"] = [
f"CN={uid},OU=users,{self.root_dn}"
for uid in attributes["memberUid"]
]
output.append(attributes)
except KeyError:
pass
return output

def users(self) -> list[LDAPAttributeDict]:
def users(self) -> list[dict[str, Any]]:
output = []
try:
queries = [
"displayName",
"givenName",
"id",
"mail",
"surname",
"userPrincipalName",
self.uid_attribute,
]
user_data = self.query(
f"https://graph.microsoft.com/v1.0/users?$select={','.join(queries)}"
)
for user_dict in user_data["value"]:
attributes = {k: [v if v else ""] for k, v in dict(user_dict).items()}
attributes["objectclass"] = [
"top",
"person",
"organizationalPerson",
"user",
]
for user_dict in cast(list[dict[str, Any]], user_data["value"]):
# Get user attributes
uid, domain = str(user_dict.get("userPrincipalName", "@")).split("@")
attributes = {}
attributes["cn"] = user_dict.get("displayName", None)
attributes["description"] = user_dict.get("id", None)
attributes["displayName"] = attributes.get("cn", None)
attributes["domain"] = domain
attributes["gidNumber"] = user_dict.get(self.uid_attribute, None)
attributes["givenName"] = user_dict.get("givenName", "")
attributes["homeDirectory"] = f"/home/{uid}" if uid else None
attributes["sn"] = user_dict.get("surname", "")
attributes["uid"] = uid if uid else None
attributes["uidNumber"] = user_dict.get(self.uid_attribute, None)
# Add group attributes
group_memberships = self.query(
f"https://graph.microsoft.com/v1.0/users/{user_dict['id']}/memberOf"
)
Expand All @@ -69,11 +92,6 @@ def users(self) -> list[LDAPAttributeDict]:
for group in group_memberships["value"]
if group["displayName"]
]
attributes["name"] = [str(user_dict["userPrincipalName"]).split("@")[0]]
attributes["domain"] = [
str(user_dict["userPrincipalName"]).split("@")[1]
]
attributes["uid"] = [str(user_dict[self.uid_attribute])]
output.append(attributes)
except KeyError:
pass
Expand Down
Loading

0 comments on commit 4115fc1

Please sign in to comment.