diff --git a/README.md b/README.md index db843a5..f00251b 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,12 @@ Each user will have an entry like ```ldif dn: CN=<user name>,OU=users,DC=<your domain> objectClass: inetOrgPerson -objectClass: inetuser +objectClass: organizationalPerson objectClass: person objectClass: posixAccount objectClass: top <user data fields here> +memberOf: <DN for each group that this user belongs to> ``` Each group will have an entry like @@ -61,6 +62,64 @@ objectClass: groupOfNames objectClass: posixGroup objectClass: top <group data fields here> +member: <DN for each user belonging to this group> +``` + +## Primary groups + +Note that each user will have an associated group to act as its POSIX user primary group + +For example: + +```ldif +dn: CN=sherlock.holmes,OU=users,DC=<your domain> +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: posixAccount +objectClass: top +... +memberOf: CN=sherlock.holmes,OU=groups,DC=<your domain> +... +``` + +will have an associated group + +```ldif +dn: CN=sherlock.holmes,OU=groups,DC=<your domain> +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=<your domain> +... +``` + +## Mirrored groups + +Each group of users will have an associated group-of-groups where each user in the group will have its user primary group in the group-of-groups. +Note that these groups-of-groups are **not** `posixGroup`s as POSIX does not allow nested groups. + +For example: + +```ldif +dn:CN=Detectives,OU=groups,DC=<your domain> +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=<your domain> +``` + +will have an associated group-of-groups + +```ldif +dn: CN=Primary user groups for Detectives,OU=groups,DC=<your domain> +objectClass: groupOfNames +objectClass: top +... +member: CN=sherlock.holmes,OU=groups,DC=<your domain> +... ``` ## OpenID Connect diff --git a/apricot/apricot_server.py b/apricot/apricot_server.py index e67dc27..7163ffb 100644 --- a/apricot/apricot_server.py +++ b/apricot/apricot_server.py @@ -49,7 +49,6 @@ def __init__( client_id=client_id, client_secret=client_secret, debug=debug, - domain=domain, uid_cache=uid_cache, **kwargs, ) @@ -60,7 +59,7 @@ def __init__( # Create an LDAPServerFactory if self.debug: log.msg("Creating an LDAPServerFactory.") - factory = OAuthLDAPServerFactory(oauth_client) + factory = OAuthLDAPServerFactory(domain, oauth_client) # Attach a listening endpoint if self.debug: diff --git a/apricot/ldap/oauth_ldap_server_factory.py b/apricot/ldap/oauth_ldap_server_factory.py index d3c075a..2890b35 100644 --- a/apricot/ldap/oauth_ldap_server_factory.py +++ b/apricot/ldap/oauth_ldap_server_factory.py @@ -8,14 +8,14 @@ class OAuthLDAPServerFactory(ServerFactory): - def __init__(self, oauth_client: OAuthClient): + def __init__(self, domain: str, oauth_client: OAuthClient): """ Initialise an LDAPServerFactory @param oauth_client: An OAuth client used to construct the LDAP tree """ # Create an LDAP lookup tree - self.adaptor = OAuthLDAPTree(oauth_client) + self.adaptor = OAuthLDAPTree(domain, oauth_client) def __repr__(self) -> str: return f"{self.__class__.__name__} using adaptor {self.adaptor}" diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index 005d26b..136ce31 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -7,23 +7,26 @@ from zope.interface import implementer from apricot.ldap.oauth_ldap_entry import OAuthLDAPEntry -from apricot.oauth import OAuthClient +from apricot.oauth import OAuthClient, OAuthDataAdaptor @implementer(IConnectedLDAPEntry) class OAuthLDAPTree: - oauth_client: OAuthClient - def __init__(self, oauth_client: OAuthClient, refresh_interval: int = 60) -> None: + def __init__( + self, domain: str, oauth_client: OAuthClient, refresh_interval: int = 60 + ) -> None: """ Initialise an OAuthLDAPTree + @param domain: The root domain of the LDAP tree @param oauth_client: An OAuth client used to construct the LDAP tree @param refresh_interval: Interval in seconds after which the tree must be refreshed """ self.debug = oauth_client.debug + self.domain = domain self.last_update = time.monotonic() - self.oauth_client: OAuthClient = oauth_client + self.oauth_client = oauth_client self.refresh_interval = refresh_interval self.root_: OAuthLDAPEntry | None = None @@ -42,13 +45,18 @@ def root(self) -> OAuthLDAPEntry: not self.root_ or (time.monotonic() - self.last_update) > self.refresh_interval ): - log.msg("Rebuilding LDAP tree from OAuth data.") + # Update users and groups from the OAuth server + log.msg("Retrieving OAuth data.") + oauth_adaptor = OAuthDataAdaptor(self.domain, self.oauth_client) + # Create a root node for the tree + log.msg("Rebuilding LDAP tree.") self.root_ = OAuthLDAPEntry( - dn=self.oauth_client.root_dn, + dn=oauth_adaptor.root_dn, attributes={"objectClass": ["dcObject"]}, oauth_client=self.oauth_client, ) + # Add OUs for users and groups groups_ou = self.root_.add_child( "OU=groups", {"ou": ["groups"], "objectClass": ["organizationalUnit"]} @@ -56,16 +64,19 @@ def root(self) -> OAuthLDAPEntry: users_ou = self.root_.add_child( "OU=users", {"ou": ["users"], "objectClass": ["organizationalUnit"]} ) + # Add groups to the groups OU if self.debug: - log.msg("Adding groups to the LDAP tree.") - for group_attrs in self.oauth_client.validated_groups(): + log.msg(f"Adding {len(oauth_adaptor.groups)} groups to the LDAP tree.") + for group_attrs in oauth_adaptor.groups: groups_ou.add_child(f"CN={group_attrs.cn}", group_attrs.to_dict()) + # Add users to the users OU if self.debug: - log.msg("Adding users to the LDAP tree.") - for user_attrs in self.oauth_client.validated_users(): + log.msg(f"Adding {len(oauth_adaptor.users)} users to the LDAP tree.") + for user_attrs in oauth_adaptor.users: users_ou.add_child(f"CN={user_attrs.cn}", user_attrs.to_dict()) + # Set last updated time log.msg("Finished building LDAP tree.") self.last_update = time.monotonic() diff --git a/apricot/models/__init__.py b/apricot/models/__init__.py index dcec823..615f99b 100644 --- a/apricot/models/__init__.py +++ b/apricot/models/__init__.py @@ -1,19 +1,19 @@ from .ldap_attribute_adaptor import LDAPAttributeAdaptor from .ldap_group_of_names import LDAPGroupOfNames from .ldap_inetorgperson import LDAPInetOrgPerson -from .ldap_inetuser import LDAPInetUser -from .ldap_oauthuser import LDAPOAuthUser -from .ldap_person import LDAPPerson from .ldap_posix_account import LDAPPosixAccount from .ldap_posix_group import LDAPPosixGroup +from .named_ldap_class import NamedLDAPClass +from .overlay_memberof import OverlayMemberOf +from .overlay_oauthentry import OverlayOAuthEntry __all__ = [ "LDAPAttributeAdaptor", "LDAPGroupOfNames", "LDAPInetOrgPerson", - "LDAPInetUser", - "LDAPOAuthUser", - "LDAPPerson", "LDAPPosixAccount", "LDAPPosixGroup", + "NamedLDAPClass", + "OverlayMemberOf", + "OverlayOAuthEntry", ] diff --git a/apricot/models/ldap_group_of_names.py b/apricot/models/ldap_group_of_names.py index a74a3da..b1e077b 100644 --- a/apricot/models/ldap_group_of_names.py +++ b/apricot/models/ldap_group_of_names.py @@ -1,7 +1,19 @@ -from pydantic import BaseModel +from .named_ldap_class import NamedLDAPClass -class LDAPGroupOfNames(BaseModel): +class LDAPGroupOfNames(NamedLDAPClass): + """ + A group with named members + + OID: 2.5.6.9 + Object class: Structural + Parent: top + Schema: rfc4519 + """ + cn: str description: str member: list[str] + + def names(self) -> list[str]: + return ["groupOfNames"] diff --git a/apricot/models/ldap_inetorgperson.py b/apricot/models/ldap_inetorgperson.py index 177e108..fe86b8e 100644 --- a/apricot/models/ldap_inetorgperson.py +++ b/apricot/models/ldap_inetorgperson.py @@ -1,9 +1,20 @@ -from pydantic import BaseModel +from .ldap_organizational_person import LDAPOrganizationalPerson -class LDAPInetOrgPerson(BaseModel): +class LDAPInetOrgPerson(LDAPOrganizationalPerson): + """ + A person belonging to an internet/intranet directory service + + OID: 2.16.840.1.113730.3.2.2 + Object class: Structural + Parent: organizationalPerson + Schema: rfc2798 + """ + cn: str - description: str displayName: str # noqa: N815 givenName: str # noqa: N815 sn: str + + def names(self) -> list[str]: + return [*super().names(), "inetOrgPerson"] diff --git a/apricot/models/ldap_inetuser.py b/apricot/models/ldap_inetuser.py deleted file mode 100644 index b9fd732..0000000 --- a/apricot/models/ldap_inetuser.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class LDAPInetUser(BaseModel): - memberOf: list[str] # noqa: N815 - uid: str diff --git a/apricot/models/ldap_oauthuser.py b/apricot/models/ldap_oauthuser.py deleted file mode 100644 index 9f45ca1..0000000 --- a/apricot/models/ldap_oauthuser.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class LDAPOAuthUser(BaseModel): - oauth_username: str diff --git a/apricot/models/ldap_organizational_person.py b/apricot/models/ldap_organizational_person.py new file mode 100644 index 0000000..064ba5a --- /dev/null +++ b/apricot/models/ldap_organizational_person.py @@ -0,0 +1,17 @@ +from .ldap_person import LDAPPerson + + +class LDAPOrganizationalPerson(LDAPPerson): + """ + A person belonging to an organisation + + OID: 2.5.6.7 + Object class: Structural + Parent: person + Schema: rfc4519 + """ + + description: str + + def names(self) -> list[str]: + return [*super().names(), "organizationalPerson"] diff --git a/apricot/models/ldap_person.py b/apricot/models/ldap_person.py index 211ea5c..0656897 100644 --- a/apricot/models/ldap_person.py +++ b/apricot/models/ldap_person.py @@ -1,6 +1,18 @@ -from pydantic import BaseModel +from .named_ldap_class import NamedLDAPClass -class LDAPPerson(BaseModel): +class LDAPPerson(NamedLDAPClass): + """ + A named person + + OID: 2.5.6.6 + Object class: Structural + Parent: top + Schema: rfc4519 + """ + cn: str sn: str + + def names(self) -> list[str]: + return ["person"] diff --git a/apricot/models/ldap_posix_account.py b/apricot/models/ldap_posix_account.py index ec98188..5bdd738 100644 --- a/apricot/models/ldap_posix_account.py +++ b/apricot/models/ldap_posix_account.py @@ -1,13 +1,24 @@ import re -from pydantic import BaseModel, StringConstraints, validator +from pydantic import StringConstraints, validator from typing_extensions import Annotated +from .named_ldap_class import NamedLDAPClass + ID_MIN = 2000 ID_MAX = 60000 -class LDAPPosixAccount(BaseModel): +class LDAPPosixAccount(NamedLDAPClass): + """ + Abstraction of an account with POSIX attributes + + OID: 1.3.6.1.1.1.2.0 + Object class: Auxiliary + Parent: top + Schema: rfc2307bis + """ + cn: str gidNumber: int # noqa: N815 homeDirectory: Annotated[ # noqa: N815 @@ -38,3 +49,6 @@ def validate_uid_number(cls, uid_number: int) -> int: msg = f"Must be in range {ID_MIN} to {ID_MAX}." raise ValueError(msg) return uid_number + + def names(self) -> list[str]: + return ["posixAccount"] diff --git a/apricot/models/ldap_posix_group.py b/apricot/models/ldap_posix_group.py index 354f46b..e926b49 100644 --- a/apricot/models/ldap_posix_group.py +++ b/apricot/models/ldap_posix_group.py @@ -1,10 +1,21 @@ -from pydantic import BaseModel, validator +from pydantic import validator + +from .named_ldap_class import NamedLDAPClass ID_MIN = 2000 ID_MAX = 4294967295 -class LDAPPosixGroup(BaseModel): +class LDAPPosixGroup(NamedLDAPClass): + """ + Abstraction of a group of accounts + + OID: 1.3.6.1.1.1.2.2 + Object class: Auxiliary + Parent: top + Schema: rfc2307bis + """ + description: str gidNumber: int # noqa: N815 memberUid: list[str] # noqa: N815 @@ -17,3 +28,6 @@ def validate_gid_number(cls, gid_number: int) -> int: msg = f"Must be in range {ID_MIN} to {ID_MAX}." raise ValueError(msg) return gid_number + + def names(self) -> list[str]: + return ["posixGroup"] diff --git a/apricot/models/named_ldap_class.py b/apricot/models/named_ldap_class.py new file mode 100644 index 0000000..329e771 --- /dev/null +++ b/apricot/models/named_ldap_class.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class NamedLDAPClass(BaseModel): + def names(self) -> list[str]: + """List of names for this LDAP object class""" + return [] diff --git a/apricot/models/overlay_memberof.py b/apricot/models/overlay_memberof.py new file mode 100644 index 0000000..3e78f71 --- /dev/null +++ b/apricot/models/overlay_memberof.py @@ -0,0 +1,14 @@ +from .named_ldap_class import NamedLDAPClass + + +class OverlayMemberOf(NamedLDAPClass): + """ + Abstraction for tracking the groups that an individual belongs to + + OID: n/a + Object class: Auxiliary + Parent: top + Schema: rfc2307bis + """ + + memberOf: list[str] # noqa: N815 diff --git a/apricot/models/overlay_oauthentry.py b/apricot/models/overlay_oauthentry.py new file mode 100644 index 0000000..3eabc37 --- /dev/null +++ b/apricot/models/overlay_oauthentry.py @@ -0,0 +1,15 @@ +from .named_ldap_class import NamedLDAPClass + + +class OverlayOAuthEntry(NamedLDAPClass): + """ + Abstraction for tracking an OAuth entry + + OID: n/a + Object class: Auxiliary + Parent: top + Schema: n/a + """ + + oauth_username: str | None = None + oauth_id: str diff --git a/apricot/oauth/__init__.py b/apricot/oauth/__init__.py index 8e47d4b..0cd8aa5 100644 --- a/apricot/oauth/__init__.py +++ b/apricot/oauth/__init__.py @@ -3,6 +3,7 @@ from .enums import OAuthBackend from .microsoft_entra_client import MicrosoftEntraClient from .oauth_client import OAuthClient +from .oauth_data_adaptor import OAuthDataAdaptor OAuthClientMap = {OAuthBackend.MICROSOFT_ENTRA: MicrosoftEntraClient} @@ -12,4 +13,5 @@ "OAuthBackend", "OAuthClient", "OAuthClientMap", + "OAuthDataAdaptor", ] diff --git a/apricot/oauth/microsoft_entra_client.py b/apricot/oauth/microsoft_entra_client.py index 2da34e7..4bc94c8 100644 --- a/apricot/oauth/microsoft_entra_client.py +++ b/apricot/oauth/microsoft_entra_client.py @@ -26,7 +26,7 @@ def __init__( def extract_token(self, json_response: JSONDict) -> str: return str(json_response["access_token"]) - def groups(self) -> list[dict[str, Any]]: + def groups(self) -> list[JSONDict]: output = [] try: queries = [ @@ -38,14 +38,15 @@ def groups(self) -> list[dict[str, Any]]: f"https://graph.microsoft.com/v1.0/groups?$select={','.join(queries)}" ) for group_dict in cast( - list[dict[str, Any]], + list[JSONDict], sorted(group_data["value"], key=lambda group: group["createdDateTime"]), ): group_uid = self.uid_cache.get_group_uid(group_dict["id"]) - attributes = {} + attributes: JSONDict = {} attributes["cn"] = group_dict.get("displayName", None) attributes["description"] = group_dict.get("id", None) attributes["gidNumber"] = group_uid + attributes["oauth_id"] = group_dict.get("id", None) # Add membership attributes members = self.query( f"https://graph.microsoft.com/v1.0/groups/{group_dict['id']}/members" @@ -55,16 +56,12 @@ def groups(self) -> list[dict[str, Any]]: 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[dict[str, Any]]: + def users(self) -> list[JSONDict]: output = [] try: queries = [ @@ -79,7 +76,7 @@ def users(self) -> list[dict[str, Any]]: f"https://graph.microsoft.com/v1.0/users?$select={','.join(queries)}" ) for user_dict in cast( - list[dict[str, Any]], + list[JSONDict], sorted(user_data["value"], key=lambda user: user["createdDateTime"]), ): # Get user attributes @@ -87,27 +84,19 @@ def users(self) -> list[dict[str, Any]]: surname = user_dict.get("surname", None) uid, domain = str(user_dict.get("userPrincipalName", "@")).split("@") user_uid = self.uid_cache.get_user_uid(user_dict["id"]) - attributes = {} - attributes["cn"] = user_dict.get("displayName", None) - attributes["description"] = user_dict.get("id", None) + attributes: JSONDict = {} + attributes["cn"] = uid if uid else None + attributes["description"] = user_dict.get("displayName", None) attributes["displayName"] = user_dict.get("displayName", None) attributes["domain"] = domain attributes["gidNumber"] = user_uid attributes["givenName"] = given_name if given_name else "" attributes["homeDirectory"] = f"/home/{uid}" if uid else None + attributes["oauth_id"] = user_dict.get("id", None) attributes["oauth_username"] = user_dict.get("userPrincipalName", None) attributes["sn"] = surname if surname else "" attributes["uid"] = uid if uid else None attributes["uidNumber"] = user_uid - # Add group attributes - group_memberships = self.query( - f"https://graph.microsoft.com/v1.0/users/{user_dict['id']}/memberOf" - ) - attributes["memberOf"] = [ - f"CN={group['displayName']},OU=groups,{self.root_dn}" - for group in group_memberships["value"] - if group["displayName"] - ] output.append(attributes) except KeyError: pass diff --git a/apricot/oauth/oauth_client.py b/apricot/oauth/oauth_client.py index 05c5719..857b553 100644 --- a/apricot/oauth/oauth_client.py +++ b/apricot/oauth/oauth_client.py @@ -9,21 +9,10 @@ LegacyApplicationClient, TokenExpiredError, ) -from pydantic import ValidationError from requests_oauthlib import OAuth2Session from twisted.python import log from apricot.cache import UidCache -from apricot.models import ( - LDAPAttributeAdaptor, - LDAPGroupOfNames, - LDAPInetOrgPerson, - LDAPInetUser, - LDAPOAuthUser, - LDAPPerson, - LDAPPosixAccount, - LDAPPosixGroup, -) from apricot.types import JSONDict @@ -35,7 +24,6 @@ def __init__( client_id: str, client_secret: str, debug: bool, # noqa: FBT001 - domain: str, redirect_uri: str, scopes: list[str], token_url: str, @@ -45,7 +33,6 @@ def __init__( self.bearer_token_: str | None = None self.client_secret = client_secret self.debug = debug - self.domain = domain self.token_url = token_url self.uid_cache = uid_cache # Allow token scope to not match requested scope. (Other auth libraries allow @@ -81,7 +68,9 @@ def __init__( @property def bearer_token(self) -> str: - """Return a bearer token, requesting a new one if necessary""" + """ + Return a bearer token, requesting a new one if necessary + """ try: if not self.bearer_token_: log.msg("Requesting a new authentication token from the OAuth backend.") @@ -104,27 +93,21 @@ def extract_token(self, json_response: JSONDict) -> str: pass @abstractmethod - def groups(self) -> list[dict[str, Any]]: + def groups(self) -> list[JSONDict]: """ - Return a list of group data - - Each return value should be a dict where 'None' is used to signify a missing value + Return JSON data about groups from the OAuth backend. + This should be a list of JSON dictionaries where 'None' is used to signify missing values. """ pass @abstractmethod - def users(self) -> list[dict[str, Any]]: + def users(self) -> list[JSONDict]: """ - Return a list of user data - - Each return value should be a dict where 'None' is used to signify a missing value + Return JSON data about users from the OAuth backend. + This should be a list of JSON dictionaries where 'None' is used to signify missing values. """ pass - @property - def root_dn(self) -> str: - return "DC=" + self.domain.replace(".", ",DC=") - def query(self, url: str) -> dict[str, Any]: """ Make a query against the OAuth backend @@ -147,89 +130,10 @@ def query_(url: str) -> requests.Response: result = query_(url) return result.json() # type: ignore - def validated_groups(self) -> list[LDAPAttributeAdaptor]: - """ - Validate output via pydantic and return a list of LDAPAttributeAdaptor - """ - if self.debug: - log.msg("Constructing and validating list of groups") - 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['cn']},OU=users,{self.root_dn}"] - # Group name is taken from 'cn' which should match the username - user_dict["cn"] = user_dict["uid"] - 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") - output.append(LDAPAttributeAdaptor(attributes)) - 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[LDAPAttributeAdaptor]: + def verify(self, username: str, password: str) -> bool: """ - Validate output via pydantic and return a list of LDAPAttributeAdaptor + Verify username and password by attempting to authenticate against the OAuth backend. """ - if self.debug: - log.msg("Constructing and validating list of users") - output = [] - for user_dict in self.users(): - try: - attributes = {"objectclass": ["top"]} - # Add user to self-titled group - user_dict["memberOf"].append( - f"CN={user_dict['cn']},OU=groups,{self.root_dn}" - ) - # 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") - # Add 'OAuthUser' attributes - oauth_user = LDAPOAuthUser(**user_dict) - attributes.update(oauth_user.model_dump()) - attributes["objectclass"].append("oauthUser") - output.append(LDAPAttributeAdaptor(attributes)) - 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/apricot/oauth/oauth_data_adaptor.py b/apricot/oauth/oauth_data_adaptor.py new file mode 100644 index 0000000..701e55a --- /dev/null +++ b/apricot/oauth/oauth_data_adaptor.py @@ -0,0 +1,219 @@ +from collections.abc import Sequence + +from pydantic import ValidationError +from twisted.python import log + +from apricot.models import ( + LDAPAttributeAdaptor, + LDAPGroupOfNames, + LDAPInetOrgPerson, + LDAPPosixAccount, + LDAPPosixGroup, + NamedLDAPClass, + OverlayMemberOf, + OverlayOAuthEntry, +) +from apricot.types import JSONDict + +from .oauth_client import OAuthClient + + +class OAuthDataAdaptor: + """Adaptor for converting raw user and group data into LDAP format.""" + + def __init__(self, domain: str, oauth_client: OAuthClient): + self.debug = oauth_client.debug + self.oauth_client = oauth_client + self.root_dn = "DC=" + domain.replace(".", ",DC=") + + # Retrieve and validate user and group information + annotated_groups, annotated_users = self._retrieve_entries() + self.validated_groups = self._validate_groups(annotated_groups) + self.validated_users = self._validate_users(annotated_users) + if self.debug: + log.msg( + f"Validated {len(self.validated_groups)} groups and {len(self.validated_users)} users." + ) + + @property + def groups(self) -> list[LDAPAttributeAdaptor]: + """ + Return a list of LDAPAttributeAdaptors representing validated group data. + """ + return self.validated_groups + + @property + def users(self) -> list[LDAPAttributeAdaptor]: + """ + Return a list of LDAPAttributeAdaptors representing validated user data. + """ + return self.validated_users + + def _dn_from_group_cn(self, group_cn: str) -> str: + return f"CN={group_cn},OU=groups,{self.root_dn}" + + def _dn_from_user_cn(self, user_cn: str) -> str: + return f"CN={user_cn},OU=users,{self.root_dn}" + + def _extract_attributes( + self, + input_dict: JSONDict, + required_classes: Sequence[type[NamedLDAPClass]], + ) -> LDAPAttributeAdaptor: + """Add appropriate LDAP class attributes""" + attributes = {"objectclass": ["top"]} + for ldap_class in required_classes: + model = ldap_class(**input_dict) + attributes.update(model.model_dump()) + attributes["objectclass"] += model.names() + return LDAPAttributeAdaptor(attributes) + + def _retrieve_entries( + self, + ) -> tuple[ + list[tuple[JSONDict, list[type[NamedLDAPClass]]]], + list[tuple[JSONDict, list[type[NamedLDAPClass]]]], + ]: + """ + Obtain lists of users and groups, and construct necessary meta-entries. + """ + # Get the initial set of users and groups + oauth_groups = self.oauth_client.groups() + oauth_users = self.oauth_client.users() + if self.debug: + log.msg( + f"Loaded {len(oauth_groups)} groups and {len(oauth_users)} users from OAuth client." + ) + + # Ensure member is set for groups + for group_dict in oauth_groups: + group_dict["member"] = [ + self._dn_from_user_cn(user_cn) for user_cn in group_dict["memberUid"] + ] + + # Add one self-titled group for each user + # Group name is taken from 'cn' which should match the username + user_primary_groups = [] + for user in oauth_users: + group_dict = {} + for attr in ("cn", "description", "gidNumber"): + group_dict[attr] = user[attr] + group_dict["member"] = [self._dn_from_user_cn(user["cn"])] + group_dict["memberUid"] = [user["cn"]] + user_primary_groups.append(group_dict) + + # Add one group of groups for each existing group. + # Its members are the primary user groups for each original group member. + groups_of_groups = [] + for group in oauth_groups: + group_dict = {} + group_dict["cn"] = f"Primary user groups for {group['cn']}" + group_dict["description"] = ( + f"Primary user groups for members of '{group['cn']}'" + ) + # Replace each member user with a member group + group_dict["member"] = [ + str(member).replace("OU=users", "OU=groups") + for member in group["member"] + ] + # Groups do not have UIDs so memberUid must be empty + group_dict["memberUid"] = [] + groups_of_groups.append(group_dict) + + # Ensure memberOf is set correctly for users + for child_dict in oauth_users: + child_dn = self._dn_from_user_cn(child_dict["cn"]) + child_dict["memberOf"] = [ + self._dn_from_group_cn(parent_dict["cn"]) + for parent_dict in oauth_groups + user_primary_groups + groups_of_groups + if child_dn in parent_dict["member"] + ] + + # Ensure memberOf is set correctly for groups + for child_dict in oauth_groups + user_primary_groups + groups_of_groups: + child_dn = self._dn_from_group_cn(child_dict["cn"]) + child_dict["memberOf"] = [ + self._dn_from_group_cn(parent_dict["cn"]) + for parent_dict in oauth_groups + user_primary_groups + groups_of_groups + if child_dn in parent_dict["member"] + ] + + # Annotate group and user dicts with the appropriate LDAP classes + annotated_groups = [ + ( + group, + [LDAPGroupOfNames, LDAPPosixGroup, OverlayMemberOf, OverlayOAuthEntry], + ) + for group in oauth_groups + ] + annotated_groups += [ + (group, [LDAPGroupOfNames, LDAPPosixGroup, OverlayMemberOf]) + for group in user_primary_groups + ] + annotated_groups += [ + (group, [LDAPGroupOfNames, OverlayMemberOf]) for group in groups_of_groups + ] + annotated_users = [ + ( + user, + [ + LDAPInetOrgPerson, + LDAPPosixAccount, + OverlayMemberOf, + OverlayOAuthEntry, + ], + ) + for user in oauth_users + ] + return (annotated_groups, annotated_users) + + def _validate_groups( + self, annotated_groups: list[tuple[JSONDict, list[type[NamedLDAPClass]]]] + ) -> list[LDAPAttributeAdaptor]: + """ + Return a list of LDAPAttributeAdaptors representing validated group data. + """ + if self.debug: + log.msg(f"Attempting to validate {len(annotated_groups)} groups.") + output = [] + for group_dict, required_classes in annotated_groups: + try: + output.append( + self._extract_attributes( + group_dict, + required_classes=required_classes, + ) + ) + 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 _validate_users( + self, annotated_users: list[tuple[JSONDict, list[type[NamedLDAPClass]]]] + ) -> list[LDAPAttributeAdaptor]: + """ + Return a list of LDAPAttributeAdaptors representing validated user data. + """ + if self.debug: + log.msg(f"Attempting to validate {len(annotated_users)} users.") + output = [] + for user_dict, required_classes in annotated_users: + try: + output.append( + self._extract_attributes( + user_dict, required_classes=required_classes + ) + ) + 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 diff --git a/apricot/types.py b/apricot/types.py index 1814961..e93f9ea 100644 --- a/apricot/types.py +++ b/apricot/types.py @@ -1,5 +1,5 @@ from typing import Any -JSONDict = dict[str, str | list[str]] +JSONDict = dict[str, Any] LDAPAttributeDict = dict[str, list[str]] LDAPControlTuple = tuple[str, bool, Any]