Browse Source

chore: Merge branch 'master' into Ujifman/master

pull/685/head
Richard Nemeth 4 weeks ago
parent
commit
375ce293db
No known key found for this signature in database GPG Key ID: 21C39470DF3DEC39
  1. 2
      .github/workflows/daily.yaml
  2. 8
      .github/workflows/lint.yaml
  3. 2
      .github/workflows/publish.yaml
  4. 2
      .readthedocs.yaml
  5. 12
      CHANGELOG.md
  6. 4
      docs/source/modules/admin.rst
  7. 3
      docs/source/modules/openid_client.rst
  8. 2021
      poetry.lock
  9. 5
      pyproject.toml
  10. 35
      src/keycloak/connection.py
  11. 50
      src/keycloak/keycloak_admin.py
  12. 7
      src/keycloak/keycloak_openid.py
  13. 4
      src/keycloak/openid_connection.py
  14. 111
      tests/test_keycloak_admin.py
  15. 13
      tests/test_keycloak_openid.py

2
.github/workflows/daily.yaml

@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
keycloak-version: ["22.0", "23.0", "24.0", "25.0", "26.0", "latest"]
env:
KEYCLOAK_DOCKER_IMAGE_TAG: ${{ matrix.keycloak-version }}

8
.github/workflows/lint.yaml

@ -20,7 +20,7 @@ jobs:
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@ -40,7 +40,7 @@ jobs:
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@ -55,7 +55,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
keycloak-version: ["22.0", "23.0", "24.0", "25.0", "26.0", "latest"]
needs:
- check-commits
@ -91,7 +91,7 @@ jobs:
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

2
.github/workflows/publish.yaml

@ -15,7 +15,7 @@ jobs:
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

2
.readthedocs.yaml

@ -6,7 +6,7 @@ sphinx:
build:
os: "ubuntu-24.04"
tools:
python: "3.13"
python: "3.14"
jobs:
post_create_environment:
- python -m pip install poetry

12
CHANGELOG.md

@ -1,3 +1,15 @@
## v5.10.0 (2025-12-27)
### Feat
- add get_role_composites_by_id method (#680)
## v5.9.0 (2025-12-27)
### Feat
- add pool_maxsize parameter to connection managers (#651)
## v5.8.1 (2025-08-19)
### Fix

4
docs/source/modules/admin.rst

@ -15,7 +15,8 @@ Configure admin client
username='example-admin',
password='secret',
realm_name="master",
user_realm_name="only_if_other_realm_than_master")
user_realm_name="only_if_other_realm_than_master",
pool_maxsize=20)
Configure admin client with connection
@ -34,6 +35,7 @@ Configure admin client with connection
user_realm_name="only_if_other_realm_than_master",
client_id="my_client",
client_secret_key="client-secret",
pool_maxsize=25,
verify=True)
keycloak_admin = KeycloakAdmin(connection=keycloak_connection)

3
docs/source/modules/openid_client.rst

@ -16,7 +16,8 @@ Configure client OpenID
keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/",
client_id="example_client",
realm_name="example_realm",
client_secret_key="secret")
client_secret_key="secret",
pool_maxsize=15) # Example: Set connection pool size
Get .well_know

2021
poetry.lock
File diff suppressed because it is too large
View File

5
pyproject.toml

@ -65,10 +65,7 @@ twine = ">=4.0.2"
freezegun = ">=1.2.2"
docutils = "<0.21"
ruff = ">=0.9.3"
[[tool.poetry.source]]
name = "PyPI"
priority = "primary"
backports-asyncio-runner = { "version" = ">=1.2.0", "python" = ">=3.9,<3.11" }
[build-system]
requires = ["poetry-core>=1.0.0"]

35
src/keycloak/connection.py

