From fb0f4250a000aa870010812081c244f99d4d042e Mon Sep 17 00:00:00 2001 From: James Robinson Date: Mon, 13 Jan 2025 15:57:08 +0000 Subject: [PATCH 1/7] :recycle: Support --keycloak-domain-attribute in Docker and drop unnecessary Keycloak setup steps --- README.md | 58 ++++++++++++++++---------------- apricot/oauth/keycloak_client.py | 20 ++++++----- apricot/oauth/oauth_client.py | 6 ++-- docker/entrypoint.sh | 3 ++ 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3ed3bab..58d8c56 100644 --- a/README.md +++ b/README.md @@ -207,36 +207,36 @@ You will need to use the following command line arguments: --keycloak-realm "" ``` +#### User attribute + +You will need to add a custom attribute to each user you want Apricot to use. +The name of this attribute should be used as the value of the `--keycloak-domain-attribute` argument above. +The value of this attribute should be the same as the `--domain` argument to Apricot. + +Any users with this attribute missing or set to something else will be ignored by Apricot. +This allows you to attach multiple Apricot servers to the same Keycloak instance, each with their own set of users. + +#### Client application + You will need to register an application to interact with `Keycloak`. Do this as follows: -- Under the realm option `Client scopes` create a new scope, e.g. `domainScope` with: - - Type: `Default` - - Include in token scope: `true` - - Save -- In the created scope click `Mappers` > `Configure new mapper` and now create either - - `Hardcoded claim` - - => Every user gets the same domain - - name: `domain` - - token claim name: `domain` - - claim value: `` - - `User attribute` - - => Every user has an attribute for the domain - - name: `domain` - - user attribute: `` - - token claim name: `domain` - Create a new `Client` in your `Keycloak` instance. - - Set the name to whatever you choose (e.g. `apricot`) - - Enable `Client authentication` - - Enable the following authentication flows and disable the rest: - - Direct access grants - - Service account roles -- Under `Credentials` copy `client secret` -- Under `Service account roles`: - - Click on `Assign role` then `Filter by clients` - - Assign the following roles: - - `realm-management` > `view-users` - - `realm-management` > `manage-users` - - `realm-management` > `query-groups` - - `realm-management` > `query-users` -- Under `Client scopes` click `Add client scope` > `domainScope`. Make sure to select type `Default` + - Under `General Settings`: + - Client type: `OpenID Connect` + - Client name: `apricot` + - Under `Capability config` + - Enable `Client authentication` + - Enable the following authentication flows and disable the rest: + - `Direct access grants` + - `Service accounts roles` + - Save the client +- For the client you have just created + - Under `Credentials` copy `client secret` + - Under `Service accounts roles`: + - Click on `Assign role` then `Filter by clients` + - Assign the following roles: + - `realm-management` > `view-users` + - `realm-management` > `manage-users` + - `realm-management` > `query-groups` + - `realm-management` > `query-users` diff --git a/apricot/oauth/keycloak_client.py b/apricot/oauth/keycloak_client.py index 660553d..96b4236 100644 --- a/apricot/oauth/keycloak_client.py +++ b/apricot/oauth/keycloak_client.py @@ -75,9 +75,7 @@ def groups(self: Self) -> list[JSONDict]: group_dict["id"], int(group_gid[0], 10), ) - - # Read group attributes - for group_dict in group_data: + # Set group attributes if not group_dict["attributes"]["gid"]: group_dict["attributes"]["gid"] = [ str(self.uid_cache.get_group_uid(group_dict["id"])), @@ -87,6 +85,9 @@ def groups(self: Self) -> list[JSONDict]: method="PUT", json=group_dict, ) + + # Read group attributes + for group_dict in group_data: attributes: JSONDict = {} attributes["cn"] = group_dict.get("name", None) attributes["description"] = group_dict.get("id", None) @@ -133,12 +134,7 @@ def users(self: Self) -> list[JSONDict]: user_dict["id"], int(user_uid[0], 10), ) - - # Read user attributes - for user_dict in sorted( - user_data, - key=operator.itemgetter("createdTimestamp"), - ): + # Set user attributes if not user_dict["attributes"]["uid"]: user_dict["attributes"]["uid"] = [ str(self.uid_cache.get_user_uid(user_dict["id"])), @@ -148,6 +144,12 @@ def users(self: Self) -> list[JSONDict]: method="PUT", json=user_dict, ) + + # Read user attributes + for user_dict in sorted( + user_data, + key=operator.itemgetter("createdTimestamp"), + ): # Get user attributes first_name = user_dict.get("firstName", None) last_name = user_dict.get("lastName", None) diff --git a/apricot/oauth/oauth_client.py b/apricot/oauth/oauth_client.py index 8536f46..6857626 100644 --- a/apricot/oauth/oauth_client.py +++ b/apricot/oauth/oauth_client.py @@ -151,7 +151,7 @@ def request( ) -> dict[str, Any]: """Make a request to the OAuth backend.""" - def query_(*args: Any, **kwargs: Any) -> requests.Response: + def request_(*args: Any, **kwargs: Any) -> requests.Response: return self.session_application.request( # type: ignore[no-any-return] method, *args, @@ -160,12 +160,12 @@ def query_(*args: Any, **kwargs: Any) -> requests.Response: ) try: - result = query_(*args, **kwargs) + result = request_(*args, **kwargs) result.raise_for_status() except (TokenExpiredError, requests.exceptions.HTTPError) as exc: log.msg(f"Authentication token is invalid.\n{exc!s}") self.bearer_token_ = None - result = query_(*args, **kwargs) + result = request_(*args, **kwargs) if result.status_code == HTTPStatus.NO_CONTENT: return {} return result.json() # type: ignore[no-any-return] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4739a1f..4be7291 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -56,6 +56,9 @@ if [ -n "${KEYCLOAK_BASE_URL}" ]; then fi EXTRA_OPTS="${EXTRA_OPTS} --keycloak-base-url $KEYCLOAK_BASE_URL --keycloak-realm $KEYCLOAK_REALM" fi +if [ -n "${KEYCLOAK_DOMAIN_ATTRIBUTE}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --keycloak-domain-attribute $KEYCLOAK_DOMAIN_ATTRIBUTE" +fi # LDAP refresh arguments From d7fb28dc0aa2c54f16871d48c7cd2694bd3ddd7d Mon Sep 17 00:00:00 2001 From: James Robinson Date: Mon, 13 Jan 2025 16:27:08 +0000 Subject: [PATCH 2/7] :memo: Update README for clarity that the full DN must be used when querying the LDAP server --- README.md | 194 +++++++++++++++++++++++++++++------------------------- 1 file changed, 106 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 58d8c56..5f1a2f8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The name is a slightly tortured acronym for: LD**A**P **pr**oxy for Open**I**D * Start the `Apricot` server on port 1389 by running: ```bash -python run.py --client-id "" --client-secret "" --backend "" --port 1389 --domain "" --redis-host "" +python run.py --client-id "" --client-secret "" --backend "" --port "" --domain "" --redis-host "" ``` If you prefer to use Docker, you can edit `docker/docker-compose.yaml` and run: @@ -41,14 +41,14 @@ To enable it you need to configure the tls port ex. `--tls-port=1636`, and provi This will create an LDAP tree that looks like this: ```ldif -dn: DC= +dn: DC=example,DC=com objectClass: dcObject -dn: OU=users,DC= +dn: OU=users,DC=example,DC=com objectClass: organizationalUnit ou: users -dn: OU=groups,DC= +dn: OU=groups,DC=example,DC=com objectClass: organizationalUnit ou: groups ``` @@ -56,7 +56,7 @@ ou: groups Each user will have an entry like ```ldif -dn: CN=,OU=users,DC= +dn: CN=,OU=users,DC=example,DC=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person @@ -69,7 +69,7 @@ memberOf: Each group will have an entry like ```ldif -dn: CN=,OU=groups,DC= +dn: CN=,OU=groups,DC=example,DC=com objectClass: groupOfNames objectClass: posixGroup objectClass: top @@ -77,92 +77,18 @@ objectClass: top member: ``` -## Primary groups +## Querying the server -:exclamation: You can disable the creation of mirrored groups with the `--disable-primary-groups` command line option :exclamation: +Anonymous queries are enabled by default. -Apricot creates an associated group for each user, which acts as its POSIX user primary group. - -For example: - -```ldif -dn: CN=sherlock.holmes,OU=users,DC= -objectClass: inetOrgPerson -objectClass: organizationalPerson -objectClass: person -objectClass: posixAccount -objectClass: top -... -memberOf: CN=sherlock.holmes,OU=groups,DC= -... -``` - -will have an associated group - -```ldif -dn: CN=sherlock.holmes,OU=groups,DC= -objectClass: groupOfNames -objectClass: posixGroup -objectClass: top -... -member: CN=sherlock.holmes,OU=users,DC= -... -``` - -## Mirrored groups - -:exclamation: You can disable the creation of mirrored groups with the `--disable-mirrored-groups` command line option :exclamation: - -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= -objectClass: groupOfNames -objectClass: posixGroup -objectClass: top -... -member: CN=sherlock.holmes,OU=users,DC= -... -``` - -will have an associated group-of-groups - -```ldif -dn: CN=Primary user groups for Detectives,OU=groups,DC= -objectClass: groupOfNames -objectClass: top -... -member: CN=sherlock.holmes,OU=groups,DC= -... -``` - -This allows a user to make a request for "all primary user groups needed by members of group X" without getting a large number of primary user groups for unrelated users. To do this, you will need an LDAP request that looks like: - -```ldif -(&(objectClass=posixGroup)(|(CN=Detectives)(memberOf=Primary user groups for Detectives))) +```bash +ldapsearch -H ldap://: -x -b "DC=example,DC=com" ``` -which will return: +If you want to query on behalf of a particular user you will need to use the full distinguished name. -```ldif -dn:CN=Detectives,OU=groups,DC= -objectClass: groupOfNames -objectClass: posixGroup -objectClass: top -... -member: CN=sherlock.holmes,OU=users,DC= -... - -dn: CN=sherlock.holmes,OU=groups,DC= -objectClass: groupOfNames -objectClass: posixGroup -objectClass: top -... -member: CN=sherlock.holmes,OU=users,DC= -... +```bash +ldapsearch -H ldap://: -x -b "DC=example,DC=com" -D "CN=,OU=users,DC=example,DC=com" ``` ## OpenID Connect @@ -207,7 +133,7 @@ You will need to use the following command line arguments: --keycloak-realm "" ``` -#### User attribute +#### User domain attribute You will need to add a custom attribute to each user you want Apricot to use. The name of this attribute should be used as the value of the `--keycloak-domain-attribute` argument above. @@ -240,3 +166,95 @@ Do this as follows: - `realm-management` > `manage-users` - `realm-management` > `query-groups` - `realm-management` > `query-users` + +## Disabling Apricot groups + +### Primary groups + +Apricot creates an associated group for each user, which acts as its POSIX user primary group. + +For example: + +```ldif +dn: CN=sherlock.holmes,OU=users,DC=example,DC=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: posixAccount +objectClass: top +... +memberOf: CN=sherlock.holmes,OU=groups,DC=example,DC=com +... +``` + +will have an associated group + +```ldif +dn: CN=sherlock.holmes,OU=groups,DC=example,DC=com +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=example,DC=com +... +``` + +:exclamation: You can disable the creation of these groups with the `--disable-primary-groups` command line option :exclamation: + + +## Mirrored groups + +Apricot creates a group-of-groups for each group of users. +This simply contains the primary group for each user in the original group. +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=example,DC=com +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=example,DC=com +... +``` + +will have an associated group-of-groups + +```ldif +dn: CN=Primary user groups for Detectives,OU=groups,DC=example,DC=com +objectClass: groupOfNames +objectClass: top +... +member: CN=sherlock.holmes,OU=groups,DC=example,DC=com +... +``` + +This allows a user to make a request for "all primary user groups needed by members of group X" without getting a large number of primary user groups for unrelated users. To do this, you will need an LDAP request that looks like: + +```ldif +(&(objectClass=posixGroup)(|(CN=Detectives)(memberOf=Primary user groups for Detectives))) +``` + +which will return: + +```ldif +dn:CN=Detectives,OU=groups,DC=example,DC=com +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=example,DC=com +... + +dn: CN=sherlock.holmes,OU=groups,DC=example,DC=com +objectClass: groupOfNames +objectClass: posixGroup +objectClass: top +... +member: CN=sherlock.holmes,OU=users,DC=example,DC=com +... +``` + +:exclamation: You can disable the creation of mirrored groups with the `--disable-mirrored-groups` command line option :exclamation: From d145c4162046291521497ea9198edc8a922025ae Mon Sep 17 00:00:00 2001 From: James Robinson Date: Tue, 14 Jan 2025 13:11:24 +0000 Subject: [PATCH 3/7] :recycle: Move data adaptor refresh into a dedicated method --- apricot/ldap/oauth_ldap_tree.py | 1 + apricot/oauth/oauth_data_adaptor.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index f27cb53..cfe94f6 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -77,6 +77,7 @@ def refresh(self: Self) -> None: self.oauth_client, enable_mirrored_groups=self.enable_mirrored_groups, ) + oauth_adaptor.refresh() # Create a root node for the tree log.msg("Rebuilding LDAP tree.") diff --git a/apricot/oauth/oauth_data_adaptor.py b/apricot/oauth/oauth_data_adaptor.py index 274190f..3dc2db5 100644 --- a/apricot/oauth/oauth_data_adaptor.py +++ b/apricot/oauth/oauth_data_adaptor.py @@ -40,19 +40,11 @@ def __init__( @param oauth_client: An OAuth client used to construct the LDAP tree """ self.debug = oauth_client.debug + self.domain = domain self.oauth_client = oauth_client self.root_dn = "DC=" + domain.replace(".", ",DC=") self.enable_mirrored_groups = enable_mirrored_groups - # 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, domain) - if self.debug: - log.msg( - f"Validated {len(self.validated_groups)} groups and {len(self.validated_users)} users.", - ) - @property def groups(self: Self) -> list[LDAPAttributeAdaptor]: """Return a list of LDAPAttributeAdaptors representing validated group data.""" @@ -233,3 +225,13 @@ def _validate_users( f" -> '{error['loc'][0]}': {error['msg']} but '{error['input']}' was provided.", ) return output + + def refresh(self) -> None: + """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, self.domain) + if self.debug: + log.msg( + f"Validated {len(self.validated_groups)} groups and {len(self.validated_users)} users.", + ) From 7521a454e750718315e53993505ed605e0474cf5 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Tue, 14 Jan 2025 13:19:59 +0000 Subject: [PATCH 4/7] :recycle: Create a single OAuthDataAdaptor at server-level that is reused throughout --- apricot/apricot_server.py | 18 ++++++++----- apricot/ldap/oauth_ldap_server_factory.py | 13 ++++------ apricot/ldap/oauth_ldap_tree.py | 31 +++++++++-------------- 3 files changed, 29 insertions(+), 33 deletions(-) diff --git a/apricot/apricot_server.py b/apricot/apricot_server.py index 577ff88..3df30ca 100644 --- a/apricot/apricot_server.py +++ b/apricot/apricot_server.py @@ -11,7 +11,7 @@ from apricot.cache import LocalCache, RedisCache, UidCache from apricot.ldap import OAuthLDAPServerFactory -from apricot.oauth import OAuthBackend, OAuthClientMap +from apricot.oauth import OAuthBackend, OAuthClientMap, OAuthDataAdaptor class ApricotServer: @@ -69,7 +69,7 @@ def __init__( log.msg("Using a local user-id cache.") uid_cache = LocalCache() - # Initialize the appropriate OAuth client + # Initialise the appropriate OAuth client try: if self.debug: log.msg(f"Creating an OAuthClient for {backend}.") @@ -88,24 +88,30 @@ def __init__( msg = f"Could not construct an OAuth client for the '{backend}' backend.\n{exc!s}" raise ValueError(msg) from exc + # Initialise the OAuth data adaptor + oauth_adaptor = OAuthDataAdaptor( + domain, + oauth_client, + enable_mirrored_groups=enable_mirrored_groups, + ) + # Create an LDAPServerFactory if self.debug: log.msg("Creating an LDAPServerFactory.") factory = OAuthLDAPServerFactory( - domain, + oauth_adaptor, oauth_client, background_refresh=background_refresh, - enable_mirrored_groups=enable_mirrored_groups, refresh_interval=refresh_interval, ) if background_refresh: if self.debug: log.msg( - f"Starting background refresh (interval={factory.adaptor.refresh_interval})", + f"Starting background refresh (interval={refresh_interval})", ) loop = task.LoopingCall(factory.adaptor.refresh) - loop.start(factory.adaptor.refresh_interval) + loop.start(refresh_interval) # 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 0744c22..e7b828a 100644 --- a/apricot/ldap/oauth_ldap_server_factory.py +++ b/apricot/ldap/oauth_ldap_server_factory.py @@ -3,7 +3,7 @@ from twisted.internet.interfaces import IAddress from twisted.internet.protocol import Protocol, ServerFactory -from apricot.oauth import OAuthClient +from apricot.oauth import OAuthClient, OAuthDataAdaptor from .oauth_ldap_tree import OAuthLDAPTree from .read_only_ldap_server import ReadOnlyLDAPServer @@ -14,27 +14,24 @@ class OAuthLDAPServerFactory(ServerFactory): def __init__( self: Self, - domain: str, + oauth_adaptor: OAuthDataAdaptor, oauth_client: OAuthClient, *, background_refresh: bool, - enable_mirrored_groups: bool, refresh_interval: int, ) -> None: """Initialise an OAuthLDAPServerFactory. @param background_refresh: Whether to refresh the LDAP tree in the background rather than on access - @param domain: The root domain of the LDAP tree - @param enable_mirrored_groups: Create a mirrored LDAP group-of-groups for each group-of-users - @param oauth_client: An OAuth client used to construct the LDAP tree + @param oauth_adaptor: An OAuth data adaptor used to construct the LDAP tree + @param oauth_client: An OAuth client used to retrieve user and group data @param refresh_interval: Interval in seconds after which the tree must be refreshed """ # Create an LDAP lookup tree self.adaptor = OAuthLDAPTree( - domain, + oauth_adaptor, oauth_client, background_refresh=background_refresh, - enable_mirrored_groups=enable_mirrored_groups, refresh_interval=refresh_interval, ) diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index cfe94f6..62c9132 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -9,11 +9,12 @@ from zope.interface import implementer from apricot.ldap.oauth_ldap_entry import OAuthLDAPEntry -from apricot.oauth import OAuthClient, OAuthDataAdaptor if TYPE_CHECKING: from twisted.internet import defer + from apricot.oauth import OAuthClient, OAuthDataAdaptor + @implementer(IConnectedLDAPEntry) class OAuthLDAPTree: @@ -21,26 +22,23 @@ class OAuthLDAPTree: def __init__( self: Self, - domain: str, + oauth_adaptor: OAuthDataAdaptor, oauth_client: OAuthClient, *, background_refresh: bool, - enable_mirrored_groups: bool, refresh_interval: int, ) -> None: """Initialise an OAuthLDAPTree. @param background_refresh: Whether to refresh the LDAP tree in the background rather than on access - @param domain: The root domain of the LDAP tree - @param enable_mirrored_groups: Create a mirrored LDAP group-of-groups for each group-of-users - @param oauth_client: An OAuth client used to construct the LDAP tree + @param oauth_adaptor: An OAuth data adaptor used to construct the LDAP tree + @param oauth_client: An OAuth client used to retrieve user and group data @param refresh_interval: Interval in seconds after which the tree must be refreshed """ self.background_refresh = background_refresh self.debug = oauth_client.debug - self.domain = domain - self.enable_mirrored_groups = enable_mirrored_groups self.last_update = time.monotonic() + self.oauth_adaptor = oauth_adaptor self.oauth_client = oauth_client self.refresh_interval = refresh_interval self.root_: OAuthLDAPEntry | None = None @@ -72,17 +70,12 @@ def refresh(self: Self) -> None: ): # Update users and groups from the OAuth server log.msg("Retrieving OAuth data.") - oauth_adaptor = OAuthDataAdaptor( - self.domain, - self.oauth_client, - enable_mirrored_groups=self.enable_mirrored_groups, - ) - oauth_adaptor.refresh() + self.oauth_adaptor.refresh() # Create a root node for the tree log.msg("Rebuilding LDAP tree.") self.root_ = OAuthLDAPEntry( - dn=oauth_adaptor.root_dn, + dn=self.oauth_adaptor.root_dn, attributes={"objectClass": ["dcObject"]}, oauth_client=self.oauth_client, ) @@ -100,9 +93,9 @@ def refresh(self: Self) -> None: # Add groups to the groups OU if self.debug: log.msg( - f"Attempting to add {len(oauth_adaptor.groups)} groups to the LDAP tree.", + f"Attempting to add {len(self.oauth_adaptor.groups)} groups to the LDAP tree.", ) - for group_attrs in oauth_adaptor.groups: + for group_attrs in self.oauth_adaptor.groups: groups_ou.add_child(f"CN={group_attrs.cn}", group_attrs.to_dict()) if self.debug: children = groups_ou.list_children() @@ -113,9 +106,9 @@ def refresh(self: Self) -> None: # Add users to the users OU if self.debug: log.msg( - f"Attempting to add {len(oauth_adaptor.users)} users to the LDAP tree.", + f"Attempting to add {len(self.oauth_adaptor.users)} users to the LDAP tree.", ) - for user_attrs in oauth_adaptor.users: + for user_attrs in self.oauth_adaptor.users: users_ou.add_child(f"CN={user_attrs.cn}", user_attrs.to_dict()) if self.debug: children = users_ou.list_children() From 9ebf429d5f05498a8005aadf0ccf01bf6b5d715f Mon Sep 17 00:00:00 2001 From: James Robinson Date: Tue, 14 Jan 2025 13:26:53 +0000 Subject: [PATCH 5/7] :recycle: Simplify OAuthDataAdaptor interface so that retrieve_all method returns users and groups directly --- apricot/ldap/oauth_ldap_tree.py | 42 ++++++++++++++--------------- apricot/oauth/oauth_data_adaptor.py | 23 ++++++---------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index 62c9132..e46cf1c 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -62,6 +62,22 @@ def root(self: Self) -> OAuthLDAPEntry: raise ValueError(msg) return self.root_ + def __repr__(self: Self) -> str: + return f"{self.__class__.__name__} with backend {self.oauth_client.__class__.__name__}" + + def lookup(self: Self, dn: DistinguishedName | str) -> defer.Deferred[ILDAPEntry]: + """Lookup the referred to by dn. + + @return: A Deferred returning an ILDAPEntry. + + @raises: LDAPNoSuchObject. + """ + if not isinstance(dn, DistinguishedName): + dn = DistinguishedName(stringValue=dn) + if self.debug: + log.msg(f"Starting an LDAP lookup for '{dn.getText()}'.") + return self.root.lookup(dn) + def refresh(self: Self) -> None: """Refresh the LDAP tree.""" if ( @@ -70,7 +86,7 @@ def refresh(self: Self) -> None: ): # Update users and groups from the OAuth server log.msg("Retrieving OAuth data.") - self.oauth_adaptor.refresh() + oauth_groups, oauth_users = self.oauth_adaptor.retrieve_all() # Create a root node for the tree log.msg("Rebuilding LDAP tree.") @@ -93,9 +109,9 @@ def refresh(self: Self) -> None: # Add groups to the groups OU if self.debug: log.msg( - f"Attempting to add {len(self.oauth_adaptor.groups)} groups to the LDAP tree.", + f"Attempting to add {len(oauth_groups)} groups to the LDAP tree.", ) - for group_attrs in self.oauth_adaptor.groups: + for group_attrs in oauth_groups: groups_ou.add_child(f"CN={group_attrs.cn}", group_attrs.to_dict()) if self.debug: children = groups_ou.list_children() @@ -106,9 +122,9 @@ def refresh(self: Self) -> None: # Add users to the users OU if self.debug: log.msg( - f"Attempting to add {len(self.oauth_adaptor.users)} users to the LDAP tree.", + f"Attempting to add {len(oauth_users)} users to the LDAP tree.", ) - for user_attrs in self.oauth_adaptor.users: + for user_attrs in oauth_users: users_ou.add_child(f"CN={user_attrs.cn}", user_attrs.to_dict()) if self.debug: children = users_ou.list_children() @@ -119,19 +135,3 @@ def refresh(self: Self) -> None: # Set last updated time log.msg("Finished building LDAP tree.") self.last_update = time.monotonic() - - def __repr__(self: Self) -> str: - return f"{self.__class__.__name__} with backend {self.oauth_client.__class__.__name__}" - - def lookup(self: Self, dn: DistinguishedName | str) -> defer.Deferred[ILDAPEntry]: - """Lookup the referred to by dn. - - @return: A Deferred returning an ILDAPEntry. - - @raises: LDAPNoSuchObject. - """ - if not isinstance(dn, DistinguishedName): - dn = DistinguishedName(stringValue=dn) - if self.debug: - log.msg(f"Starting an LDAP lookup for '{dn.getText()}'.") - return self.root.lookup(dn) diff --git a/apricot/oauth/oauth_data_adaptor.py b/apricot/oauth/oauth_data_adaptor.py index 3dc2db5..1a3262e 100644 --- a/apricot/oauth/oauth_data_adaptor.py +++ b/apricot/oauth/oauth_data_adaptor.py @@ -45,16 +45,6 @@ def __init__( self.root_dn = "DC=" + domain.replace(".", ",DC=") self.enable_mirrored_groups = enable_mirrored_groups - @property - def groups(self: Self) -> list[LDAPAttributeAdaptor]: - """Return a list of LDAPAttributeAdaptors representing validated group data.""" - return self.validated_groups - - @property - def users(self: Self) -> list[LDAPAttributeAdaptor]: - """Return a list of LDAPAttributeAdaptors representing validated user data.""" - return self.validated_users - def _dn_from_group_cn(self: Self, group_cn: str) -> str: return f"CN={group_cn},OU=groups,{self.root_dn}" @@ -226,12 +216,15 @@ def _validate_users( ) return output - def refresh(self) -> None: - """Retrieve and validate user and group information.""" + def retrieve_all( + self, + ) -> tuple[list[LDAPAttributeAdaptor], list[LDAPAttributeAdaptor]]: + """Retrieve and return validated 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, self.domain) + validated_groups = self._validate_groups(annotated_groups) + validated_users = self._validate_users(annotated_users, self.domain) if self.debug: log.msg( - f"Validated {len(self.validated_groups)} groups and {len(self.validated_users)} users.", + f"Validated {len(validated_groups)} groups and {len(validated_users)} users.", ) + return (validated_groups, validated_users) From 2d0867844443f0b40011ffa34a460114351ffae6 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Tue, 14 Jan 2025 15:51:47 +0000 Subject: [PATCH 6/7] :wrench: Group arguments by category in both Docker and run.py --- README.md | 2 + apricot/apricot_server.py | 5 +- apricot/oauth/oauth_data_adaptor.py | 31 +++++++---- docker/entrypoint.sh | 79 ++++++++++++++------------ run.py | 86 +++++++++++++++++++---------- 5 files changed, 125 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 5f1a2f8..fe95c65 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ The value of this attribute should be the same as the `--domain` argument to Apr Any users with this attribute missing or set to something else will be ignored by Apricot. This allows you to attach multiple Apricot servers to the same Keycloak instance, each with their own set of users. +:exclamation: You can disable user domain verification with the `--disable-user-domain-verification` command line option :exclamation: + #### Client application You will need to register an application to interact with `Keycloak`. diff --git a/apricot/apricot_server.py b/apricot/apricot_server.py index 3df30ca..c8aed64 100644 --- a/apricot/apricot_server.py +++ b/apricot/apricot_server.py @@ -28,6 +28,7 @@ def __init__( background_refresh: bool = False, debug: bool = False, enable_mirrored_groups: bool = True, + enable_user_domain_verification: bool = True, redis_host: str | None = None, redis_port: int | None = None, refresh_interval: int = 60, @@ -45,7 +46,8 @@ def __init__( @param port: Port to expose LDAP on @param background_refresh: Whether to refresh the LDAP tree in the background @param debug: Enable debug output - @param enable_mirrored_groups: Create a mirrored LDAP group-of-groups for each group-of-users + @param enable_mirrored_groups: Whether to create a mirrored LDAP group-of-groups for each group-of-users + @param enable_user_domain_verification: Whether to verify users belong to the correct domain @param redis_host: Host for a Redis cache (if used) @param redis_port: Port for a Redis cache (if used) @param refresh_interval: Interval after which the LDAP information is stale @@ -93,6 +95,7 @@ def __init__( domain, oauth_client, enable_mirrored_groups=enable_mirrored_groups, + enable_user_domain_verification=enable_user_domain_verification, ) # Create an LDAPServerFactory diff --git a/apricot/oauth/oauth_data_adaptor.py b/apricot/oauth/oauth_data_adaptor.py index 1a3262e..23d549d 100644 --- a/apricot/oauth/oauth_data_adaptor.py +++ b/apricot/oauth/oauth_data_adaptor.py @@ -32,11 +32,13 @@ def __init__( oauth_client: OAuthClient, *, enable_mirrored_groups: bool, + enable_user_domain_verification: bool, ) -> None: """Initialise an OAuthDataAdaptor. @param domain: The root domain of the LDAP tree - @param enable_mirrored_groups: Create a mirrored LDAP group-of-groups for each group-of-users + @param enable_mirrored_groups: Whether to create a mirrored LDAP group-of-groups for each group-of-users + @param enable_user_domain_verification: Whether to verify users belong to the correct domain @param oauth_client: An OAuth client used to construct the LDAP tree """ self.debug = oauth_client.debug @@ -44,6 +46,7 @@ def __init__( self.oauth_client = oauth_client self.root_dn = "DC=" + domain.replace(".", ",DC=") self.enable_mirrored_groups = enable_mirrored_groups + self.enable_user_domain_verification = enable_user_domain_verification def _dn_from_group_cn(self: Self, group_cn: str) -> str: return f"CN={group_cn},OU=groups,{self.root_dn}" @@ -187,7 +190,6 @@ def _validate_groups( def _validate_users( self: Self, annotated_users: list[tuple[JSONDict, list[type[LDAPObjectClass]]]], - domain: str, ) -> list[LDAPAttributeAdaptor]: """Return a list of LDAPAttributeAdaptors representing validated user data.""" if self.debug: @@ -196,18 +198,23 @@ def _validate_users( for user_dict, required_classes in annotated_users: name = user_dict.get("cn", "unknown") try: - if (user_domain := user_dict.get("domain", None)) == domain: - output.append( - LDAPAttributeAdaptor.from_attributes( - user_dict, - required_classes=required_classes, - ), - ) - else: + # Verify user domain if enabled + if ( + self.enable_user_domain_verification + and (user_domain := user_dict.get("domain", None)) != self.domain + ): log.msg(f"... user '{name}' failed validation.") log.msg( - f" -> 'domain': expected '{domain}' but '{user_domain}' was provided.", + f" -> 'domain': expected '{self.domain}' but '{user_domain}' was provided.", ) + continue + # Construct an LDAPAttributeAdaptor from the user attributes + output.append( + LDAPAttributeAdaptor.from_attributes( + user_dict, + required_classes=required_classes, + ), + ) except ValidationError as exc: log.msg(f"... user '{name}' failed validation.") for error in exc.errors(): @@ -222,7 +229,7 @@ def retrieve_all( """Retrieve and return validated user and group information.""" annotated_groups, annotated_users = self._retrieve_entries() validated_groups = self._validate_groups(annotated_groups) - validated_users = self._validate_users(annotated_users, self.domain) + validated_users = self._validate_users(annotated_users) if self.debug: log.msg( f"Validated {len(validated_groups)} groups and {len(validated_users)} users.", diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4be7291..3729b0c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -2,7 +2,37 @@ # shellcheck disable=SC2086 # shellcheck disable=SC2089 -# Required arguments +# Optional arguments +EXTRA_OPTS="" + + +# Common server-level options +if [ -z "${PORT}" ]; then + PORT="1389" + echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] PORT environment variable is not set: using default of '${PORT}'" +fi + +if [ -n "${DEBUG}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --debug" +fi + + +# LDAP tree arguments +if [ -z "${DOMAIN}" ]; then + echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] DOMAIN environment variable is not set" + exit 1 +fi + +if [ -n "${DISABLE_MIRRORED_GROUPS}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --disable-mirrored-groups" +fi + +if [ -n "${DISABLE_USER_DOMAIN_VERIFICATION}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --disable-user-domain-verification" +fi + + +# OAuth client arguments if [ -z "${BACKEND}" ]; then echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] BACKEND environment variable is not set" exit 1 @@ -18,27 +48,14 @@ if [ -z "${CLIENT_SECRET}" ]; then exit 1 fi -if [ -z "${DOMAIN}" ]; then - echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] DOMAIN environment variable is not set" - exit 1 -fi - - -# Arguments with defaults -if [ -z "${PORT}" ]; then - PORT="1389" - echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] PORT environment variable is not set: using default of '${PORT}'" -fi - -# Optional arguments -EXTRA_OPTS="" -if [ -n "${DEBUG}" ]; then - EXTRA_OPTS="${EXTRA_OPTS} --debug" +# LDAP refresh arguments +if [ -n "${BACKGROUND_REFRESH}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --background-refresh" fi -if [ -n "${DISABLE_MIRRORED_GROUPS}" ]; then - EXTRA_OPTS="${EXTRA_OPTS} --disable-mirrored-groups" +if [ -n "${REFRESH_INTERVAL}" ]; then + EXTRA_OPTS="${EXTRA_OPTS} --refresh-interval $REFRESH_INTERVAL" fi @@ -61,13 +78,13 @@ if [ -n "${KEYCLOAK_DOMAIN_ATTRIBUTE}" ]; then fi -# LDAP refresh arguments -if [ -n "${BACKGROUND_REFRESH}" ]; then - EXTRA_OPTS="${EXTRA_OPTS} --background-refresh" -fi - -if [ -n "${REFRESH_INTERVAL}" ]; then - EXTRA_OPTS="${EXTRA_OPTS} --refresh-interval $REFRESH_INTERVAL" +# Redis arguments +if [ -n "${REDIS_HOST}" ]; then + if [ -z "${REDIS_PORT}" ]; then + REDIS_PORT="6379" + echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] REDIS_PORT environment variable is not set: using default of '${REDIS_PORT}'" + fi + EXTRA_OPTS="${EXTRA_OPTS} --redis-host $REDIS_HOST --redis-port $REDIS_PORT" fi @@ -85,16 +102,6 @@ if [ -n "${TLS_PORT}" ]; then fi -# Redis arguments -if [ -n "${REDIS_HOST}" ]; then - if [ -z "${REDIS_PORT}" ]; then - REDIS_PORT="6379" - echo "$(date +'%Y-%m-%d %H:%M:%S+0000') [-] REDIS_PORT environment variable is not set: using default of '${REDIS_PORT}'" - fi - EXTRA_OPTS="${EXTRA_OPTS} --redis-host $REDIS_HOST --redis-port $REDIS_PORT" -fi - - # Run the server hatch run python run.py \ --backend "${BACKEND}" \ diff --git a/run.py b/run.py index 9df97e4..000620e 100644 --- a/run.py +++ b/run.py @@ -10,52 +10,77 @@ prog="Apricot", description="Apricot is a proxy for delegating LDAP requests to an OpenID Connect backend.", ) - # Common options needed for all backends + # Common server-level options parser.add_argument( - "-b", - "--backend", - type=OAuthBackend, - help="Which OAuth backend to use.", + "-p", + "--port", + type=int, + default=1389, + help="Port to run on.", ) parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging.", + ) + + # LDAP tree settings + ldap_group = parser.add_argument_group("LDAP tree settings") + ldap_group.add_argument( "-d", "--domain", type=str, help="Which domain users belong to.", + required=True, ) - parser.add_argument("-i", "--client-id", type=str, help="OAuth client ID.") - parser.add_argument( - "-p", - "--port", - type=int, - default=1389, - help="Port to run on.", + ldap_group.add_argument( + "--disable-mirrored-groups", + action="store_false", + default=True, + dest="enable_mirrored_groups", + help="Disable creation of mirrored groups.", ) - parser.add_argument( + ldap_group.add_argument( + "--disable-user-domain-verification", + action="store_false", + default=True, + dest="enable_user_domain_verification", + help="Disable check that users belong to the correct domain.", + ) + + # OAuth client settings + oauth_group = parser.add_argument_group("OAuth settings") + oauth_group.add_argument( + "-b", + "--backend", + type=OAuthBackend, + help="Which OAuth backend to use.", + required=True, + ) + oauth_group.add_argument( + "-i", + "--client-id", + type=str, + help="OAuth client ID.", + required=True, + ) + oauth_group.add_argument( "-s", "--client-secret", type=str, help="OAuth client secret.", + required=True, ) - parser.add_argument( + + # Options for refreshing the tree + refresh_group = parser.add_argument_group("Refresh settings") + refresh_group.add_argument( "--background-refresh", action="store_true", default=False, help="Refresh in the background instead of as needed per request", ) - parser.add_argument( - "--debug", - action="store_true", - help="Enable debug logging.", - ) - parser.add_argument( - "--disable-mirrored-groups", - action="store_false", - default=True, - dest="enable_mirrored_groups", - help="Disable creation of mirrored groups.", - ) - parser.add_argument( + refresh_group.add_argument( "--refresh-interval", type=int, default=60, @@ -63,7 +88,7 @@ ) # Options for Microsoft Entra backend - entra_group = parser.add_argument_group("Microsoft Entra") + entra_group = parser.add_argument_group("Microsoft Entra backend") entra_group.add_argument( "--entra-tenant-id", type=str, @@ -71,7 +96,7 @@ ) # Options for Keycloak backend - keycloak_group = parser.add_argument_group("Keycloak") + keycloak_group = parser.add_argument_group("Keycloak backend") keycloak_group.add_argument( "--keycloak-base-url", type=str, @@ -88,6 +113,7 @@ default="domain", help="The attribute in Keycloak that contains the users' domain.", ) + # Options for Redis cache redis_group = parser.add_argument_group("Redis") redis_group.add_argument( @@ -100,6 +126,7 @@ type=int, help="Port for Redis server.", ) + # Options for TLS tls_group = parser.add_argument_group("TLS") tls_group.add_argument( @@ -118,6 +145,7 @@ type=str, help="Location of TLS private key (pem).", ) + # Parse arguments args = parser.parse_args() From 5d4209e506a29140ce7982f3272a99543a7d57b7 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Tue, 14 Jan 2025 17:15:48 +0000 Subject: [PATCH 7/7] :rotating_light: Fix linting issues --- README.md | 21 ++++++++++----------- apricot/apricot_server.py | 8 +++++--- apricot/cache/redis_cache.py | 2 +- apricot/cache/uid_cache.py | 2 +- apricot/ldap/oauth_ldap_entry.py | 4 ++-- apricot/oauth/keycloak_client.py | 13 +++++++------ apricot/oauth/microsoft_entra_client.py | 11 ++++++----- pyproject.toml | 10 +++++----- 8 files changed, 37 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index fe95c65..8d905c8 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,8 @@ Do this as follows: - Create a new `Client` in your `Keycloak` instance. - Under `General Settings`: - - Client type: `OpenID Connect` - - Client name: `apricot` + - Client type: `OpenID Connect` + - Client name: `apricot` - Under `Capability config` - Enable `Client authentication` - Enable the following authentication flows and disable the rest: @@ -160,14 +160,14 @@ Do this as follows: - `Service accounts roles` - Save the client - For the client you have just created - - Under `Credentials` copy `client secret` - - Under `Service accounts roles`: - - Click on `Assign role` then `Filter by clients` - - Assign the following roles: - - `realm-management` > `view-users` - - `realm-management` > `manage-users` - - `realm-management` > `query-groups` - - `realm-management` > `query-users` + - Under `Credentials` copy `client secret` + - Under `Service accounts roles`: + - Click on `Assign role` then `Filter by clients` + - Assign the following roles: + - `realm-management` > `view-users` + - `realm-management` > `manage-users` + - `realm-management` > `query-groups` + - `realm-management` > `query-users` ## Disabling Apricot groups @@ -203,7 +203,6 @@ member: CN=sherlock.holmes,OU=users,DC=example,DC=com :exclamation: You can disable the creation of these groups with the `--disable-primary-groups` command line option :exclamation: - ## Mirrored groups Apricot creates a group-of-groups for each group of users. diff --git a/apricot/apricot_server.py b/apricot/apricot_server.py index c8aed64..20bc696 100644 --- a/apricot/apricot_server.py +++ b/apricot/apricot_server.py @@ -2,17 +2,19 @@ import inspect import sys -from typing import Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast from twisted.internet import reactor, task from twisted.internet.endpoints import quoteStringArgument, serverFromString -from twisted.internet.interfaces import IReactorCore, IStreamServerEndpoint from twisted.python import log from apricot.cache import LocalCache, RedisCache, UidCache from apricot.ldap import OAuthLDAPServerFactory from apricot.oauth import OAuthBackend, OAuthClientMap, OAuthDataAdaptor +if TYPE_CHECKING: + from twisted.internet.interfaces import IReactorCore, IStreamServerEndpoint + class ApricotServer: """The Apricot server running via Twisted.""" @@ -139,7 +141,7 @@ def __init__( ssl_endpoint.listen(factory) # Load the Twisted reactor - self.reactor = cast(IReactorCore, reactor) + self.reactor = cast("IReactorCore", reactor) def run(self: Self) -> None: """Start the Twisted reactor.""" diff --git a/apricot/cache/redis_cache.py b/apricot/cache/redis_cache.py index 47ccc97..dff71ff 100644 --- a/apricot/cache/redis_cache.py +++ b/apricot/cache/redis_cache.py @@ -42,4 +42,4 @@ def set(self: Self, identifier: str, uid_value: int) -> None: self.cache.set(identifier, uid_value) def values(self: Self, keys: list[str]) -> list[int]: - return [int(cast(str, v)) for v in self.cache.mget(keys)] + return [int(cast("str", v)) for v in self.cache.mget(keys)] diff --git a/apricot/cache/uid_cache.py b/apricot/cache/uid_cache.py index 82621f3..895cb6b 100644 --- a/apricot/cache/uid_cache.py +++ b/apricot/cache/uid_cache.py @@ -55,7 +55,7 @@ def get_uid( min_value = min_value or 0 next_uid = max(self._get_max_uid(category) + 1, min_value) self.set(identifier_, next_uid) - return cast(int, self.get(identifier_)) + return cast("int", self.get(identifier_)) def _get_max_uid(self: Self, category: str | None) -> int: """Get maximum UID for a given category. diff --git a/apricot/ldap/oauth_ldap_entry.py b/apricot/ldap/oauth_ldap_entry.py index 0058bf7..7b7fdc7 100644 --- a/apricot/ldap/oauth_ldap_entry.py +++ b/apricot/ldap/oauth_ldap_entry.py @@ -75,7 +75,7 @@ def add_child( except LDAPEntryAlreadyExists: log.msg(f"Refusing to add child '{rdn.getText()}' as it already exists.") output = self._children[rdn.getText()] - return cast(OAuthLDAPEntry, output) + return cast("OAuthLDAPEntry", output) def bind(self: Self, password: bytes) -> defer.Deferred[OAuthLDAPEntry]: def _bind(password: bytes) -> OAuthLDAPEntry: @@ -89,4 +89,4 @@ def _bind(password: bytes) -> OAuthLDAPEntry: return defer.maybeDeferred(_bind, password) def list_children(self: Self) -> list[OAuthLDAPEntry]: - return [cast(OAuthLDAPEntry, entry) for entry in self._children.values()] + return [cast("OAuthLDAPEntry", entry) for entry in self._children.values()] diff --git a/apricot/oauth/keycloak_client.py b/apricot/oauth/keycloak_client.py index 96b4236..6b557c7 100644 --- a/apricot/oauth/keycloak_client.py +++ b/apricot/oauth/keycloak_client.py @@ -1,14 +1,15 @@ from __future__ import annotations import operator -from typing import Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast from twisted.python import log -from apricot.typedefs import JSONDict - from .oauth_client import OAuthClient +if TYPE_CHECKING: + from apricot.typedefs import JSONDict + class KeycloakClient(OAuthClient): """OAuth client for the Keycloak backend.""" @@ -56,7 +57,7 @@ def groups(self: Self) -> list[JSONDict]: f"{self.base_url}/admin/realms/{self.realm}/groups?first={len(group_data)}&max={self.max_rows}&briefRepresentation=false", use_client_secret=False, ): - group_data.extend(cast(list[JSONDict], data)) + group_data.extend(cast("list[JSONDict]", data)) if len(data) != self.max_rows: break @@ -99,7 +100,7 @@ def groups(self: Self) -> list[JSONDict]: use_client_secret=False, ) attributes["memberUid"] = [ - user["username"] for user in cast(list[JSONDict], members) + user["username"] for user in cast("list[JSONDict]", members) ] output.append(attributes) except KeyError as exc: @@ -115,7 +116,7 @@ def users(self: Self) -> list[JSONDict]: f"{self.base_url}/admin/realms/{self.realm}/users?first={len(user_data)}&max={self.max_rows}&briefRepresentation=false", use_client_secret=False, ): - user_data.extend(cast(list[JSONDict], data)) + user_data.extend(cast("list[JSONDict]", data)) if len(data) != self.max_rows: break diff --git a/apricot/oauth/microsoft_entra_client.py b/apricot/oauth/microsoft_entra_client.py index 3444d92..cd67b3b 100644 --- a/apricot/oauth/microsoft_entra_client.py +++ b/apricot/oauth/microsoft_entra_client.py @@ -1,14 +1,15 @@ from __future__ import annotations import operator -from typing import Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast from twisted.python import log -from apricot.typedefs import JSONDict - from .oauth_client import OAuthClient +if TYPE_CHECKING: + from apricot.typedefs import JSONDict + class MicrosoftEntraClient(OAuthClient): """OAuth client for the Microsoft Entra backend.""" @@ -50,7 +51,7 @@ def groups(self: Self) -> list[JSONDict]: f"https://graph.microsoft.com/v1.0/groups?$select={','.join(queries)}", ) for group_dict in cast( - list[JSONDict], + "list[JSONDict]", sorted(group_data["value"], key=operator.itemgetter("createdDateTime")), ): try: @@ -92,7 +93,7 @@ def users(self: Self) -> list[JSONDict]: f"https://graph.microsoft.com/v1.0/users?$select={','.join(queries)}", ) for user_dict in cast( - list[JSONDict], + "list[JSONDict]", sorted(user_data["value"], key=operator.itemgetter("createdDateTime")), ): # Get user attributes diff --git a/pyproject.toml b/pyproject.toml index 319d97b..93c6324 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,10 +168,10 @@ strict = true # enable all optional error checking flags [[tool.mypy.overrides]] module = [ - "ldaptor.*", - "pydantic.*", - "requests_oauthlib.*", - "twisted.*", - "zope.interface.*", + "ldaptor.*", + "pydantic.*", + "requests_oauthlib.*", + "twisted.*", + "zope.interface.*", ] ignore_missing_imports = true \ No newline at end of file