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]