codetether 1.2.2__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 (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,760 @@
1
+ """
2
+ Keycloak Tenant Provisioning Service for A2A Server.
3
+
4
+ Provides multi-tenant provisioning capabilities:
5
+ - Create new realms for organizations
6
+ - Configure standard clients (SPA, API, Mobile)
7
+ - Manage admin users and roles
8
+ - Delete tenants
9
+ """
10
+
11
+ import logging
12
+ import secrets
13
+ from typing import Dict, Any, List, Optional
14
+ from dataclasses import dataclass
15
+
16
+ import httpx
17
+
18
+ from .keycloak_auth import (
19
+ KEYCLOAK_URL,
20
+ KEYCLOAK_ADMIN_USERNAME,
21
+ KEYCLOAK_ADMIN_PASSWORD,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class TenantInfo:
29
+ """Information about a provisioned tenant."""
30
+
31
+ realm_name: str
32
+ org_slug: str
33
+ spa_client_id: str
34
+ api_client_id: str
35
+ api_client_secret: str
36
+ mobile_client_id: str
37
+ admin_user_id: str
38
+ admin_email: str
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ return {
42
+ 'realm_name': self.realm_name,
43
+ 'org_slug': self.org_slug,
44
+ 'client_ids': {
45
+ 'spa': self.spa_client_id,
46
+ 'api': self.api_client_id,
47
+ 'mobile': self.mobile_client_id,
48
+ },
49
+ 'api_client_secret': self.api_client_secret,
50
+ 'admin_user_id': self.admin_user_id,
51
+ 'admin_email': self.admin_email,
52
+ }
53
+
54
+
55
+ class KeycloakTenantServiceError(Exception):
56
+ """Base exception for tenant service errors."""
57
+
58
+ pass
59
+
60
+
61
+ class TenantAlreadyExistsError(KeycloakTenantServiceError):
62
+ """Raised when attempting to create a tenant that already exists."""
63
+
64
+ pass
65
+
66
+
67
+ class TenantNotFoundError(KeycloakTenantServiceError):
68
+ """Raised when a tenant is not found."""
69
+
70
+ pass
71
+
72
+
73
+ class KeycloakTenantService:
74
+ """Manages Keycloak tenant (realm) provisioning."""
75
+
76
+ # Standard redirect URIs and web origins for clients
77
+ REDIRECT_URIS = [
78
+ 'http://localhost:*',
79
+ 'https://*.codetether.run/*',
80
+ 'https://app.codetether.run/*',
81
+ ]
82
+ WEB_ORIGINS = [
83
+ 'http://localhost:3000',
84
+ 'http://localhost:8080',
85
+ 'https://app.codetether.run',
86
+ 'https://*.codetether.run',
87
+ ]
88
+
89
+ def __init__(
90
+ self,
91
+ keycloak_url: str = KEYCLOAK_URL,
92
+ admin_username: str = KEYCLOAK_ADMIN_USERNAME,
93
+ admin_password: str = KEYCLOAK_ADMIN_PASSWORD,
94
+ ):
95
+ self.keycloak_url = keycloak_url.rstrip('/')
96
+ self.admin_username = admin_username
97
+ self.admin_password = admin_password
98
+ self._admin_token: Optional[str] = None
99
+
100
+ logger.info(
101
+ f'KeycloakTenantService initialized for {self.keycloak_url}'
102
+ )
103
+
104
+ async def _get_admin_token(self) -> str:
105
+ """Get admin access token from master realm using admin-cli client."""
106
+ token_url = (
107
+ f'{self.keycloak_url}/realms/master/protocol/openid-connect/token'
108
+ )
109
+
110
+ async with httpx.AsyncClient() as client:
111
+ try:
112
+ response = await client.post(
113
+ token_url,
114
+ data={
115
+ 'grant_type': 'password',
116
+ 'client_id': 'admin-cli',
117
+ 'username': self.admin_username,
118
+ 'password': self.admin_password,
119
+ },
120
+ timeout=30.0,
121
+ )
122
+ response.raise_for_status()
123
+ token_data = response.json()
124
+ self._admin_token = token_data['access_token']
125
+ logger.debug('Successfully obtained Keycloak admin token')
126
+ return self._admin_token
127
+ except httpx.HTTPStatusError as e:
128
+ logger.error(
129
+ f'Failed to get admin token: {e.response.status_code}'
130
+ )
131
+ raise KeycloakTenantServiceError(
132
+ f'Failed to authenticate as admin: {e.response.text}'
133
+ )
134
+ except httpx.HTTPError as e:
135
+ logger.error(f'HTTP error getting admin token: {e}')
136
+ raise KeycloakTenantServiceError(
137
+ f'Failed to connect to Keycloak: {str(e)}'
138
+ )
139
+
140
+ def _get_auth_headers(self) -> Dict[str, str]:
141
+ """Get authorization headers with admin token."""
142
+ if not self._admin_token:
143
+ raise KeycloakTenantServiceError('Admin token not available')
144
+ return {
145
+ 'Authorization': f'Bearer {self._admin_token}',
146
+ 'Content-Type': 'application/json',
147
+ }
148
+
149
+ async def create_tenant(
150
+ self,
151
+ org_slug: str,
152
+ admin_email: str,
153
+ admin_password: str,
154
+ admin_first_name: str = 'Admin',
155
+ admin_last_name: str = 'User',
156
+ ) -> TenantInfo:
157
+ """
158
+ Create a new tenant (realm) in Keycloak.
159
+
160
+ Args:
161
+ org_slug: Organization slug (e.g., "acme") - will create realm "acme.codetether.run"
162
+ admin_email: Email for the admin user
163
+ admin_password: Password for the admin user
164
+ admin_first_name: First name for the admin user
165
+ admin_last_name: Last name for the admin user
166
+
167
+ Returns:
168
+ TenantInfo with realm details and client credentials
169
+ """
170
+ # Get fresh admin token
171
+ await self._get_admin_token()
172
+
173
+ realm_name = f'{org_slug}.codetether.run'
174
+ logger.info(f'Creating tenant realm: {realm_name}')
175
+
176
+ async with httpx.AsyncClient() as client:
177
+ # Step 1: Create the realm
178
+ await self._create_realm(client, realm_name, org_slug)
179
+
180
+ # Step 2: Create standard clients
181
+ spa_client_id = f'{org_slug}-spa'
182
+ api_client_id = f'{org_slug}-api'
183
+ mobile_client_id = f'{org_slug}-mobile'
184
+
185
+ await self._create_spa_client(client, realm_name, spa_client_id)
186
+ api_secret = await self._create_api_client(
187
+ client, realm_name, api_client_id
188
+ )
189
+ await self._create_mobile_client(
190
+ client, realm_name, mobile_client_id
191
+ )
192
+
193
+ # Step 3: Create admin role
194
+ await self._create_role(
195
+ client, realm_name, 'admin', 'Administrator role'
196
+ )
197
+
198
+ # Step 4: Create admin user
199
+ admin_user_id = await self._create_user(
200
+ client,
201
+ realm_name,
202
+ admin_email,
203
+ admin_password,
204
+ admin_first_name,
205
+ admin_last_name,
206
+ )
207
+
208
+ # Step 5: Assign admin role to user
209
+ await self._assign_role_to_user(
210
+ client, realm_name, admin_user_id, 'admin'
211
+ )
212
+
213
+ logger.info(f'Successfully created tenant: {realm_name}')
214
+
215
+ return TenantInfo(
216
+ realm_name=realm_name,
217
+ org_slug=org_slug,
218
+ spa_client_id=spa_client_id,
219
+ api_client_id=api_client_id,
220
+ api_client_secret=api_secret,
221
+ mobile_client_id=mobile_client_id,
222
+ admin_user_id=admin_user_id,
223
+ admin_email=admin_email,
224
+ )
225
+
226
+ async def _create_realm(
227
+ self, client: httpx.AsyncClient, realm_name: str, org_slug: str
228
+ ) -> None:
229
+ """Create a new Keycloak realm."""
230
+ realm_url = f'{self.keycloak_url}/admin/realms'
231
+
232
+ realm_config = {
233
+ 'realm': realm_name,
234
+ 'enabled': True,
235
+ 'displayName': f'{org_slug.title()} - CodeTether',
236
+ 'displayNameHtml': f'<b>{org_slug.title()}</b> - CodeTether',
237
+ 'registrationAllowed': False,
238
+ 'registrationEmailAsUsername': True,
239
+ 'rememberMe': True,
240
+ 'verifyEmail': False,
241
+ 'loginWithEmailAllowed': True,
242
+ 'duplicateEmailsAllowed': False,
243
+ 'resetPasswordAllowed': True,
244
+ 'editUsernameAllowed': False,
245
+ 'bruteForceProtected': True,
246
+ 'permanentLockout': False,
247
+ 'maxFailureWaitSeconds': 900,
248
+ 'minimumQuickLoginWaitSeconds': 60,
249
+ 'waitIncrementSeconds': 60,
250
+ 'quickLoginCheckMilliSeconds': 1000,
251
+ 'maxDeltaTimeSeconds': 43200,
252
+ 'failureFactor': 30,
253
+ 'sslRequired': 'external',
254
+ 'accessTokenLifespan': 300,
255
+ 'accessTokenLifespanForImplicitFlow': 900,
256
+ 'ssoSessionIdleTimeout': 1800,
257
+ 'ssoSessionMaxLifespan': 36000,
258
+ 'offlineSessionIdleTimeout': 2592000,
259
+ 'accessCodeLifespan': 60,
260
+ 'accessCodeLifespanUserAction': 300,
261
+ 'accessCodeLifespanLogin': 1800,
262
+ }
263
+
264
+ try:
265
+ response = await client.post(
266
+ realm_url,
267
+ json=realm_config,
268
+ headers=self._get_auth_headers(),
269
+ timeout=30.0,
270
+ )
271
+
272
+ if response.status_code == 409:
273
+ raise TenantAlreadyExistsError(
274
+ f'Realm {realm_name} already exists'
275
+ )
276
+
277
+ response.raise_for_status()
278
+ logger.info(f'Created realm: {realm_name}')
279
+
280
+ except httpx.HTTPStatusError as e:
281
+ if e.response.status_code != 409:
282
+ logger.error(f'Failed to create realm: {e.response.text}')
283
+ raise KeycloakTenantServiceError(
284
+ f'Failed to create realm: {e.response.text}'
285
+ )
286
+ raise
287
+
288
+ async def _create_spa_client(
289
+ self, client: httpx.AsyncClient, realm_name: str, client_id: str
290
+ ) -> None:
291
+ """Create a public SPA client."""
292
+ clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
293
+
294
+ client_config = {
295
+ 'clientId': client_id,
296
+ 'name': f'{client_id} - Web Application',
297
+ 'enabled': True,
298
+ 'publicClient': True,
299
+ 'standardFlowEnabled': True,
300
+ 'directAccessGrantsEnabled': True,
301
+ 'implicitFlowEnabled': False,
302
+ 'serviceAccountsEnabled': False,
303
+ 'authorizationServicesEnabled': False,
304
+ 'redirectUris': self.REDIRECT_URIS,
305
+ 'webOrigins': self.WEB_ORIGINS,
306
+ 'protocol': 'openid-connect',
307
+ 'attributes': {
308
+ 'pkce.code.challenge.method': 'S256',
309
+ 'post.logout.redirect.uris': '+',
310
+ },
311
+ }
312
+
313
+ try:
314
+ response = await client.post(
315
+ clients_url,
316
+ json=client_config,
317
+ headers=self._get_auth_headers(),
318
+ timeout=30.0,
319
+ )
320
+ response.raise_for_status()
321
+ logger.info(f'Created SPA client: {client_id}')
322
+ except httpx.HTTPStatusError as e:
323
+ logger.error(f'Failed to create SPA client: {e.response.text}')
324
+ raise KeycloakTenantServiceError(
325
+ f'Failed to create SPA client: {e.response.text}'
326
+ )
327
+
328
+ async def _create_api_client(
329
+ self, client: httpx.AsyncClient, realm_name: str, client_id: str
330
+ ) -> str:
331
+ """Create a confidential API client and return its secret."""
332
+ clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
333
+
334
+ # Generate a secure client secret
335
+ client_secret = secrets.token_urlsafe(32)
336
+
337
+ client_config = {
338
+ 'clientId': client_id,
339
+ 'name': f'{client_id} - Backend API',
340
+ 'enabled': True,
341
+ 'publicClient': False,
342
+ 'standardFlowEnabled': False,
343
+ 'directAccessGrantsEnabled': True,
344
+ 'serviceAccountsEnabled': True,
345
+ 'authorizationServicesEnabled': False,
346
+ 'secret': client_secret,
347
+ 'redirectUris': self.REDIRECT_URIS,
348
+ 'webOrigins': self.WEB_ORIGINS,
349
+ 'protocol': 'openid-connect',
350
+ 'attributes': {
351
+ 'client.secret.creation.time': '0',
352
+ },
353
+ }
354
+
355
+ try:
356
+ response = await client.post(
357
+ clients_url,
358
+ json=client_config,
359
+ headers=self._get_auth_headers(),
360
+ timeout=30.0,
361
+ )
362
+ response.raise_for_status()
363
+ logger.info(f'Created API client: {client_id}')
364
+ return client_secret
365
+ except httpx.HTTPStatusError as e:
366
+ logger.error(f'Failed to create API client: {e.response.text}')
367
+ raise KeycloakTenantServiceError(
368
+ f'Failed to create API client: {e.response.text}'
369
+ )
370
+
371
+ async def _create_mobile_client(
372
+ self, client: httpx.AsyncClient, realm_name: str, client_id: str
373
+ ) -> None:
374
+ """Create a public mobile client."""
375
+ clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
376
+
377
+ client_config = {
378
+ 'clientId': client_id,
379
+ 'name': f'{client_id} - Mobile Application',
380
+ 'enabled': True,
381
+ 'publicClient': True,
382
+ 'standardFlowEnabled': True,
383
+ 'directAccessGrantsEnabled': True,
384
+ 'implicitFlowEnabled': False,
385
+ 'serviceAccountsEnabled': False,
386
+ 'authorizationServicesEnabled': False,
387
+ 'redirectUris': [
388
+ 'http://localhost:*',
389
+ f'codetether://{client_id}/*',
390
+ f'codetether.{client_id}://*',
391
+ ],
392
+ 'webOrigins': ['*'],
393
+ 'protocol': 'openid-connect',
394
+ 'attributes': {
395
+ 'pkce.code.challenge.method': 'S256',
396
+ },
397
+ }
398
+
399
+ try:
400
+ response = await client.post(
401
+ clients_url,
402
+ json=client_config,
403
+ headers=self._get_auth_headers(),
404
+ timeout=30.0,
405
+ )
406
+ response.raise_for_status()
407
+ logger.info(f'Created mobile client: {client_id}')
408
+ except httpx.HTTPStatusError as e:
409
+ logger.error(f'Failed to create mobile client: {e.response.text}')
410
+ raise KeycloakTenantServiceError(
411
+ f'Failed to create mobile client: {e.response.text}'
412
+ )
413
+
414
+ async def _create_role(
415
+ self,
416
+ client: httpx.AsyncClient,
417
+ realm_name: str,
418
+ role_name: str,
419
+ description: str = '',
420
+ ) -> None:
421
+ """Create a realm role."""
422
+ roles_url = f'{self.keycloak_url}/admin/realms/{realm_name}/roles'
423
+
424
+ role_config = {
425
+ 'name': role_name,
426
+ 'description': description,
427
+ 'composite': False,
428
+ 'clientRole': False,
429
+ }
430
+
431
+ try:
432
+ response = await client.post(
433
+ roles_url,
434
+ json=role_config,
435
+ headers=self._get_auth_headers(),
436
+ timeout=30.0,
437
+ )
438
+ response.raise_for_status()
439
+ logger.info(f'Created role: {role_name} in realm {realm_name}')
440
+ except httpx.HTTPStatusError as e:
441
+ # Role might already exist
442
+ if e.response.status_code != 409:
443
+ logger.error(f'Failed to create role: {e.response.text}')
444
+ raise KeycloakTenantServiceError(
445
+ f'Failed to create role: {e.response.text}'
446
+ )
447
+ logger.info(f'Role {role_name} already exists')
448
+
449
+ async def _create_user(
450
+ self,
451
+ client: httpx.AsyncClient,
452
+ realm_name: str,
453
+ email: str,
454
+ password: str,
455
+ first_name: str,
456
+ last_name: str,
457
+ ) -> str:
458
+ """Create a user and return their ID."""
459
+ users_url = f'{self.keycloak_url}/admin/realms/{realm_name}/users'
460
+
461
+ user_config = {
462
+ 'username': email,
463
+ 'email': email,
464
+ 'firstName': first_name,
465
+ 'lastName': last_name,
466
+ 'enabled': True,
467
+ 'emailVerified': True,
468
+ 'credentials': [
469
+ {
470
+ 'type': 'password',
471
+ 'value': password,
472
+ 'temporary': False,
473
+ }
474
+ ],
475
+ }
476
+
477
+ try:
478
+ response = await client.post(
479
+ users_url,
480
+ json=user_config,
481
+ headers=self._get_auth_headers(),
482
+ timeout=30.0,
483
+ )
484
+ response.raise_for_status()
485
+
486
+ # Get user ID from Location header
487
+ location = response.headers.get('Location', '')
488
+ user_id = location.split('/')[-1] if location else ''
489
+
490
+ if not user_id:
491
+ # Fetch user by email to get ID
492
+ search_response = await client.get(
493
+ users_url,
494
+ params={'email': email},
495
+ headers=self._get_auth_headers(),
496
+ timeout=30.0,
497
+ )
498
+ search_response.raise_for_status()
499
+ users = search_response.json()
500
+ if users:
501
+ user_id = users[0]['id']
502
+
503
+ logger.info(f'Created user: {email} with ID {user_id}')
504
+ return user_id
505
+
506
+ except httpx.HTTPStatusError as e:
507
+ logger.error(f'Failed to create user: {e.response.text}')
508
+ raise KeycloakTenantServiceError(
509
+ f'Failed to create user: {e.response.text}'
510
+ )
511
+
512
+ async def _assign_role_to_user(
513
+ self,
514
+ client: httpx.AsyncClient,
515
+ realm_name: str,
516
+ user_id: str,
517
+ role_name: str,
518
+ ) -> None:
519
+ """Assign a realm role to a user."""
520
+ # First, get the role representation
521
+ role_url = (
522
+ f'{self.keycloak_url}/admin/realms/{realm_name}/roles/{role_name}'
523
+ )
524
+
525
+ try:
526
+ role_response = await client.get(
527
+ role_url,
528
+ headers=self._get_auth_headers(),
529
+ timeout=30.0,
530
+ )
531
+ role_response.raise_for_status()
532
+ role = role_response.json()
533
+
534
+ # Assign role to user
535
+ user_roles_url = f'{self.keycloak_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm'
536
+
537
+ response = await client.post(
538
+ user_roles_url,
539
+ json=[role],
540
+ headers=self._get_auth_headers(),
541
+ timeout=30.0,
542
+ )
543
+ response.raise_for_status()
544
+ logger.info(f'Assigned role {role_name} to user {user_id}')
545
+
546
+ except httpx.HTTPStatusError as e:
547
+ logger.error(f'Failed to assign role: {e.response.text}')
548
+ raise KeycloakTenantServiceError(
549
+ f'Failed to assign role: {e.response.text}'
550
+ )
551
+
552
+ async def delete_tenant(self, realm_name: str) -> bool:
553
+ """
554
+ Delete a tenant (realm) from Keycloak.
555
+
556
+ Args:
557
+ realm_name: The realm name to delete
558
+
559
+ Returns:
560
+ True if successfully deleted
561
+ """
562
+ await self._get_admin_token()
563
+
564
+ realm_url = f'{self.keycloak_url}/admin/realms/{realm_name}'
565
+
566
+ async with httpx.AsyncClient() as client:
567
+ try:
568
+ response = await client.delete(
569
+ realm_url,
570
+ headers=self._get_auth_headers(),
571
+ timeout=30.0,
572
+ )
573
+
574
+ if response.status_code == 404:
575
+ raise TenantNotFoundError(f'Realm {realm_name} not found')
576
+
577
+ response.raise_for_status()
578
+ logger.info(f'Deleted tenant realm: {realm_name}')
579
+ return True
580
+
581
+ except httpx.HTTPStatusError as e:
582
+ if e.response.status_code == 404:
583
+ raise TenantNotFoundError(f'Realm {realm_name} not found')
584
+ logger.error(f'Failed to delete realm: {e.response.text}')
585
+ raise KeycloakTenantServiceError(
586
+ f'Failed to delete realm: {e.response.text}'
587
+ )
588
+
589
+ async def get_tenant_clients(self, realm_name: str) -> List[Dict[str, Any]]:
590
+ """
591
+ Get all clients for a tenant realm.
592
+
593
+ Args:
594
+ realm_name: The realm name to fetch clients for
595
+
596
+ Returns:
597
+ List of client details
598
+ """
599
+ await self._get_admin_token()
600
+
601
+ clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
602
+
603
+ async with httpx.AsyncClient() as client:
604
+ try:
605
+ response = await client.get(
606
+ clients_url,
607
+ headers=self._get_auth_headers(),
608
+ timeout=30.0,
609
+ )
610
+
611
+ if response.status_code == 404:
612
+ raise TenantNotFoundError(f'Realm {realm_name} not found')
613
+
614
+ response.raise_for_status()
615
+ clients = response.json()
616
+
617
+ # Filter out built-in Keycloak clients and format response
618
+ custom_clients = []
619
+ builtin_clients = {
620
+ 'account',
621
+ 'account-console',
622
+ 'admin-cli',
623
+ 'broker',
624
+ 'realm-management',
625
+ 'security-admin-console',
626
+ }
627
+
628
+ for c in clients:
629
+ client_id = c.get('clientId', '')
630
+ if client_id not in builtin_clients:
631
+ client_info = {
632
+ 'id': c.get('id'),
633
+ 'clientId': client_id,
634
+ 'name': c.get('name'),
635
+ 'enabled': c.get('enabled'),
636
+ 'publicClient': c.get('publicClient'),
637
+ 'standardFlowEnabled': c.get('standardFlowEnabled'),
638
+ 'directAccessGrantsEnabled': c.get(
639
+ 'directAccessGrantsEnabled'
640
+ ),
641
+ 'serviceAccountsEnabled': c.get(
642
+ 'serviceAccountsEnabled'
643
+ ),
644
+ 'redirectUris': c.get('redirectUris', []),
645
+ 'webOrigins': c.get('webOrigins', []),
646
+ }
647
+
648
+ # Get client secret for confidential clients
649
+ if not c.get('publicClient'):
650
+ secret = await self._get_client_secret(
651
+ client, realm_name, c.get('id')
652
+ )
653
+ client_info['secret'] = secret
654
+
655
+ custom_clients.append(client_info)
656
+
657
+ logger.info(
658
+ f'Retrieved {len(custom_clients)} clients for realm {realm_name}'
659
+ )
660
+ return custom_clients
661
+
662
+ except httpx.HTTPStatusError as e:
663
+ if e.response.status_code == 404:
664
+ raise TenantNotFoundError(f'Realm {realm_name} not found')
665
+ logger.error(f'Failed to get clients: {e.response.text}')
666
+ raise KeycloakTenantServiceError(
667
+ f'Failed to get clients: {e.response.text}'
668
+ )
669
+
670
+ async def _get_client_secret(
671
+ self, client: httpx.AsyncClient, realm_name: str, client_uuid: str
672
+ ) -> Optional[str]:
673
+ """Get the secret for a confidential client."""
674
+ secret_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients/{client_uuid}/client-secret'
675
+
676
+ try:
677
+ response = await client.get(
678
+ secret_url,
679
+ headers=self._get_auth_headers(),
680
+ timeout=30.0,
681
+ )
682
+ response.raise_for_status()
683
+ data = response.json()
684
+ return data.get('value')
685
+ except httpx.HTTPStatusError:
686
+ return None
687
+
688
+ async def tenant_exists(self, realm_name: str) -> bool:
689
+ """
690
+ Check if a tenant realm exists.
691
+
692
+ Args:
693
+ realm_name: The realm name to check
694
+
695
+ Returns:
696
+ True if realm exists
697
+ """
698
+ await self._get_admin_token()
699
+
700
+ realm_url = f'{self.keycloak_url}/admin/realms/{realm_name}'
701
+
702
+ async with httpx.AsyncClient() as client:
703
+ try:
704
+ response = await client.get(
705
+ realm_url,
706
+ headers=self._get_auth_headers(),
707
+ timeout=30.0,
708
+ )
709
+ return response.status_code == 200
710
+ except httpx.HTTPError:
711
+ return False
712
+
713
+ async def list_tenants(self) -> List[Dict[str, Any]]:
714
+ """
715
+ List all tenant realms (excluding master and default realms).
716
+
717
+ Returns:
718
+ List of realm information
719
+ """
720
+ await self._get_admin_token()
721
+
722
+ realms_url = f'{self.keycloak_url}/admin/realms'
723
+
724
+ async with httpx.AsyncClient() as client:
725
+ try:
726
+ response = await client.get(
727
+ realms_url,
728
+ headers=self._get_auth_headers(),
729
+ timeout=30.0,
730
+ )
731
+ response.raise_for_status()
732
+ realms = response.json()
733
+
734
+ # Filter to only codetether.run realms
735
+ tenant_realms = []
736
+ for realm in realms:
737
+ realm_name = realm.get('realm', '')
738
+ if realm_name.endswith('.codetether.run'):
739
+ tenant_realms.append(
740
+ {
741
+ 'realm_name': realm_name,
742
+ 'org_slug': realm_name.replace(
743
+ '.codetether.run', ''
744
+ ),
745
+ 'display_name': realm.get('displayName'),
746
+ 'enabled': realm.get('enabled'),
747
+ }
748
+ )
749
+
750
+ return tenant_realms
751
+
752
+ except httpx.HTTPStatusError as e:
753
+ logger.error(f'Failed to list realms: {e.response.text}')
754
+ raise KeycloakTenantServiceError(
755
+ f'Failed to list realms: {e.response.text}'
756
+ )
757
+
758
+
759
+ # Global tenant service instance
760
+ keycloak_tenant_service = KeycloakTenantService()