@ -59,6 +59,8 @@ class ConnectionManager:
:type cert: Union[str,Tuple[str,str]]
:param max_retries: The total number of times to retry HTTP requests.
:type max_retries: int
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
def __init__(
@ -70,6 +72,7 @@ class ConnectionManager:
proxies: dict | None = None,
cert: str | tuple | None = None,
max_retries: int = 1,
pool_maxsize: int | None = None,
) -> None:
"""
Init method.
@ -91,19 +94,25 @@ class ConnectionManager:
:type cert: Union[str,Tuple[str,str]]
:param max_retries: The total number of times to retry HTTP requests.
:type max_retries: int
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
self.base_url = base_url
self.headers = headers
self.timeout = timeout
self.verify = verify
self.cert = cert
self.pool_maxsize = pool_maxsize
self._s = requests.Session()
self._s.auth = lambda x: x # don't let requests add auth headers
# retry once to reset connection with Keycloak after tomcat's ConnectionTimeout
# see https://github.com/marcospereirampj/python-keycloak/issues/36
for protocol in ("https://", "http://"):
adapter = HTTPAdapter(max_retries=max_retries)
adapter_kwargs = {"max_retries": max_retries}
if pool_maxsize is not None:
adapter_kwargs["pool_maxsize"] = pool_maxsize
adapter = HTTPAdapter(**adapter_kwargs)
# adds POST to retry whitelist
allowed_methods = set(adapter.max_retries.allowed_methods)
allowed_methods.add("POST")
@ -114,7 +123,15 @@ class ConnectionManager:
if proxies:
self._s.proxies.update(proxies)
self.async_s = httpx.AsyncClient(verify=verify, mounts=proxies, cert=cert)
self.async_s = httpx.AsyncClient(
verify=verify,
mounts=proxies,
cert=cert,
limits=httpx.Limits(
max_connections=100 if pool_maxsize is None else pool_maxsize,
max_keepalive_connections=20,
),
)
self.async_s.auth = None # don't let requests add auth headers
self.async_s.transport = httpx.AsyncHTTPTransport(retries=1)
@ -184,6 +201,20 @@ class ConnectionManager:
def cert(self, value: str | tuple) -> None:
self._cert = value
@property
def pool_maxsize(self) -> int | None:
"""
Return the maximum number of connections to save in the pool.
:returns: Pool maxsize
:rtype: int or None
"""
return self._pool_maxsize
@pool_maxsize.setter
def pool_maxsize(self, value: int | None) -> None:
self._pool_maxsize = value
@property
def headers(self) -> dict:
"""

50
src/keycloak/keycloak_admin.py

