-
Notifications
You must be signed in to change notification settings - Fork 38
Expand file tree
/
Copy pathsetup_keycloak.py
More file actions
1521 lines (1278 loc) · 59.5 KB
/
Copy pathsetup_keycloak.py
File metadata and controls
1521 lines (1278 loc) · 59.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
setup_keycloak.py - Keycloak Setup for GitHub Issue Agent + AuthBridge Demo
This script supports two modes:
Manual mode (no config file):
Configures Keycloak for running the GitHub Issue Agent demo with
AuthBridge transparent token exchange.
Architecture:
UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent
↓
Agent Pod (git-issue-agent + AuthBridge sidecars)
|
| Agent calls GitHub Tool with user's token
v
AuthProxy (Envoy) - intercepts, validates inbound, exchanges outbound
|
| Token Exchange → audience "github-tool"
v
GitHub Tool (validates token, uses appropriate GitHub PAT)
Clients created:
- github-tool: Target audience for token exchange (the MCP GitHub tool)
Client Scopes created:
- agent-<ns>-<sa>-aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT)
- github-tool-aud: Adds "github-tool" to exchanged tokens (realm OPTIONAL)
- github-full-access: Optional scope for privileged GitHub API access (realm OPTIONAL)
Demo Users created:
- alice: Regular user — tokens requested without github-full-access scope → public access
- bob: Privileged user — tokens requested with scope=github-full-access → full access
Note on scope model:
github-full-access is a realm OPTIONAL scope. Optional scopes are NOT automatically
included in tokens — the client must explicitly request them via the "scope" parameter
in the token request. In a production system you would enforce per-user scope access
via role-based policies. In this demo the calling client controls which scope to
request for each user (see demo-manual.md Step 9).
Usage:
python setup_keycloak.py
python setup_keycloak.py --namespace myns --service-account mysa
RBAC mode (config file supplied via -rbac flag):
Creates a realm with clients, roles, client scopes, audience mappers,
and users to demonstrate role-based access control through OAuth2 token exchange.
Before provisioning, existing artifacts declared in the config (clients,
client roles, realm roles, and client scopes) are deleted so re-running
the script starts from a clean slate. Operator-registered scopes (e.g.
agent-*-aud created by the operator's ClientRegistrationReconciler)
are intentionally left alone.
Usage:
python setup_keycloak.py -rbac <path-to>/config.yaml [-policy <path-to>/policy.yaml] [--reset-only]
Arguments:
-rbac <path-to>/config_file.yaml Path to main configuration YAML file
-policy <path-to>/policy.yaml Path to access control policy YAML file (optional).
Makes realm roles composites of the client roles
declared in the policy, implementing RBAC through
OAuth2 token scopes.
Flags:
--reset-only Run the cleanup pass (initialize_realm_state) and exit
without provisioning. Useful for tearing the demo
state down between runs.
Environment variables loaded from .env file
Configuration files: (assumed to be under 'aiac' directory relative to script dir)
.env - Keycloak connection settings and realm name
config.yaml - Main configuration (clients, roles, users, scope_to_client)
policy.yaml - Access control policy (realm role -> client role mappings)
Security Note:
- This script uses default Keycloak admin credentials (username: "admin", password: "admin")
for demo and local development only. These credentials are insecure and MUST NOT be used
in any production or internet-exposed environment.
"""
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from dotenv import load_dotenv
from keycloak import KeycloakAdmin, KeycloakGetError, KeycloakPostError
# ===========================================================================
# Manual setup — module-level configuration
# ===========================================================================
# Default configuration
KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080")
KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "rossoctl")
KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin")
KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin")
if KEYCLOAK_ADMIN_USERNAME == "admin" and KEYCLOAK_ADMIN_PASSWORD == "admin":
print(
"WARNING: Using default Keycloak admin credentials 'admin'/'admin'. "
"These credentials are INSECURE and must NOT be used in production.",
file=sys.stderr,
)
DEFAULT_NAMESPACE = "team1"
DEFAULT_SERVICE_ACCOUNT = "git-issue-agent"
SPIFFE_TRUST_DOMAIN = "localtest.me"
UI_CLIENT_ID = os.environ.get("UI_CLIENT_ID", "rossoctl")
DEMO_USERS = [
{
"username": "alice",
"email": "alice@example.com",
"firstName": "Alice",
"lastName": "Demo",
"password": "alice123",
"description": "Regular user - request token without github-full-access scope",
},
{
"username": "bob",
"email": "bob@example.com",
"firstName": "Bob",
"lastName": "Admin",
"password": "bob123",
"description": "Privileged user - request token with scope=github-full-access",
},
]
# ===========================================================================
# Manual helper functions
# ===========================================================================
def get_spiffe_id(namespace: str, service_account: str) -> str:
return f"spiffe://{SPIFFE_TRUST_DOMAIN}/ns/{namespace}/sa/{service_account}"
def ensure_admin_in_realm(keycloak_admin, username, password):
"""Ensure the admin user exists in the target realm with a non-temporary password.
Fixes 'invalid_grant: Invalid user credentials' when requesting tokens directly
from the realm — the admin user lives in master but not in the target realm.
"""
user_id = keycloak_admin.get_user_id(username)
if not user_id:
keycloak_admin.create_user({"username": username, "enabled": True, "emailVerified": True})
user_id = keycloak_admin.get_user_id(username)
print(f"Created user '{username}' in realm '{KEYCLOAK_REALM}'.")
keycloak_admin.set_user_password(user_id, password, temporary=False)
print(f"Password set as non-temporary for '{username}' in realm '{KEYCLOAK_REALM}'.")
def get_or_create_realm(keycloak_admin, realm_name):
try:
realms = keycloak_admin.get_realms()
for realm in realms:
if realm["realm"] == realm_name:
print(f"Realm '{realm_name}' already exists.")
return
keycloak_admin.create_realm({"realm": realm_name, "enabled": True, "displayName": realm_name})
print(f"Created realm '{realm_name}'.")
except Exception as e:
print(f"Error checking/creating realm: {e}", file=sys.stderr)
raise
def get_or_create_client(keycloak_admin, client_payload):
client_id = client_payload["clientId"]
existing_client_id = keycloak_admin.get_client_id(client_id)
if existing_client_id:
print(f"Client '{client_id}' already exists.")
return existing_client_id
internal_id = keycloak_admin.create_client(client_payload)
print(f"Created client '{client_id}'.")
return internal_id
def get_or_create_client_scope(keycloak_admin, scope_payload):
scope_name = scope_payload.get("name")
scopes = keycloak_admin.get_client_scopes()
for scope in scopes:
if scope["name"] == scope_name:
print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}")
return scope["id"]
try:
scope_id = keycloak_admin.create_client_scope(scope_payload)
print(f"Created client scope '{scope_name}': {scope_id}")
return scope_id
except KeycloakPostError as e:
print(f"Could not create client scope '{scope_name}': {e}")
raise
def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience):
mapper_payload = {
"name": mapper_name,
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": False,
"config": {
"included.custom.audience": audience,
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false",
},
}
try:
keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload)
print(f"Added audience mapper '{mapper_name}' for audience '{audience}'")
except Exception as e:
print(f"Note: Could not add mapper '{mapper_name}' (might already exist): {e}")
def get_or_create_user(keycloak_admin, user_config):
username = user_config["username"]
users = keycloak_admin.get_users({"username": username})
existing_user = next(
(user for user in users if user.get("username") == username),
None,
)
if existing_user:
print(f"User '{username}' already exists.")
return existing_user["id"]
try:
user_id = keycloak_admin.create_user(
{
"username": username,
"email": user_config["email"],
"firstName": user_config["firstName"],
"lastName": user_config["lastName"],
"enabled": True,
"emailVerified": True,
"credentials": [
{
"type": "password",
"value": user_config["password"],
"temporary": False,
}
],
}
)
print(f"Created user '{username}' with ID: {user_id}")
return user_id
except KeycloakPostError as e:
print(f"Could not create user '{username}': {e}")
raise
def main_manual(namespace: str = DEFAULT_NAMESPACE, service_account: str = DEFAULT_SERVICE_ACCOUNT):
agent_spiffe_id = get_spiffe_id(namespace, service_account)
print("=" * 70)
print("GitHub Issue Agent + AuthBridge - Keycloak Setup")
print("=" * 70)
print(f"\nNamespace: {namespace}")
print(f"Service Account: {service_account}")
print(f"SPIFFE ID: {agent_spiffe_id}")
# Connect to Keycloak
print(f"\nConnecting to Keycloak at {KEYCLOAK_URL}...")
try:
master_admin = KeycloakAdmin(
server_url=KEYCLOAK_URL,
username=KEYCLOAK_ADMIN_USERNAME,
password=KEYCLOAK_ADMIN_PASSWORD,
realm_name="master",
user_realm_name="master",
)
except Exception as e:
print(f"Failed to connect to Keycloak: {e}")
print("\nMake sure Keycloak is running and accessible at:")
print(f" {KEYCLOAK_URL}")
print("\nIf using port-forward, run:")
print(" kubectl port-forward service/keycloak-service -n keycloak 8080:8080")
sys.exit(1)
# Ensure admin password is non-temporary
admin_user_id = master_admin.get_user_id(KEYCLOAK_ADMIN_USERNAME)
if admin_user_id:
master_admin.set_user_password(admin_user_id, KEYCLOAK_ADMIN_PASSWORD, temporary=False)
print(f"Admin password set as non-temporary for '{KEYCLOAK_ADMIN_USERNAME}'.")
# Create realm
print(f"\n--- Setting up realm: {KEYCLOAK_REALM} ---")
get_or_create_realm(master_admin, KEYCLOAK_REALM)
# Switch to target realm
keycloak_admin = KeycloakAdmin(
server_url=KEYCLOAK_URL,
username=KEYCLOAK_ADMIN_USERNAME,
password=KEYCLOAK_ADMIN_PASSWORD,
realm_name=KEYCLOAK_REALM,
user_realm_name="master",
)
# Ensure admin user exists in the rossoctl realm so direct token requests succeed
ensure_admin_in_realm(keycloak_admin, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD)
# ---------------------------------------------------------------
# Create github-tool client (target audience for token exchange)
# ---------------------------------------------------------------
print("\n--- Creating github-tool client ---")
print("This client represents the GitHub MCP tool as a token exchange target")
get_or_create_client(
keycloak_admin,
{
"clientId": "github-tool",
"name": "GitHub Tool",
"enabled": True,
"publicClient": False,
"standardFlowEnabled": False,
"serviceAccountsEnabled": True,
"attributes": {"standard.token.exchange.enabled": "true"},
},
)
# ---------------------------------------------------------------
# Create client scopes
# ---------------------------------------------------------------
print("\n--- Creating client scopes ---")
# 1. agent-spiffe-aud scope: adds Agent's SPIFFE ID to all tokens (realm default)
scope_name = f"agent-{namespace}-{service_account}-aud"
print(f"\nCreating scope for Agent's SPIFFE ID audience: {scope_name}")
agent_spiffe_scope_id = get_or_create_client_scope(
keycloak_admin,
{
"name": scope_name,
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
},
},
)
add_audience_mapper(keycloak_admin, agent_spiffe_scope_id, scope_name, agent_spiffe_id)
# 2. github-tool-aud scope: adds "github-tool" to exchanged tokens (optional)
print("\nCreating scope for github-tool audience...")
github_tool_scope_id = get_or_create_client_scope(
keycloak_admin,
{
"name": "github-tool-aud",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
},
},
)
add_audience_mapper(keycloak_admin, github_tool_scope_id, "github-tool-aud", "github-tool")
# 3. github-full-access scope: optional scope for privileged access
print("\nCreating scope for privileged GitHub access...")
github_full_access_scope_id = get_or_create_client_scope(
keycloak_admin,
{
"name": "github-full-access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
},
},
)
# ---------------------------------------------------------------
# Assign scopes at realm level
# ---------------------------------------------------------------
print("\n--- Assigning scopes ---")
# agent-spiffe-aud as realm default (all tokens get Agent's SPIFFE ID in audience)
try:
keycloak_admin.add_default_default_client_scope(agent_spiffe_scope_id)
print(f"Added '{scope_name}' as realm default scope.")
except Exception as e:
print(f"Note: Could not add '{scope_name}' as realm default: {e}")
# github-tool-aud as realm optional (available for token exchange requests)
try:
keycloak_admin.add_default_optional_client_scope(github_tool_scope_id)
print("Added 'github-tool-aud' as realm OPTIONAL scope.")
except Exception as e:
print(f"Note: Could not add 'github-tool-aud' as optional: {e}")
# github-full-access as realm optional (must be requested explicitly in token request)
try:
keycloak_admin.add_default_optional_client_scope(github_full_access_scope_id)
print("Added 'github-full-access' as realm OPTIONAL scope.")
print(" → Tokens will only include this scope when explicitly requested")
print(" via scope=github-full-access in the token request.")
except Exception as e:
print(f"Note: Could not add 'github-full-access' as optional: {e}")
# ---------------------------------------------------------------
# Add agent audience scope to the Rossoctl UI client
# ---------------------------------------------------------------
# Keycloak only auto-assigns realm default scopes to NEW clients.
# The UI client was created during install (before this scope existed),
# so we must add it explicitly. Without this, the UI's tokens won't
# include the agent's SPIFFE ID in the audience, and AuthBridge will
# reject UI chat requests with "invalid audience".
#
# TODO: Remove this workaround once the client-registration sidecar
# handles this automatically (rossoctl/cortex#169).
print(f"\n--- Adding agent audience scope to UI client '{UI_CLIENT_ID}' ---")
ui_client_internal_id = keycloak_admin.get_client_id(UI_CLIENT_ID)
if ui_client_internal_id:
try:
keycloak_admin.add_client_default_client_scope(ui_client_internal_id, agent_spiffe_scope_id, {})
print(f"Added '{scope_name}' as default scope on client '{UI_CLIENT_ID}'.")
print(" → UI tokens will now include the agent's SPIFFE ID in audience.")
print(" → Users must log out and back in for the new scope to take effect.")
except Exception as e:
print(f"Note: Could not add scope to '{UI_CLIENT_ID}' client: {e}")
else:
print(
f"Warning: UI client '{UI_CLIENT_ID}' not found in realm "
f"'{KEYCLOAK_REALM}'. UI chat with this agent will require "
f"manually adding the '{scope_name}' scope to the UI client."
)
# ---------------------------------------------------------------
# Add token exchange scopes to the agent's client (if it exists)
# ---------------------------------------------------------------
# The agent's Keycloak client is created dynamically by the
# client-registration sidecar when the agent pod starts. If the
# client already exists (from a prior deployment), realm-level
# optional scopes added after client creation won't be inherited.
# Explicitly add the scopes so client_credentials grants with
# scope=github-tool-aud+github-full-access succeed.
print("\n--- Adding scopes to agent client (if registered) ---")
agent_internal_id = keycloak_admin.get_client_id(agent_spiffe_id)
if agent_internal_id:
# Add the agent's own audience scope as a default so tokens issued
# via client_credentials include the agent's SPIFFE ID in `aud`.
try:
keycloak_admin.add_client_default_client_scope(agent_internal_id, agent_spiffe_scope_id, {})
print(f"Added '{scope_name}' as default scope on agent client.")
print(" → client_credentials tokens will include the agent's SPIFFE ID in aud.")
except Exception as e:
print(f"Note: Could not add '{scope_name}' to agent client: {e}")
# Add token exchange scopes as optional so client_credentials grants
# with scope=github-tool-aud+github-full-access succeed.
for exchange_scope_name, exchange_scope_id in [
("github-tool-aud", github_tool_scope_id),
("github-full-access", github_full_access_scope_id),
]:
try:
keycloak_admin.add_client_optional_client_scope(agent_internal_id, exchange_scope_id, {})
print(f"Added '{exchange_scope_name}' as optional scope on agent client.")
except Exception as e:
print(f"Note: Could not add '{exchange_scope_name}' to agent client: {e}")
else:
print(
f"Agent client '{agent_spiffe_id}' not yet registered.\n"
f" The scopes are realm-level defaults/optionals and will be inherited\n"
f" when client-registration creates the client. If the agent was deployed\n"
f" before this script, re-run it after the agent is running."
)
# ---------------------------------------------------------------
# Create demo users and assign the admin realm role
# ---------------------------------------------------------------
print("\n--- Creating demo users ---")
for user in DEMO_USERS:
print(f"\n {user['username']}: {user['description']}")
get_or_create_user(keycloak_admin, user)
# The Rossoctl backend uses the "admin" realm role for RBAC. Without
# it, users can log in but see no agents or tools in the UI.
print("\n--- Assigning 'admin' realm role to demo users ---")
try:
admin_role = keycloak_admin.get_realm_role("admin")
except KeycloakGetError:
admin_role = None
if admin_role:
for user in DEMO_USERS:
user_id = keycloak_admin.get_user_id(user["username"])
try:
keycloak_admin.assign_realm_roles(user_id, [admin_role])
print(f"Assigned 'admin' role to '{user['username']}'.")
except Exception as e:
print(f"Note: Could not assign 'admin' role to '{user['username']}' (might already have it): {e}")
else:
print(
"Warning: 'admin' realm role not found. Demo users will not "
"be able to see agents/tools in the UI. Ensure the Rossoctl "
"platform is installed before running this script."
)
# ---------------------------------------------------------------
# Summary
# ---------------------------------------------------------------
print("\n" + "=" * 70)
print("SETUP COMPLETE")
print("=" * 70)
print(
f"""
Keycloak is configured for the GitHub Issue Agent + AuthBridge demo.
Created:
Realm: {KEYCLOAK_REALM}
Clients: github-tool (target audience for token exchange)
Scopes: {scope_name} (realm DEFAULT - auto-adds Agent's SPIFFE ID to aud)
github-tool-aud (realm OPTIONAL - for exchanged tokens)
github-full-access (realm OPTIONAL - for privileged access)
Users: alice (public access), bob (privileged access) — both with admin role
Scope model:
github-full-access is OPTIONAL — it must be explicitly requested in the
token request (scope=github-full-access). To test:
- alice: request token WITHOUT github-full-access → PUBLIC_ACCESS_PAT
- bob: request token WITH scope=github-full-access → PRIVILEGED_ACCESS_PAT
Token flow:
1. UI gets token for user (aud includes Agent's SPIFFE ID via default scope)
2. UI sends request to Agent with token
3. AuthBridge validates inbound token (aud = Agent's SPIFFE ID)
4. Agent calls GitHub tool
5. AuthBridge exchanges token: aud={agent_spiffe_id} → aud=github-tool
6. GitHub tool validates exchanged token and uses appropriate PAT
Next steps:
1. Deploy operator: See https://github.com/rossoctl/operator for webhook setup
2. Apply ConfigMaps: kubectl apply -f demos/github-issue/k8s/configmaps.yaml
3. Create PAT secret: kubectl create secret generic github-tool-secrets -n {namespace} \\
--from-literal=INIT_AUTH_HEADER="Bearer <PRIVILEGED_PAT>" \\
--from-literal=UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE="Bearer <PRIVILEGED_PAT>" \\
--from-literal=UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE="Bearer <PUBLIC_PAT>"
4. Deploy tool: kubectl apply -f demos/github-issue/k8s/github-tool-deployment.yaml
5. Deploy agent: kubectl apply -f demos/github-issue/k8s/git-issue-agent-deployment.yaml
"""
)
# ===========================================================================
# Config-file-based setup
# ===========================================================================
# ---------------------------------------------------------------------------
# 1. Configuration loading
# ---------------------------------------------------------------------------
def load_main_config(config_file: Path) -> Dict[str, Any]:
"""Load main configuration from YAML file."""
if not config_file.exists():
raise FileNotFoundError(f"Configuration file not found: {config_file}")
with open(config_file, "r") as f:
return yaml.safe_load(f)
def get_config_value(config: Dict[str, Any], *keys, default=None, env_var=None) -> Any:
"""Get configuration value with fallback to environment variable and default."""
if env_var and os.environ.get(env_var):
return os.environ.get(env_var)
value = config
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return default
return value if value != config else default
def load_keycloak_config() -> Tuple[str, str]:
"""Read and validate non-sensitive Keycloak connection settings.
Credentials are intentionally not returned to avoid tainting non-sensitive
variables. They are consumed directly by connect_admin.
"""
keycloak_url = os.getenv("KEYCLOAK_URL")
realm = os.getenv("REALM_NAME")
admin_username = os.getenv("KEYCLOAK_ADMIN_USERNAME")
admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD")
if not all([keycloak_url, realm, admin_username, admin_password]):
raise ValueError(
"Missing required environment variables. Please ensure aiac.env file contains "
"KEYCLOAK_URL, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD, and REALM_NAME"
)
assert isinstance(keycloak_url, str)
assert isinstance(realm, str)
return keycloak_url, realm
def connect_admin(server_url: str, realm_name: str) -> KeycloakAdmin:
"""Build a KeycloakAdmin client authenticating against the master realm."""
return KeycloakAdmin(
server_url=server_url,
username=os.environ["KEYCLOAK_ADMIN_USERNAME"],
password=os.environ["KEYCLOAK_ADMIN_PASSWORD"],
realm_name=realm_name,
user_realm_name="master",
)
# ---------------------------------------------------------------------------
# 2. Realm creation
# ---------------------------------------------------------------------------
def create_realm(admin: KeycloakAdmin, realm: str) -> None:
"""Create the demo realm if it does not already exist."""
print(f"\n=== Creating realm: {realm} ===")
try:
admin.create_realm(
{
"realm": realm,
"enabled": True,
"accessTokenLifespan": 600,
"verifyEmail": False,
"registrationEmailAsUsername": False,
}
)
print(f" Created realm: {realm}")
except KeycloakPostError:
print(f" Realm {realm} already exists, continuing...")
# ---------------------------------------------------------------------------
# 3. Reset realm state
# ---------------------------------------------------------------------------
def preserve_client_secrets(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> Dict[str, str]:
"""Preserve secrets from existing clients that don't have explicit secrets in config.
Returns:
Dict mapping client_id to secret for clients that should preserve their secret
"""
print("\nPreserving client secrets:")
preserved_secrets: Dict[str, str] = {}
for client_config in clients_config:
client_id = client_config["client_id"]
# Only preserve if no explicit secret in config
if not client_config.get("secret"):
existing_secret = get_existing_client_secret(admin, client_id)
if existing_secret:
preserved_secrets[client_id] = existing_secret
print(f" ✓ Preserved secret for: {client_id}")
else:
print(f" - No existing secret for: {client_id}")
return preserved_secrets
def initialize_realm_state(admin: KeycloakAdmin, main_config: Dict[str, Any]) -> Dict[str, str]:
"""Delete client roles, clients, realm roles, and client scopes defined in config so re-provisioning \
starts clean.
Returns:
Dict mapping client_id to preserved secret (for clients without explicit secret in config)
"""
clients_config = main_config.get("clients", [])
realm_roles_config = main_config.get("realm_roles", [])
# Preserve secrets before deleting clients
preserved_secrets = preserve_client_secrets(admin, clients_config)
delete_client_roles(admin, clients_config)
delete_clients(admin, clients_config)
delete_realm_roles(admin, realm_roles_config)
delete_client_scopes(admin, clients_config)
return preserved_secrets
def delete_client_scopes(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None:
"""Delete client scopes whose names match roles declared in clients_config.
Operator-registered scopes (e.g. agent-*-aud) are not in clients_config roles
and are intentionally left alone.
"""
print("\nDeleting client scopes:")
if not clients_config:
print(" No clients in configuration")
return
scope_names = {
role["name"] if isinstance(role, dict) else role for c in clients_config for role in c.get("roles", ["access"])
}
try:
existing = {s["name"]: s["id"] for s in admin.get_client_scopes()}
except Exception as e:
print(f" ✗ Failed to list client scopes: {e}")
return
for name in scope_names:
if name not in existing:
print(f" - Scope not found: {name}")
continue
try:
admin.delete_client_scope(existing[name])
print(f" ✓ Deleted client scope: {name}")
except Exception as e:
print(f" ✗ Failed to delete scope {name}: {e}")
def delete_client_roles(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None:
print("\nDeleting client roles:")
if not clients_config:
print(" No clients in configuration")
return
for client_config in clients_config:
client_id = client_config["client_id"]
client_roles = client_config.get("roles", ["access"])
try:
internal_id = admin.get_client_id(client_id)
except Exception as e:
print(f" - Skipping roles for {client_id}: {e}")
continue
if not internal_id:
print(f" - Client not found, skipping roles: {client_id}")
continue
for role in client_roles:
role_name = role["name"] if isinstance(role, dict) else role
try:
admin.delete_client_role(internal_id, role_name)
print(f" ✓ Deleted client role: {client_id}.{role_name}")
except Exception as e:
print(f" - Role not found or error: {client_id}.{role_name} ({e})")
def delete_clients(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None:
print("\nDeleting clients:")
if not clients_config:
print(" No clients in configuration")
return
for client_config in clients_config:
client_id = client_config["client_id"]
try:
internal_id = admin.get_client_id(client_id)
if internal_id:
admin.delete_client(internal_id)
print(f" ✓ Deleted client: {client_id}")
else:
print(f" - Client not found: {client_id}")
except Exception as e:
print(f" ✗ Failed to delete client {client_id}: {e}")
def delete_realm_roles(admin: KeycloakAdmin, realm_roles_config: List[str]) -> None:
print("\nDeleting realm roles:")
if not realm_roles_config:
print(" No realm roles in configuration")
return
for role_name in realm_roles_config:
try:
admin.delete_realm_role(role_name)
print(f" ✓ Deleted realm role: {role_name}")
except Exception as e:
print(f" - Realm role not found or error: {role_name} ({e})")
# ---------------------------------------------------------------------------
# 4. Create clients
# ---------------------------------------------------------------------------
def get_existing_client_secret(admin: KeycloakAdmin, client_id: str) -> str | None:
"""Retrieve the secret of an existing client, if it exists.
Returns:
The client secret if the client exists and has a secret, None otherwise.
"""
try:
internal_id = admin.get_client_id(client_id)
if not internal_id:
return None
# Get the full client representation which includes the secret
client_repr = admin.get_client(internal_id)
return client_repr.get("secret")
except Exception:
# Client doesn't exist or error retrieving it
return None
def create_clients(
admin: KeycloakAdmin, clients_config: List[Dict[str, Any]], preserved_secrets: Dict[str, str]
) -> Dict[str, Dict[str, Any]]:
"""Create every client in config and return a {client_id: {id, secret, roles}} map.
Args:
admin: Keycloak admin client
clients_config: List of client configurations
preserved_secrets: Dict of client_id -> secret for clients to preserve
"""
print("\n=== Creating clients ===")
client_ids: Dict[str, Dict[str, Any]] = {}
for client_config in clients_config:
client_id = client_config["client_id"]
client_secret = client_config.get("secret")
direct_access_enabled = client_config.get("direct_access_grants", False)
# If no secret is provided in config, use preserved secret if available
if not client_secret and client_id in preserved_secrets:
client_secret = preserved_secrets[client_id]
payload = build_client_payload(client_id, client_secret, direct_access_enabled)
internal_id = create_client_idempotent(admin, payload)
if client_secret:
if client_config.get("secret"):
print(" Secret: (configured)")
else:
print(" Secret: (preserved from existing client)")
else:
print(" Secret: (auto-generated by Keycloak)")
if direct_access_enabled:
print(" Direct access grants: enabled")
client_ids[client_id] = {
"id": internal_id,
"secret": client_secret,
"roles": client_config.get("roles", ["access"]),
}
return client_ids
def build_client_payload(client_id: str, client_secret: str | None, direct_access_enabled: bool) -> Dict[str, Any]:
"""Build the Keycloak client representation payload."""
payload: Dict[str, Any] = {
"clientId": client_id,
"publicClient": False,
"serviceAccountsEnabled": True,
"directAccessGrantsEnabled": direct_access_enabled,
"standardFlowEnabled": False,
"fullScopeAllowed": False,
"attributes": {"standard.token.exchange.enabled": "true"},
}
if client_secret:
payload["secret"] = client_secret
return payload
def create_client_idempotent(admin: KeycloakAdmin, payload: dict) -> str:
"""Create a client or return existing internal ID."""
client_id = payload["clientId"]
try:
internal_id = admin.create_client(payload)
print(f" Created client: {client_id}")
return internal_id
except KeycloakPostError:
internal_id = admin.get_client_id(client_id)
if internal_id is None:
raise ValueError(f"Client '{client_id}' not found and could not be created")
print(f" Using existing client: {client_id}")
return internal_id
# ---------------------------------------------------------------------------
# 5. Create client roles
# ---------------------------------------------------------------------------
def create_client_roles(admin: KeycloakAdmin, client_ids: Dict[str, Dict[str, Any]]) -> None:
print("\n=== Creating client roles ===")
for client_name, client_info in client_ids.items():
for role in client_info["roles"]:
role_name = role["name"] if isinstance(role, dict) else role
create_client_role_safe(admin, client_info["id"], role_name, client_name)
# NOTE: We do NOT add target client roles to source client scope mappings.
# This prevents target audiences from appearing in initial login tokens.
# Token exchange will still work because:
# 1. Target client scopes are assigned as DEFAULT to source clients
# 2. Scope-to-role mappings filter which scopes are included based on user's roles
# 3. During token exchange, Keycloak uses the requested audience to determine which scopes to include
print("\n=== Skipping target client role scope mappings (prevents initial token pollution) ===")
print(" Target audiences will only appear during token exchange, not in initial login tokens")
def create_client_role_safe(
admin: KeycloakAdmin,
client_id: str,
role_name: str,
client_name: str | None = None,
) -> bool:
"""Create a client role with proper error handling."""
display_name = client_name or client_id
try:
admin.create_client_role(
client_id,
{"name": role_name, "clientRole": True},
skip_exists=True,
)
print(f" ✓ Created client role: {role_name} for {display_name}")
return True
except Exception as e:
print(f" ℹ Client role {role_name} for {display_name} already exists or error: {e}")
return True
# ---------------------------------------------------------------------------
# 6. Create realm roles
# ---------------------------------------------------------------------------
def create_realm_roles(admin: KeycloakAdmin, realm_roles: List[str]) -> None:
print("\n=== Creating realm roles ===")
for role in realm_roles:
# Extract role name and description - support both dict and string formats
if isinstance(role, dict):
role_payload = {"name": role["name"]}
if "description" in role:
role_payload["description"] = role["description"]
role_name = role["name"]
else:
role_payload = {"name": role}
role_name = role
try:
admin.create_realm_role(role_payload, skip_exists=True)
print(f" Created role: {role_name}")
except Exception:
print(f" Role {role_name} already exists")
# ---------------------------------------------------------------------------
# 7. Create client scopes (one per role per client)
# ---------------------------------------------------------------------------
DEFAULT_SCOPE_ATTRIBUTES = {
"include_in_token_scope": "true",
"display_on_consent_screen": "false",
"consent_screen_text": "",
}
DEFAULT_MAPPER_CONFIG = {
"introspection_token_claim": "true",
"userinfo_token_claim": "false",
"id_token_claim": "false",
"lightweight_claim": "false",
"access_token_claim": "true",
"lightweight_access_token_claim": "false",
}
def create_client_scopes(
admin: KeycloakAdmin,
client_ids: Dict[str, Dict[str, Any]],
) -> Dict[str, str]:
"""Create one scope per role per client. Returns {scope_name: scope_id}."""
print("\n=== Creating client scopes ===")
scope_ids: Dict[str, str] = {}
for client_name, client_info in client_ids.items():
for role in client_info["roles"]:
# Extract role name - support both dict and string formats
scope_name = role["name"] if isinstance(role, dict) else role
scope_id = create_single_client_scope(
admin,
scope_name,
client_name,
DEFAULT_SCOPE_ATTRIBUTES,
DEFAULT_MAPPER_CONFIG,
)
scope_ids[scope_name] = scope_id
return scope_ids
def create_single_client_scope(
admin: KeycloakAdmin,
scope_name: str,
target_client: str,
default_attributes: Dict[str, str],
default_mapper_config: Dict[str, str],
) -> str:
"""Create client scope with audience mapper for a specific role.
The scope will be assigned as optional and conditionally included based on client role.
"""
keycloak_attributes = {key.replace("_", "."): str(value) for key, value in default_attributes.items()}
scope_payload = {
"name": scope_name,
"protocol": "openid-connect",
"attributes": keycloak_attributes,
}
scope_id = admin.create_client_scope(
scope_payload,
skip_exists=True,
)
_disable_full_scope_allowed(admin, scope_id, scope_name)
_add_audience_mapper(admin, scope_id, target_client, default_mapper_config)