pkg-auth 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. pkg_auth/__init__.py +15 -0
  2. pkg_auth/admin/__init__.py +35 -0
  3. pkg_auth/admin/cli.py +87 -0
  4. pkg_auth/admin/client.py +401 -0
  5. pkg_auth/admin/env.py +74 -0
  6. pkg_auth/admin/helpers.py +113 -0
  7. pkg_auth/admin/provision_client.py +86 -0
  8. pkg_auth/admin/settings.py +33 -0
  9. pkg_auth/authentication/__init__.py +33 -0
  10. pkg_auth/authentication/adapters/__init__.py +1 -0
  11. pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
  12. pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
  13. pkg_auth/authentication/application/__init__.py +1 -0
  14. pkg_auth/authentication/application/use_cases/__init__.py +1 -0
  15. pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
  16. pkg_auth/authentication/domain/__init__.py +1 -0
  17. pkg_auth/authentication/domain/entities.py +50 -0
  18. pkg_auth/authentication/domain/exceptions.py +18 -0
  19. pkg_auth/authentication/domain/ports.py +26 -0
  20. pkg_auth/authentication/domain/value_objects.py +42 -0
  21. pkg_auth/authorization/__init__.py +117 -0
  22. pkg_auth/authorization/adapters/__init__.py +1 -0
  23. pkg_auth/authorization/adapters/cache/__init__.py +32 -0
  24. pkg_auth/authorization/adapters/cache/decorators.py +181 -0
  25. pkg_auth/authorization/adapters/cache/memory.py +61 -0
  26. pkg_auth/authorization/adapters/cache/protocol.py +36 -0
  27. pkg_auth/authorization/adapters/cache/redis.py +60 -0
  28. pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
  29. pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
  30. pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
  31. pkg_auth/authorization/adapters/django_orm/models.py +226 -0
  32. pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
  33. pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
  34. pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
  35. pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
  36. pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
  37. pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
  38. pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
  39. pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
  40. pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
  41. pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
  42. pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
  43. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
  44. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
  45. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
  46. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
  47. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
  48. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
  49. pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
  50. pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
  51. pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
  52. pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
  53. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
  54. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
  55. pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
  56. pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
  57. pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
  58. pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
  59. pkg_auth/authorization/application/__init__.py +1 -0
  60. pkg_auth/authorization/application/use_cases/__init__.py +1 -0
  61. pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
  62. pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
  63. pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
  64. pkg_auth/authorization/application/use_cases/create_role.py +69 -0
  65. pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
  66. pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
  67. pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
  68. pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
  69. pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
  70. pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
  71. pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
  72. pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
  73. pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
  74. pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
  75. pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
  76. pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
  77. pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
  78. pkg_auth/authorization/application/use_cases/update_role.py +61 -0
  79. pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
  80. pkg_auth/authorization/cli/__init__.py +1 -0
  81. pkg_auth/authorization/cli/sync_catalog.py +180 -0
  82. pkg_auth/authorization/cli/sync_services.py +151 -0
  83. pkg_auth/authorization/config.py +21 -0
  84. pkg_auth/authorization/domain/__init__.py +1 -0
  85. pkg_auth/authorization/domain/entities.py +192 -0
  86. pkg_auth/authorization/domain/exceptions.py +68 -0
  87. pkg_auth/authorization/domain/ports.py +217 -0
  88. pkg_auth/authorization/domain/value_objects.py +208 -0
  89. pkg_auth/authorization/platform.py +47 -0
  90. pkg_auth/integrations/__init__.py +0 -0
  91. pkg_auth/integrations/django/__init__.py +32 -0
  92. pkg_auth/integrations/django/apps.py +10 -0
  93. pkg_auth/integrations/django/auth_context_middleware.py +105 -0
  94. pkg_auth/integrations/django/decorators.py +74 -0
  95. pkg_auth/integrations/django/install.py +136 -0
  96. pkg_auth/integrations/django/middleware.py +63 -0
  97. pkg_auth/integrations/fastapi/__init__.py +26 -0
  98. pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
  99. pkg_auth/integrations/fastapi/auth_factory.py +84 -0
  100. pkg_auth/integrations/fastapi/decorators.py +55 -0
  101. pkg_auth/integrations/fastapi/errors.py +72 -0
  102. pkg_auth/integrations/fastapi/identity_dep.py +41 -0
  103. pkg_auth/integrations/strawberry/__init__.py +20 -0
  104. pkg_auth/integrations/strawberry/auth.py +137 -0
  105. pkg_auth/integrations/strawberry/permissions.py +56 -0
  106. pkg_auth-3.0.0.dist-info/METADATA +147 -0
  107. pkg_auth-3.0.0.dist-info/RECORD +110 -0
  108. pkg_auth-3.0.0.dist-info/WHEEL +5 -0
  109. pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
  110. pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