@ -88,6 +88,8 @@ class KeycloakAdmin:
:type max_retries: int
:param connection: A KeycloakOpenIDConnection as an alternative to individual params.
:type connection: KeycloakOpenIDConnection
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
PAGE_SIZE = 100
@ -110,6 +112,7 @@ class KeycloakAdmin:
cert: str | tuple | None = None,
max_retries: int = 1,
connection: KeycloakOpenIDConnection | None = None,
pool_maxsize: int | None = None,
) -> None:
"""
Init method.
@ -149,6 +152,8 @@ class KeycloakAdmin:
:type max_retries: int
:param connection: An OpenID Connection as an alternative to individual params.
:type connection: KeycloakOpenIDConnection
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
self.connection = connection or KeycloakOpenIDConnection(
server_url=server_url,
@ -166,6 +171,7 @@ class KeycloakAdmin:
timeout=timeout,
cert=cert,
max_retries=max_retries,
pool_maxsize=pool_maxsize,
)
@property
@ -1671,7 +1677,7 @@ class KeycloakAdmin:
def send_update_account(
self,
user_id: str,
payload: dict,
payload: list,
client_id: str | None = None,
lifespan: int | None = None,
redirect_uri: str | None = None,
@ -3502,6 +3508,26 @@ class KeycloakAdmin:
expected_codes=[HTTP_NO_CONTENT],
)
def get_role_composites_by_id(self, role_id: str, query: dict | None = None) -> list:
"""
Get all composite roles by role id.
:param role_id: id of role
:type role_id: str
:param query: Query parameters (optional). Supported keys: 'first', 'max', 'search'
:type query: dict
:return: Keycloak server response (RoleRepresentation)
:rtype: list
"""
query = query or {}
params_path = {"realm-name": self.connection.realm_name, "role-id": role_id}
url = urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path)
if "first" in query or "max" in query:
return self.__fetch_paginated(url, query)
return self.__fetch_all(url, query)
def create_realm_role(self, payload: dict, skip_exists: bool = False) -> str:
"""
Create a new role for the realm or client.
@ -6982,7 +7008,7 @@ class KeycloakAdmin:
async def a_send_update_account(
self,
user_id: str,
payload: dict,
payload: list,
client_id: str | None = None,
lifespan: int | None = None,
redirect_uri: str | None = None,
@ -8827,6 +8853,26 @@ class KeycloakAdmin:
expected_codes=[HTTP_NO_CONTENT],
)
async def a_get_role_composites_by_id(self, role_id: str, query: dict | None = None) -> list:
"""
Get all composite roles by role id asynchronously.
:param role_id: id of role
:type role_id: str
:param query: Query parameters (optional). Supported keys: 'first', 'max', 'search'
:type query: dict
:return: Keycloak server response (RoleRepresentation)
:rtype: list
"""
query = query or {}
params_path = {"realm-name": self.connection.realm_name, "role-id": role_id}
url = urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path)
if "first" in query or "max" in query:
return await self.a___fetch_paginated(url, query)
return await self.a___fetch_all(url, query)
async def a_create_realm_role(self, payload: dict, skip_exists: bool = False) -> str:
"""
Create a new role for the realm or client asynchronously.

7
src/keycloak/keycloak_openid.py

@ -87,7 +87,8 @@ class KeycloakOpenID:
Either a path to an SSL certificate file, or two-tuple of
(certificate file, key file).
:param max_retries: The total number of times to retry HTTP requests.
:type max_retries: int
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
def __init__(
@ -102,6 +103,7 @@ class KeycloakOpenID:
timeout: int = 60,
cert: str | tuple | None = None,
max_retries: int = 1,
pool_maxsize: int | None = None,
) -> None:
"""
Init method.
@ -129,6 +131,8 @@ class KeycloakOpenID:
:type cert: Union[str,Tuple[str,str]]
:param max_retries: The total number of times to retry HTTP requests.
:type max_retries: int
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
self.client_id = client_id
self.client_secret_key = client_secret_key
@ -142,6 +146,7 @@ class KeycloakOpenID:
proxies=proxies,
cert=cert,
max_retries=max_retries,
pool_maxsize=pool_maxsize,
)
self.authorization = Authorization()

4
src/keycloak/openid_connection.py

@ -82,6 +82,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
timeout: int | None = 60,
cert: str | tuple | None = None,
max_retries: int = 1,
pool_maxsize: int | None = None,
) -> None:
"""
Init method.
@ -120,6 +121,8 @@ class KeycloakOpenIDConnection(ConnectionManager):
:type cert: Union[str,Tuple[str,str]]
:param max_retries: The total number of times to retry HTTP requests.
:type max_retries: int
:param pool_maxsize: The maximum number of connections to save in the pool.
:type pool_maxsize: int
"""
# token is renewed when it hits 90% of its lifetime. This is to account for any possible
# clock skew.
@ -154,6 +157,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
verify=self.verify,
cert=cert,
max_retries=max_retries,
pool_maxsize=pool_maxsize,
)
@property

111
tests/test_keycloak_admin.py

