Skip to content

Commit

Permalink
feat: add realms property
Browse files Browse the repository at this point in the history
Make it possible to interact with the /admin/realms endpoint
with `client.realms`. It acts as a resource, so listing realms
can be achieved with `client.realms.get()`.
  • Loading branch information
derlin committed Jul 28, 2024
1 parent 67c2b45 commit 84da9e7
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 2 deletions.
28 changes: 28 additions & 0 deletions docs/02-making-calls.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
📡 Making calls
===============

General overview
----------------

Once you have configured how to authenticate to Keycloak, the rest is easy-peasy. mantelo **starts
with the URL** ``<server-url>/admin/realms/<realm-name>`` and constructs the URL from there,
depending on how you call the client.
Expand Down Expand Up @@ -70,3 +73,28 @@ To better understand, here are some examples of URL mapping (``c`` is the
> Content-Type: application/json
> {"username": ...}
Special case: working with realms
---------------------------------


By default, a client is bound to a realm, and has the base URL set to
``<server-url>/admin/realms/<realm-name>``. Hence, to query ``GET /admin/realms/<realm-name>``, you
can use :python:`c.get()` directly (or :python:`c.post({})` to update its properties).

.. important::

Be careful not to delete the realm you used for authentication, as it will invalidate your token!
:python:`c.delete()` should be avoided if you used the same realm for connection and the client.

Remember that you can switch the realm by setting the :py:attr:`~.KeycloakAdmin.realm_name`
attribute. This will only change the base URL (the result of the calls), not the connection itself.
You will stay logged in to the initial realm you connected with.

If you want to work with the ``/realms/`` endpoint itself, for instance, to list all realms, or
create a new one, you can use the special :py:attr:`~.KeycloakAdmin.realms` attribute on the client.
It returns a slumber resource whose base URL is ``<server-url>/admin/realms`` (without any realm
name). The same rules apply as for the other resources, but the URL is now relative to the
``/realms/`` endpoint. For example, you can list realms with :python:`c.realms.get()`.

See :ref:`examples` for more hands-on examples.
61 changes: 61 additions & 0 deletions docs/03-examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,64 @@ List and count resources
# Count the active sessions for a client
>>> client.clients(c_uid).session_count.get()
{'count': 0}


Interact with realms directly
-----------------------------

If you need to view or edit properties of the current realm (``/admin/realm/{realm}`` endpoint), you can use the client directly:

.. doctest::

# Describe the current realm
>>> client.get()
{'id': ..., 'realm': 'master', 'displayName': ..., ...}

# Update the realm
>>> client.put({"displayName": "MASTER!"})

You can at any point change the realm of the client by setting the
:py:attr:`~.KeycloakAdmin.realm_name`. This won't impact the connection, which will still use the
same token. This is useful when you want to definitely switch to another realm. If you only need to
do a few operations in another realm, consider using the :py:attr:`~.KeycloakAdmin.realms` instead
(keep reading).

.. doctest::

>>> client.get()["realm"]
'master'

# Change the realm
>>> client.realm_name = "orwell"

# Describe the current realm
>>> client.get()["realm"]
'orwell'

# Switch back to the original realm
>>> client.realm_name = "master"

To work with the ``/admin/realms/`` endpoint directly, for example, to list existing realms or create a new one,
or simply to quickly query another realm's information, use the special `~.KeycloakAdmin.realms` attribute:

.. doctest::

# List all realms
>>> len(client.realms.get())
2

# Create a new realm
>>> client.realms.post({"realm": "new_realm", "enabled": True, "displayName": "New Realm"})
b''

# Get the new realm
>>> client.realms("new_realm").get()
{'id': ..., 'realm': 'new_realm', 'displayName': 'New Realm', ...}

# Query the users in the new realm
>>> client.realms("new_realm").users.get()
[]

# Delete the new realm
>>> client.realms("new_realm").delete()
True
27 changes: 27 additions & 0 deletions mantelo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def realm_name(self) -> str:
"""
:getter: Get the current realm name.
:setter: Set the realm name. This updates the :attr:`base_url` and impact all future requests.
:seealso: :attr:`realms`
"""
return self._store["base_url"].split("/realms/")[1]

Expand All @@ -127,6 +128,32 @@ def realm_name(self, realm_name: str) -> None:
base_url = self._store["base_url"].split("/realms/")[0]
self._store["base_url"] = f"{base_url}/realms/{realm_name}"

@property
def realms(self, **kwargs) -> HyphenatedResource:
"""
Special resource to interact with the ``/admin/realms/`` endpoint.
By default, the client base URL contains a realm name, making it impossible to query the
``/admin/realms/`` endpoint. This special property allows you to start the URL at ``/realms/``
instead of ``/realms/{realm_name}``.
Some example usages:
.. code-block:: python
# List all realms
client.realms.get()
# Get users in another realm
client.realms("test").users.get()
# Get users in the current realm
client.get() == client.realms(client.realm_name).get()
"""
kwargs = dict(self._store.items())
base_url = self._store["base_url"].split("/realms/")[0]
kwargs.update({"base_url": f"{base_url}/realms/"})
return self._get_resource(**kwargs)

@classmethod
def create(
cls,
Expand Down
22 changes: 20 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def test_password_connection(with_custom_session):
if session:
assert adm.session == session

resp = [u["username"] for u in adm.users.get()]
assert constants.TEST_USER in resp
resp = adm.get()
assert resp.get("realm") == constants.TEST_REALM


@pytest.mark.integration
Expand Down Expand Up @@ -167,3 +167,21 @@ def test_resource_private(openid_connection_password):

with pytest.raises(AttributeError, match="_users"):
adm._users.get()


def test_client_as_resource():
adm = KeycloakAdmin(server_url="any", realm_name="any", auth=object)

# Ensure the client itself defines the get, etc operations
# (to query /admin/realms/{realm_name}/)
for op in ["head", "get", "post", "put", "delete"]:
assert hasattr(adm.clients, op)
assert f"bound method Resource.{op}" in str(getattr(adm, op))


@pytest.mark.integration
def test_realms_endpoint(openid_connection_admin):
adm = KeycloakAdmin.create(connection=openid_connection_admin)

assert len(adm.realms.get()) == 2
assert adm.get() == adm.realms(constants.MASTER_REALM).get()

0 comments on commit 84da9e7

Please sign in to comment.