pkg_auth/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """pkg_auth: clean-architecture identity + ACL for Python services.
2
+
3
+ Importing this top-level package gives you only the version. Reach for
4
+ specific surfaces via the sub-packages:
5
+
6
+ from pkg_auth.authentication import IdentityContext, AuthenticateTokenUseCase
7
+ from pkg_auth.authentication.adapters.keycloak import JWTTokenDecoder
8
+
9
+ Authorization (ACL) and framework integrations (FastAPI, Django,
10
+ Strawberry) are available via sub-packages.
11
+ """
12
+
13
+ __version__ = "2.1.0"
14
+
15
+ __all__ = ["__version__"]
@@ -0,0 +1,35 @@
1
+ """
2
+ pkg_auth.admin.keycloak
3
+
4
+ Async Keycloak admin utilities:
5
+
6
+ - KCAdminSettings: configuration for Keycloak admin connection.
7
+ - KeycloakAdminClient: minimal async admin client (httpx-based).
8
+ - provision_keycloak_client: high-level async helper to:
9
+ * ensure API client exists (bearer-only)
10
+ * ensure client roles match your permission list
11
+ * ensure audience + roles mappers on frontend clients
12
+ - settings_from_env / ensure_keycloak_client_from_env:
13
+ convenience wrappers for env-driven CLI / initContainers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .client import KeycloakAdminClient
19
+ from .env import settings_from_env, ensure_keycloak_client_from_env
20
+ from .helpers import (
21
+ _ensure_api_client,
22
+ _ensure_roles,
23
+ _ensure_frontend_mappers,
24
+ _remove_frontend_mappers,
25
+ )
26
+ from .provision_client import provision_keycloak_client
27
+ from .settings import KCAdminSettings
28
+
29
+ __all__ = [
30
+ "KCAdminSettings",
31
+ "KeycloakAdminClient",
32
+ "settings_from_env",
33
+ "ensure_keycloak_client_from_env",
34
+ "provision_keycloak_client"
35
+ ]
pkg_auth/admin/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ # src/pkg_auth/keycloak_admin/__main__.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from typing import Any, Sequence
10
+
11
+ from .env import settings_from_env
12
+ from .provision_client import provision_keycloak_client
13
+
14
+
15
+ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description="Provision Keycloak API client, roles and audience mappers",
18
+ )
19
+
20
+ parser.add_argument(
21
+ "--client-id",
22
+ help="Override API clientId (default: {APP_NAME|SERVICE_NAME}-api)",
23
+ )
24
+ parser.add_argument(
25
+ "--permissions",
26
+ "-P",
27
+ nargs="*",
28
+ help="Explicit list of permission/role names "
29
+ "(if omitted, your caller can pass them programmatically).",
30
+ )
31
+ parser.add_argument(
32
+ "--frontend-client-ids",
33
+ "-F",
34
+ nargs="*",
35
+ help="Frontend clientIds to grant audience and client-roles mappers to "
36
+ "(defaults from env KEYCLOAK_FRONTEND_CLIENT_IDS).",
37
+ )
38
+ parser.add_argument(
39
+ "--remove-frontend-client-ids",
40
+ "-R",
41
+ nargs="*",
42
+ help="Frontend clientIds to remove audience + roles mappers from "
43
+ "(effective only with --strict-audience).",
44
+ )
45
+ parser.add_argument(
46
+ "--strict-roles",
47
+ action="store_true",
48
+ help="Reconcile roles strictly: create missing and delete extra roles.",
49
+ )
50
+ parser.add_argument(
51
+ "--strict-audience",
52
+ action="store_true",
53
+ help="Reconcile audience + roles mappers strictly and remove mappers "
54
+ "from --remove-frontend-client-ids.",
55
+ )
56
+
57
+ return parser.parse_args(args=argv)
58
+
59
+
60
+ async def _run(args: argparse.Namespace) -> dict[str, Any]:
61
+ settings = settings_from_env()
62
+ return await provision_keycloak_client(
63
+ settings=settings,
64
+ client_id=args.client_id,
65
+ permissions=list(args.permissions or []),
66
+ frontend_client_ids=list(args.frontend_client_ids or []),
67
+ remove_frontend_client_ids=list(args.remove_frontend_client_ids or []),
68
+ strict_roles=bool(args.strict_roles),
69
+ strict_audience=bool(args.strict_audience),
70
+ )
71
+
72
+
73
+ def main(argv: Sequence[str] | None = None) -> None:
74
+ args = _parse_args(argv)
75
+
76
+ try:
77
+ summary = asyncio.run(_run(args))
78
+ json.dump({"ok": True, **summary}, sys.stdout, indent=2)
79
+ sys.stdout.write("\n")
80
+ except Exception as exc: # noqa: BLE001
81
+ json.dump({"ok": False, "error": str(exc)}, sys.stdout, indent=2)
82
+ sys.stdout.write("\n")
83
+ raise
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
@@ -0,0 +1,401 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ import urllib.parse
6
+ from typing import Any, Optional, Dict, List
7
+
8
+ import httpx
9
+
10
+ from .settings import KCAdminSettings
11
+
12
+
13
+ class KeycloakAdminClient:
14
+ """
15
+ Minimal async Keycloak Admin wrapper.
16
+
17
+ - obtains admin tokens
18
+ - retries once on 401
19
+ - exposes helpers for clients, roles, and protocol mappers
20
+ """
21
+
22
+ def __init__(self, settings: KCAdminSettings, client: Optional[httpx.AsyncClient] = None):
23
+ self.s = settings
24
+ self._client = client or httpx.AsyncClient(verify=self.s.verify_ssl, timeout=30.0)
25
+ self._token: Optional[str] = None
26
+ self._token_exp: float = 0.0
27
+ self._lock = asyncio.Lock()
28
+
29
+ async def close(self) -> None:
30
+ await self._client.aclose()
31
+
32
+ # ------------------------------------------------------------------ #
33
+ # token management
34
+ # ------------------------------------------------------------------ #
35
+
36
+ async def _get_token(self) -> str:
37
+ # reuse cached token if still valid
38
+ if self._token and time.time() < (self._token_exp - 20):
39
+ return self._token
40
+
41
+ async with self._lock:
42
+ if self._token and time.time() < (self._token_exp - 20):
43
+ return self._token
44
+
45
+ token_url = f"{self.s.base_url_slash}realms/master/protocol/openid-connect/token"
46
+ data = {
47
+ "client_id": "admin-cli",
48
+ "username": self.s.keycloak_admin_user,
49
+ "password": self.s.keycloak_admin_pass,
50
+ "grant_type": "password",
51
+ }
52
+ resp = await self._client.post(token_url, data=data)
53
+ try:
54
+ resp.raise_for_status()
55
+ except httpx.HTTPStatusError as e:
56
+ raise RuntimeError(
57
+ f"Failed to obtain admin token: {e.response.status_code} {e.response.text}"
58
+ ) from e
59
+
60
+ payload = resp.json()
61
+ self._token = payload["access_token"]
62
+ self._token_exp = time.time() + float(payload.get("expires_in", 60))
63
+ return self._token
64
+
65
+ def _auth_headers(self, token: str) -> dict[str, str]:
66
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
67
+
68
+ async def _request(
69
+ self,
70
+ method: str,
71
+ url: str,
72
+ *,
73
+ params: Optional[Dict[str, Any]] = None,
74
+ json: Optional[Dict[str, Any]] = None,
75
+ ) -> httpx.Response:
76
+ token = await self._get_token()
77
+ resp = await self._client.request(
78
+ method,
79
+ url,
80
+ headers=self._auth_headers(token),
81
+ params=params,
82
+ json=json,
83
+ )
84
+ if resp.status_code == 401:
85
+ # refresh once
86
+ await self._get_token()
87
+ token = self._token or ""
88
+ resp = await self._client.request(
89
+ method,
90
+ url,
91
+ headers=self._auth_headers(token),
92
+ params=params,
93
+ json=json,
94
+ )
95
+ resp.raise_for_status()
96
+ return resp
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # base helpers
100
+ # ------------------------------------------------------------------ #
101
+
102
+ def _realm_admin(self) -> str:
103
+ return f"{self.s.base_url_slash}admin/realms/{self.s.keycloak_realm}"
104
+
105
+ # ------------------------------------------------------------------ #
106
+ # client management
107
+ # ------------------------------------------------------------------ #
108
+
109
+ async def get_client_by_client_id(self, client_id: str) -> Optional[dict[str, Any]]:
110
+ url = f"{self._realm_admin()}/clients"
111
+ resp = await self._request("GET", url, params={"clientId": client_id})
112
+ arr = resp.json() or []
113
+ return arr[0] if arr else None
114
+
115
+ async def create_client(self, client_repr: dict[str, Any]) -> dict[str, Any]:
116
+ url = f"{self._realm_admin()}/clients"
117
+ try:
118
+ resp = await self._request("POST", url, json=client_repr)
119
+ except httpx.HTTPStatusError as e:
120
+ # handle race: 409 when another process created it
121
+ if e.response is not None and e.response.status_code == 409:
122
+ existing = await self.get_client_by_client_id(client_repr["clientId"])
123
+ if existing:
124
+ return existing
125
+ raise
126
+
127
+ location = resp.headers.get("Location")
128
+ if location:
129
+ fetch = await self._request("GET", location)
130
+ return fetch.json()
131
+
132
+ created = await self.get_client_by_client_id(client_repr["clientId"])
133
+ if not created:
134
+ raise RuntimeError("Client creation succeeded but could not read back the resource.")
135
+ return created
136
+
137
+ async def update_client(self, internal_id: str, client_repr: dict[str, Any]) -> dict[str, Any]:
138
+ url = f"{self._realm_admin()}/clients/{internal_id}"
139
+ await self._request("PUT", url, json=client_repr)
140
+ fetch = await self._request("GET", url)
141
+ return fetch.json()
142
+
143
+ async def ensure_client(self, client_repr: dict[str, Any]) -> dict[str, Any]:
144
+ """
145
+ Idempotently create or update a client by clientId.
146
+ Returns the trimmed server representation.
147
+ """
148
+ existing = await self.get_client_by_client_id(client_repr["clientId"])
149
+ if not existing:
150
+ created = await self.create_client(client_repr)
151
+ return self._trim_client(created)
152
+
153
+ internal_id = existing["id"]
154
+ merged = {**existing, **client_repr}
155
+ updated = await self.update_client(internal_id, merged)
156
+ return self._trim_client(updated)
157
+
158
+ # ------------------------------------------------------------------ #
159
+ # roles
160
+ # ------------------------------------------------------------------ #
161
+
162
+ async def list_client_roles(self, internal_id: str) -> list[str]:
163
+ url = f"{self._realm_admin()}/clients/{internal_id}/roles"
164
+ resp = await self._request("GET", url)
165
+ return [r.get("name") for r in (resp.json() or [])]
166
+
167
+ async def create_client_role(
168
+ self,
169
+ internal_id: str,
170
+ name: str,
171
+ description: Optional[str] = None,
172
+ ) -> None:
173
+ url = f"{self._realm_admin()}/clients/{internal_id}/roles"
174
+ payload: Dict[str, Any] = {"name": name}
175
+ if description:
176
+ payload["description"] = description
177
+ await self._request("POST", url, json=payload)
178
+
179
+ async def ensure_client_roles(self, internal_id: str, role_names: List[str]) -> dict[str, int]:
180
+ existing = set(await self.list_client_roles(internal_id))
181
+ created = 0
182
+ for name in role_names:
183
+ if name not in existing:
184
+ await self.create_client_role(internal_id, name)
185
+ created += 1
186
+ return {
187
+ "created": created,
188
+ "existing": len(existing.intersection(set(role_names))),
189
+ }
190
+
191
+ async def delete_client_role(self, internal_id: str, role_name: str) -> None:
192
+ encoded = urllib.parse.quote(role_name, safe="")
193
+ url = f"{self._realm_admin()}/clients/{internal_id}/roles/{encoded}"
194
+ await self._request("DELETE", url)
195
+
196
+ async def ensure_client_roles_strict(self, internal_id: str, desired_roles: List[str]) -> dict[str, int]:
197
+ existing = set(await self.list_client_roles(internal_id))
198
+ desired = set(desired_roles)
199
+ to_create = sorted(desired - existing)
200
+ to_delete = sorted(existing - desired)
201
+ created = 0
202
+ deleted = 0
203
+ for r in to_create:
204
+ await self.create_client_role(internal_id, r)
205
+ created += 1
206
+ for r in to_delete:
207
+ await self.delete_client_role(internal_id, r)
208
+ deleted += 1
209
+ return {
210
+ "created": created,
211
+ "deleted": deleted,
212
+ "kept": len(existing & desired),
213
+ }
214
+
215
+ # ------------------------------------------------------------------ #
216
+ # protocol mappers (audience + roles)
217
+ # ------------------------------------------------------------------ #
218
+
219
+ async def list_protocol_mappers(self, internal_id: str) -> list[dict[str, Any]]:
220
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
221
+ resp = await self._request("GET", url)
222
+ return resp.json() or []
223
+
224
+ # ---- audience mapper ------------------------------------------------
225
+
226
+ async def _find_audience_mapper(
227
+ self,
228
+ internal_id: str,
229
+ included_client_id: str,
230
+ ) -> Optional[dict[str, Any]]:
231
+ for m in await self.list_protocol_mappers(internal_id):
232
+ if (
233
+ m.get("protocol") == "openid-connect"
234
+ and m.get("protocolMapper") == "oidc-audience-mapper"
235
+ and (m.get("config") or {}).get("included.client.audience") == included_client_id
236
+ ):
237
+ return m
238
+ return None
239
+
240
+ async def ensure_audience_mapper(
241
+ self,
242
+ internal_id: str,
243
+ included_client_id: str,
244
+ *,
245
+ id_token_claim: bool = True,
246
+ access_token_claim: bool = True,
247
+ update_if_different: bool = False,
248
+ ) -> dict[str, bool]:
249
+ """
250
+ Ensure audience mapper exists; optionally update its config if different.
251
+
252
+ Returns {"created": bool, "updated": bool}.
253
+ """
254
+ desired_cfg = {
255
+ "included.client.audience": included_client_id,
256
+ "id.token.claim": "true" if id_token_claim else "false",
257
+ "access.token.claim": "true" if access_token_claim else "false",
258
+ }
259
+
260
+ existing = await self._find_audience_mapper(internal_id, included_client_id)
261
+ if existing:
262
+ if update_if_different:
263
+ current = existing.get("config") or {}
264
+ if any(current.get(k) != v for k, v in desired_cfg.items()):
265
+ mapper_id = existing.get("id")
266
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
267
+ payload = {
268
+ "id": mapper_id,
269
+ "name": existing.get("name") or f"aud-{included_client_id}",
270
+ "protocol": "openid-connect",
271
+ "protocolMapper": "oidc-audience-mapper",
272
+ "config": desired_cfg,
273
+ }
274
+ await self._request("PUT", url, json=payload)
275
+ return {"created": False, "updated": True}
276
+ return {"created": False, "updated": False}
277
+
278
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
279
+ payload = {
280
+ "name": f"aud-{included_client_id}",
281
+ "protocol": "openid-connect",
282
+ "protocolMapper": "oidc-audience-mapper",
283
+ "config": desired_cfg,
284
+ }
285
+ await self._request("POST", url, json=payload)
286
+ return {"created": True, "updated": False}
287
+
288
+ async def remove_audience_mapper(
289
+ self,
290
+ internal_id: str,
291
+ included_client_id: str,
292
+ ) -> bool:
293
+ existing = await self._find_audience_mapper(internal_id, included_client_id)
294
+ if not existing:
295
+ return False
296
+ mapper_id = existing.get("id")
297
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
298
+ await self._request("DELETE", url)
299
+ return True
300
+
301
+ # ---- client roles mapper (FE sees resource_access.<api>.roles) ------
302
+
303
+ async def _find_client_roles_mapper(
304
+ self,
305
+ internal_id: str,
306
+ source_client_id: str,
307
+ ) -> Optional[dict[str, Any]]:
308
+ claim = f"resource_access.{source_client_id}.roles"
309
+ for m in await self.list_protocol_mappers(internal_id):
310
+ if (
311
+ m.get("protocol") == "openid-connect"
312
+ and m.get("protocolMapper") == "oidc-usermodel-client-role-mapper"
313
+ ):
314
+ cfg = m.get("config") or {}
315
+ if (
316
+ cfg.get("usermodel.clientRoleMapping.clientId") == source_client_id
317
+ and cfg.get("claim.name") == claim
318
+ ):
319
+ return m
320
+ return None
321
+
322
+ async def ensure_client_roles_mapper(
323
+ self,
324
+ internal_id: str,
325
+ source_client_id: str,
326
+ *,
327
+ update_if_different: bool = False,
328
+ ) -> dict[str, bool]:
329
+ """
330
+ Ensure FE client has a roles mapper for roles of source_client_id under
331
+ resource_access.{source_client_id}.roles.
332
+
333
+ Returns {"created": bool, "updated": bool}.
334
+ """
335
+ desired_cfg = {
336
+ "usermodel.clientRoleMapping.clientId": source_client_id,
337
+ "claim.name": f"resource_access.{source_client_id}.roles",
338
+ "jsonType.label": "String",
339
+ "multivalued": "true",
340
+ "id.token.claim": "true",
341
+ "access.token.claim": "true",
342
+ "userinfo.token.claim": "true",
343
+ }
344
+
345
+ existing = await self._find_client_roles_mapper(internal_id, source_client_id)
346
+ if existing:
347
+ if update_if_different:
348
+ current = existing.get("config") or {}
349
+ if any(current.get(k) != v for k, v in desired_cfg.items()):
350
+ mapper_id = existing.get("id")
351
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
352
+ payload = {
353
+ "id": mapper_id,
354
+ "name": existing.get("name") or f"roles-{source_client_id}",
355
+ "protocol": "openid-connect",
356
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
357
+ "config": {**current, **desired_cfg},
358
+ }
359
+ await self._request("PUT", url, json=payload)
360
+ return {"created": False, "updated": True}
361
+ return {"created": False, "updated": False}
362
+
363
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
364
+ payload = {
365
+ "name": f"roles-{source_client_id}",
366
+ "protocol": "openid-connect",
367
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
368
+ "config": desired_cfg,
369
+ }
370
+ await self._request("POST", url, json=payload)
371
+ return {"created": True, "updated": False}
372
+
373
+ async def remove_client_roles_mapper(
374
+ self,
375
+ internal_id: str,
376
+ source_client_id: str,
377
+ ) -> bool:
378
+ existing = await self._find_client_roles_mapper(internal_id, source_client_id)
379
+ if not existing:
380
+ return False
381
+ mapper_id = existing.get("id")
382
+ url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
383
+ await self._request("DELETE", url)
384
+ return True
385
+
386
+ # ------------------------------------------------------------------ #
387
+ # helpers
388
+ # ------------------------------------------------------------------ #
389
+
390
+ @staticmethod
391
+ def _trim_client(obj: dict[str, Any]) -> dict[str, Any]:
392
+ return {
393
+ "id": obj.get("id"),
394
+ "clientId": obj.get("clientId"),
395
+ "publicClient": obj.get("publicClient"),
396
+ "serviceAccountsEnabled": obj.get("serviceAccountsEnabled"),
397
+ "standardFlowEnabled": obj.get("standardFlowEnabled"),
398
+ "redirectUris": obj.get("redirectUris") or [],
399
+ "webOrigins": obj.get("webOrigins") or [],
400
+ "enabled": obj.get("enabled"),
401
+ }
pkg_auth/admin/env.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from typing import Optional, Any
6
+
7
+ from .settings import KCAdminSettings
8
+ from .provision_client import provision_keycloak_client
9
+
10
+
11
+ def settings_from_env() -> KCAdminSettings:
12
+ def _bool(key: str, default: bool = True) -> bool:
13
+ raw = os.getenv(key)
14
+ if raw is None:
15
+ return default
16
+ return str(raw).strip().lower() in {"1", "true", "yes", "on"}
17
+
18
+ def _split_csv(key: str) -> list[str]:
19
+ raw = os.getenv(key)
20
+ if not raw:
21
+ return []
22
+ return [x.strip() for x in raw.split(",") if x and x.strip()]
23
+
24
+ base_url = os.getenv("KEYCLOAK_BASE_URL")
25
+ realm = os.getenv("KEYCLOAK_REALM")
26
+ admin_user = os.getenv("KEYCLOAK_ADMIN_USER")
27
+ admin_pass = os.getenv("KEYCLOAK_ADMIN_PASS")
28
+ if not all([base_url, realm, admin_user, admin_pass]):
29
+ missing = [
30
+ n
31
+ for n, v in [
32
+ ("KEYCLOAK_BASE_URL", base_url),
33
+ ("KEYCLOAK_REALM", realm),
34
+ ("KEYCLOAK_ADMIN_USER", admin_user),
35
+ ("KEYCLOAK_ADMIN_PASS", admin_pass),
36
+ ]
37
+ if not v
38
+ ]
39
+ raise RuntimeError(f"Missing Keycloak admin settings: {', '.join(missing)}")
40
+
41
+ return KCAdminSettings(
42
+ keycloak_base_url=base_url,
43
+ keycloak_realm=realm,
44
+ keycloak_admin_user=admin_user,
45
+ keycloak_admin_pass=admin_pass,
46
+ verify_ssl=_bool("VERIFY_SSL", True),
47
+ app_name=os.getenv("APP_NAME"),
48
+ service_name=os.getenv("SERVICE_NAME"),
49
+ frontend_client_ids=_split_csv("KEYCLOAK_FRONTEND_CLIENT_IDS"),
50
+ )
51
+
52
+
53
+ def ensure_keycloak_client_from_env(
54
+ *,
55
+ strict_roles: bool = False,
56
+ strict_audience: bool = False,
57
+ client_id: Optional[str] = None,
58
+ permissions: Optional[list[str]] = None,
59
+ frontend_client_ids: Optional[list[str]] = None,
60
+ remove_frontend_client_ids: Optional[list[str]] = None,
61
+ ) -> dict[str, Any]:
62
+ """Convenience sync wrapper using env-configured settings."""
63
+ settings = settings_from_env()
64
+ return asyncio.run(
65
+ provision_keycloak_client(
66
+ settings=settings,
67
+ client_id=client_id,
68
+ permissions=permissions,
69
+ frontend_client_ids=frontend_client_ids,
70
+ remove_frontend_client_ids=remove_frontend_client_ids,
71
+ strict_roles=strict_roles,
72
+ strict_audience=strict_audience,
73
+ )
74
+ )