@ -57,6 +57,7 @@ def test_keycloak_admin_init(env: KeycloakTestEnv) -> None:
server_url=f"http://{env.keycloak_host}:{env.keycloak_port}",
username=env.keycloak_admin,
password=env.keycloak_admin_password,
pool_maxsize=5,
)
assert admin.connection.server_url == f"http://{env.keycloak_host}:{env.keycloak_port}", (
admin.connection.server_url
@ -72,6 +73,7 @@ def test_keycloak_admin_init(env: KeycloakTestEnv) -> None:
assert admin.connection.token is None, admin.connection.token
assert admin.connection.user_realm_name is None, admin.connection.user_realm_name
assert admin.connection.custom_headers is None, admin.connection.custom_headers
assert admin.connection.pool_maxsize == 5, admin.connection.pool_maxsize
admin = KeycloakAdmin(
server_url=f"http://{env.keycloak_host}:{env.keycloak_port}",
@ -2645,7 +2647,7 @@ def test_auth_flows(admin: KeycloakAdmin, realm: str) -> None:
# Test flow executions
res = admin.get_authentication_flow_executions(flow_alias="browser")
assert len(res) in [8, 12, 14], res
assert len(res) in [8, 12, 14, 15], res
with pytest.raises(KeycloakGetError) as err:
admin.get_authentication_flow_executions(flow_alias="bad")
@ -2788,7 +2790,7 @@ def test_authentication_configs(admin: KeycloakAdmin, realm: str) -> None:
# Test list of auth providers
res = admin.get_authenticator_providers()
assert len(res) <= 41
assert len(res) <= 42
res = admin.get_authenticator_provider_config_description(provider_id="auth-cookie")
assert res == {
@ -3310,6 +3312,48 @@ def test_get_role_client_level_children(
assert child["id"] in [x["id"] for x in res]
def test_get_role_composites_by_id(
admin: KeycloakAdmin,
realm: str,
client: str,
composite_client_role: str,
client_role: str,
) -> None:
"""
Test get role's children by role ID.
:param admin: Keycloak Admin client
:type admin: KeycloakAdmin
:param realm: Keycloak realm
:type realm: str
:param client: Keycloak client
:type client: str
:param composite_client_role: Composite client role
:type composite_client_role: str
:param client_role: Client role
:type client_role: str
"""
admin.change_current_realm(realm)
parent_role = admin.get_client_role(client, composite_client_role)
child_role = admin.get_client_role(client, client_role)
composites = admin.get_role_composites_by_id(parent_role["id"])
assert len(composites) > 0
assert child_role["id"] in [x["id"] for x in composites]
composites_paginated = admin.get_role_composites_by_id(
parent_role["id"], query={"first": 0, "max": 10}
)
assert len(composites_paginated) > 0
assert child_role["id"] in [x["id"] for x in composites_paginated]
composites_searched = admin.get_role_composites_by_id(
parent_role["id"], query={"search": client_role[:3]}
)
assert len(composites_searched) > 0
def test_upload_certificate(
admin: KeycloakAdmin,
realm: str,
@ -3351,7 +3395,7 @@ def test_get_bruteforce_status_for_user(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
admin.change_current_realm(realm)
# Turn on bruteforce protection
@ -3389,7 +3433,7 @@ def test_clear_bruteforce_attempts_for_user(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
admin.change_current_realm(realm)
# Turn on bruteforce protection
@ -3430,7 +3474,7 @@ def test_clear_bruteforce_attempts_for_all_users(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
admin.change_current_realm(realm)
# Turn on bruteforce protection
@ -3591,7 +3635,7 @@ def test_initial_access_token(
assert res["count"] == 2
assert res["expiration"] == 3
oid, username, password = oid_with_credentials
oid, _, _ = oid_with_credentials
client = str(uuid.uuid4())
secret = str(uuid.uuid4())
@ -6376,7 +6420,7 @@ async def test_a_auth_flows(admin: KeycloakAdmin, realm: str) -> None:
# Test flow executions
res = await admin.a_get_authentication_flow_executions(flow_alias="browser")
assert len(res) in [8, 12, 14], res
assert len(res) in [8, 12, 14, 15], res
with pytest.raises(KeycloakGetError) as err:
await admin.a_get_authentication_flow_executions(flow_alias="bad")
@ -6524,7 +6568,7 @@ async def test_a_authentication_configs(admin: KeycloakAdmin, realm: str) -> Non
# Test list of auth providers
res = await admin.a_get_authenticator_providers()
assert len(res) <= 41
assert len(res) <= 42
res = await admin.a_get_authenticator_provider_config_description(provider_id="auth-cookie")
assert res == {
@ -7067,6 +7111,49 @@ async def test_a_get_role_client_level_children(
assert child["id"] in [x["id"] for x in res]
@pytest.mark.asyncio
async def test_a_get_role_composites_by_id(
admin: KeycloakAdmin,
realm: str,
client: str,
composite_client_role: str,
client_role: str,
) -> None:
"""
Test get all composite roles by role id asynchronously.
:param admin: Keycloak Admin client
:type admin: KeycloakAdmin
:param realm: Keycloak realm
:type realm: str
:param client: Keycloak client
:type client: str
:param composite_client_role: Composite client role
:type composite_client_role: str
:param client_role: Client role
:type client_role: str
"""
await admin.a_change_current_realm(realm)
parent_role = await admin.a_get_client_role(client, composite_client_role)
child_role = await admin.a_get_client_role(client, client_role)
composites = await admin.a_get_role_composites_by_id(parent_role["id"])
assert len(composites) > 0
assert child_role["id"] in [x["id"] for x in composites]
composites_paginated = await admin.a_get_role_composites_by_id(
parent_role["id"], query={"first": 0, "max": 10}
)
assert len(composites_paginated) > 0
assert child_role["id"] in [x["id"] for x in composites_paginated]
composites_searched = await admin.a_get_role_composites_by_id(
parent_role["id"], query={"search": client_role[:3]}
)
assert len(composites_searched) > 0
@pytest.mark.asyncio
async def test_a_upload_certificate(
admin: KeycloakAdmin,
@ -7110,7 +7197,7 @@ async def test_a_get_bruteforce_status_for_user(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
await admin.a_change_current_realm(realm)
# Turn on bruteforce protection
@ -7149,7 +7236,7 @@ async def test_a_clear_bruteforce_attempts_for_user(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
await admin.a_change_current_realm(realm)
# Turn on bruteforce protection
@ -7191,7 +7278,7 @@ async def test_a_clear_bruteforce_attempts_for_all_users(
:param realm: Keycloak realm
:type realm: str
"""
oid, username, password = oid_with_credentials
oid, username, _ = oid_with_credentials
await admin.a_change_current_realm(realm)
# Turn on bruteforce protection
@ -7365,7 +7452,7 @@ async def test_a_initial_access_token(
assert res["count"] == 2
assert res["expiration"] == 3
oid, username, password = oid_with_credentials
oid, _, _ = oid_with_credentials
client = str(uuid.uuid4())
secret = str(uuid.uuid4())

13
tests/test_keycloak_openid.py

@ -35,6 +35,7 @@ def test_keycloak_openid_init(env: KeycloakTestEnv) -> None:
server_url=f"http://{env.keycloak_host}:{env.keycloak_port}",
realm_name="master",
client_id="admin-cli",
pool_maxsize=5,
)
assert oid.client_id == "admin-cli"
@ -42,6 +43,14 @@ def test_keycloak_openid_init(env: KeycloakTestEnv) -> None:
assert oid.realm_name == "master"
assert isinstance(oid.connection, ConnectionManager)
assert isinstance(oid.authorization, Authorization)
assert oid.connection.pool_maxsize == 5
oid_default = KeycloakOpenID(
server_url=f"http://{env.keycloak_host}:{env.keycloak_port}",
realm_name="master",
client_id="admin-cli",
)
assert oid_default.connection.pool_maxsize is None
def test_well_known(oid: KeycloakOpenID) -> None:
@ -383,7 +392,7 @@ def test_load_authorization_config(
server with client credentials
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]
"""
oid, username, password = oid_with_credentials_authz
oid, _, _ = oid_with_credentials_authz
oid.load_authorization_config(path="tests/data/authz_settings.json")
assert "test-authz-rb-policy" in oid.authorization.policies
@ -927,7 +936,7 @@ async def test_a_load_authorization_config(
server with client credentials
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]
"""
oid, username, password = oid_with_credentials_authz
oid, _, _ = oid_with_credentials_authz
await oid.a_load_authorization_config(path="tests/data/authz_settings.json")
assert "test-authz-rb-policy" in oid.authorization.policies

Loading…
Cancel
Save