diff --git a/Dockerfile b/Dockerfile index 0033914..6f8fe57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apricot/ldap/oauth_ldap_entry.py b/apricot/ldap/oauth_ldap_entry.py index 50eae6d..8a4954e 100644 --- a/apricot/ldap/oauth_ldap_entry.py +++ b/apricot/ldap/oauth_ldap_entry.py @@ -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": diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index aa81313..4133371 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -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__}" diff --git a/apricot/ldap/read_only_ldap_server.py b/apricot/ldap/read_only_ldap_server.py index 92c00d4..e745a8c 100644 --- a/apricot/ldap/read_only_ldap_server.py +++ b/apricot/ldap/read_only_ldap_server.py @@ -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 @@ -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) @@ -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 @@ -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 @@ -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) @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) diff --git a/apricot/models/__init__.py b/apricot/models/__init__.py new file mode 100644 index 0000000..430bfbb --- /dev/null +++ b/apricot/models/__init__.py @@ -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", +] diff --git a/apricot/models/ldap_group_of_names.py b/apricot/models/ldap_group_of_names.py new file mode 100644 index 0000000..920fe93 --- /dev/null +++ b/apricot/models/ldap_group_of_names.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class LdapGroupOfNames(BaseModel): + cn: str + description: str + member: list[str] diff --git a/apricot/models/ldap_inetorgperson.py b/apricot/models/ldap_inetorgperson.py new file mode 100644 index 0000000..1f6f27d --- /dev/null +++ b/apricot/models/ldap_inetorgperson.py @@ -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 diff --git a/apricot/models/ldap_inetuser.py b/apricot/models/ldap_inetuser.py new file mode 100644 index 0000000..2899c9a --- /dev/null +++ b/apricot/models/ldap_inetuser.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LdapInetUser(BaseModel): + memberOf: list[str] # noqa: N815 + uid: str diff --git a/apricot/models/ldap_person.py b/apricot/models/ldap_person.py new file mode 100644 index 0000000..f7f6b5f --- /dev/null +++ b/apricot/models/ldap_person.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LdapPerson(BaseModel): + cn: str + sn: str diff --git a/apricot/models/ldap_posix_account.py b/apricot/models/ldap_posix_account.py new file mode 100644 index 0000000..e700961 --- /dev/null +++ b/apricot/models/ldap_posix_account.py @@ -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 diff --git a/apricot/models/ldap_posix_group.py b/apricot/models/ldap_posix_group.py new file mode 100644 index 0000000..fbcaa5d --- /dev/null +++ b/apricot/models/ldap_posix_group.py @@ -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 diff --git a/apricot/oauth/microsoft_entra_client.py b/apricot/oauth/microsoft_entra_client.py index 3b4ae6a..f2ff346 100644 --- a/apricot/oauth/microsoft_entra_client.py +++ b/apricot/oauth/microsoft_entra_client.py @@ -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): @@ -25,27 +25,43 @@ 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, @@ -53,14 +69,21 @@ def users(self) -> list[LDAPAttributeDict]: 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" ) @@ -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 diff --git a/apricot/oauth/oauth_client.py b/apricot/oauth/oauth_client.py index eeaefdf..f3de1cc 100644 --- a/apricot/oauth/oauth_client.py +++ b/apricot/oauth/oauth_client.py @@ -7,9 +7,19 @@ InvalidGrantError, LegacyApplicationClient, ) +from pydantic import ValidationError from requests_oauthlib import OAuth2Session from twisted.python import log +from apricot.models import ( + LdapGroupOfNames, + LdapInetOrgPerson, + LdapInetUser, + LdapPerson, + LdapPosixAccount, + LdapPosixGroup, +) + from .types import JSONDict, LDAPAttributeDict @@ -75,11 +85,21 @@ def extract_token(self, json_response: JSONDict) -> str: pass @abstractmethod - def groups(self) -> list[LDAPAttributeDict]: + def groups(self) -> list[dict[str, Any]]: + """ + Return a list of group data + + Each return value should be a dict where 'None' is used to signify a missing value + """ pass @abstractmethod - def users(self) -> list[LDAPAttributeDict]: + def users(self) -> list[dict[str, Any]]: + """ + Return a list of user data + + Each return value should be a dict where 'None' is used to signify a missing value + """ pass @property @@ -96,7 +116,87 @@ def query(self, url: str) -> dict[str, Any]: ) return result.json() # type: ignore + def validated_groups(self) -> list[LDAPAttributeDict]: + """ + Validate output via pydantic and return a list of LDAPAttributeDict + """ + output = [] + # Add one self-titled group for each user + user_group_dicts = [] + for user_dict in self.users(): + user_dict["memberUid"] = [user_dict["uid"]] + user_dict["member"] = [f"CN={user_dict['uid']},OU=users,{self.root_dn}"] + user_group_dicts.append(user_dict) + # Iterate over groups and validate them + for group_dict in self.groups() + user_group_dicts: + try: + attributes = {"objectclass": ["top"]} + # Add 'groupOfNames' attributes + group_of_names = LdapGroupOfNames(**group_dict) + attributes.update(group_of_names.model_dump()) + attributes["objectclass"].append("groupOfNames") + # Add 'posixGroup' attributes + posix_group = LdapPosixGroup(**group_dict) + attributes.update(posix_group.model_dump()) + attributes["objectclass"].append("posixGroup") + # Ensure that all values are lists as required for LDAPAttributeDict + output.append( + { + k: v if isinstance(v, list) else [v] # type: ignore[list-item] + for k, v in attributes.items() + } + ) + except ValidationError as exc: + name = group_dict["cn"] if "cn" in group_dict else "unknown" + log.msg(f"Validation failed for group '{name}'.") + for error in exc.errors(): + log.msg( + f"... '{error['loc'][0]}': {error['msg']} but '{error['input']}' was provided." + ) + return output + + def validated_users(self) -> list[LDAPAttributeDict]: + """ + Validate output via pydantic and return a list of LDAPAttributeDict + """ + output = [] + for user_dict in self.users(): + try: + attributes = {"objectclass": ["top"]} + # Add 'inetOrgPerson' attributes + inetorg_person = LdapInetOrgPerson(**user_dict) + attributes.update(inetorg_person.model_dump()) + attributes["objectclass"].append("inetOrgPerson") + # Add 'inetUser' attributes + inet_user = LdapInetUser(**user_dict) + attributes.update(inet_user.model_dump()) + attributes["objectclass"].append("inetuser") + # Add 'person' attributes + person = LdapPerson(**user_dict) + attributes.update(person.model_dump()) + attributes["objectclass"].append("person") + # Add 'posixAccount' attributes + posix_account = LdapPosixAccount(**user_dict) + attributes.update(posix_account.model_dump()) + attributes["objectclass"].append("posixAccount") + # Ensure that all values are lists as required for LDAPAttributeDict + output.append( + { + k: v if isinstance(v, list) else [v] # type: ignore[list-item] + for k, v in attributes.items() + } + ) + except ValidationError as exc: + name = user_dict["cn"] if "cn" in user_dict else "unknown" + log.msg(f"Validation failed for user '{name}'.") + for error in exc.errors(): + log.msg( + f"... '{error['loc'][0]}': {error['msg']} but '{error['input']}' was provided." + ) + return output + def verify(self, username: str, password: str) -> bool: + """Verify client connection details""" try: self.session_interactive.fetch_token( token_url=self.token_url, diff --git a/pyproject.toml b/pyproject.toml index 3ed172e..0f689cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "ldaptor~=21.2.0", "oauthlib~=3.2.0", + "pydantic~=2.4.0", "requests-oauthlib~=1.3.0", "Twisted~=23.10.0", "zope.interface~=6.2", @@ -127,6 +128,7 @@ strict = true # enable all optional error checking flags [[tool.mypy.overrides]] module = [ "ldaptor.*", + "pydantic.*", "requests_oauthlib.*", "twisted.*", "zope.interface.*", diff --git a/run.py b/run.py index 75f3c11..ca441f3 100644 --- a/run.py +++ b/run.py @@ -26,7 +26,7 @@ # Create the Apricot server reactor = ApricotServer(**vars(args)) except Exception as exc: - msg = f"Unable to initialise Apricot server from provided command line arguments.\n{str(exc)}" + msg = f"Unable to initialise Apricot server.\n{str(exc)}" print(msg) sys.exit(1)