Skip to content

Commit 12328ec

Browse files
committed
activate dockerized direct access grant on simulator
1 parent 071b62e commit 12328ec

File tree

10 files changed

+164
-41
lines changed

10 files changed

+164
-41
lines changed

docker-compose.dev.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ services:
109109
KEYCLOAK_URL: "http://keycloak:8080"
110110
API_URL: "http://backend:80/graphql"
111111
REALM: "tasks"
112+
USE_DIRECT_GRANT: "true"
113+
USERNAME: "test"
114+
PASSWORD: "test"
112115
CLIENT_ID: "tasks-web"
113116
depends_on:
114117
- backend

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ services:
114114
KEYCLOAK_URL: "http://keycloak:8080/keycloak"
115115
API_URL: "http://backend:80/graphql"
116116
REALM: "tasks"
117+
USE_DIRECT_GRANT: "true"
118+
USERNAME: "test"
119+
PASSWORD: "test"
117120
CLIENT_ID: "tasks-web"
118121
depends_on:
119122
- backend

keycloak/tasks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@
682682
"consentRequired" : false,
683683
"standardFlowEnabled" : true,
684684
"implicitFlowEnabled" : false,
685-
"directAccessGrantsEnabled" : false,
685+
"directAccessGrantsEnabled" : true,
686686
"serviceAccountsEnabled" : false,
687687
"publicClient" : true,
688688
"frontchannelLogout" : true,

simulator/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ FROM python:${PY_VERSION}-alpine
1616

1717
ENV PYTHONDONTWRITEBYTECODE=1
1818
ENV PYTHONUNBUFFERED=1
19+
ENV USE_DIRECT_GRANT=true
1920

2021
COPY --from=builder /build/venv /usr/local/
2122
COPY . /app
2223

2324
WORKDIR /app
2425

25-
CMD ["python", "-m", "simulator"]
26-
26+
CMD ["python", "main.py"]

simulator/README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This script is designed solely for development and testing environments to mimic
88

99
## Features
1010

11-
- **Authentication**: Implements OpenID Connect Authorization Code flow with a local callback server.
11+
- **Authentication**: Supports both interactive (browser-based) and non-interactive (direct grant) authentication flows.
1212
- **Location Management**: Displays hospital structure and manages location hierarchies.
1313
- **Patient Simulation**:
1414
- Creates new patients in waiting room or admits them directly.
@@ -33,13 +33,25 @@ This script is designed solely for development and testing environments to mimic
3333
KEYCLOAK_URL=http://localhost:8080
3434
API_URL=http://localhost:8000/graphql
3535
REALM=tasks
36-
CLIENT_ID=tasks-web
36+
USE_DIRECT_GRANT=false # Set to "true" for non-interactive authentication
37+
CLIENT_ID=tasks-web # Automatically set to "admin-cli" when USE_DIRECT_GRANT=true
38+
CLIENT_SECRET= # Optional, required if client is confidential
39+
USERNAME= # Required when USE_DIRECT_GRANT=true
40+
PASSWORD= # Required when USE_DIRECT_GRANT=true
3741
INFLUXDB_URL=http://localhost:8086
3842
INFLUXDB_TOKEN=tasks-token-secret
3943
INFLUXDB_ORG=tasks
4044
INFLUXDB_BUCKET=audit
4145
```
4246

47+
### Authentication Modes
48+
49+
The simulator supports two authentication modes:
50+
51+
1. **Interactive (Default)**: Browser-based OAuth2 Authorization Code flow with a local callback server. This is the default mode and requires no additional configuration.
52+
53+
2. **Non-Interactive**: Set `USE_DIRECT_GRANT=true` along with `USERNAME` and `PASSWORD` environment variables. The simulator will authenticate using the direct grant (resource owner password credentials) flow without opening a browser. This mode is automatically enabled in Docker containers.
54+
4355
## Usage
4456

4557
### Local Development
@@ -93,7 +105,9 @@ The simulator is split into multiple modules:
93105

94106
## How It Works
95107

96-
1. Authenticates with Keycloak using OAuth2 Authorization Code flow
108+
1. Authenticates with Keycloak using either:
109+
- Interactive: OAuth2 Authorization Code flow (default) - opens browser
110+
- Non-Interactive: Direct Grant flow (if USE_DIRECT_GRANT=true and USERNAME/PASSWORD are set) - no browser required
97111
2. Loads current state (locations, patients, tasks, users)
98112
3. Displays the location structure hierarchy
99113
4. Creates initial patients (some in waiting room, some admitted)

simulator/authentication.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1+
import threading
2+
import time
13
import webbrowser
24
from http.server import BaseHTTPRequestHandler, HTTPServer
35
from typing import Any
46
from urllib.parse import parse_qs, urlparse
5-
import threading
7+
68
import requests
79
from config import (
810
CALLBACK_PORT,
911
CLIENT_ID,
12+
CLIENT_SECRET,
1013
KEYCLOAK_URL,
14+
PASSWORD,
1115
REALM,
1216
REDIRECT_URI,
17+
USERNAME,
1318
logger,
1419
)
1520

@@ -96,3 +101,79 @@ def _exchange_code(self, code: str) -> str:
96101
token_data = response.json()
97102
logger.info("Authentication successful")
98103
return token_data["access_token"]
104+
105+
106+
class DirectGrantAuthenticator:
107+
def __init__(self, session: requests.Session):
108+
self.session = session
109+
110+
def login(self) -> str:
111+
if not USERNAME or not PASSWORD:
112+
raise Exception(
113+
"USERNAME and PASSWORD environment variables are required for non-interactive authentication",
114+
)
115+
116+
logger.info("Authenticating with username/password...")
117+
118+
token_url = (
119+
f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/token"
120+
)
121+
data = {
122+
"grant_type": "password",
123+
"client_id": CLIENT_ID,
124+
"username": USERNAME,
125+
"password": PASSWORD,
126+
"scope": "openid profile email organization",
127+
}
128+
129+
if CLIENT_SECRET:
130+
data["client_secret"] = CLIENT_SECRET
131+
132+
max_retries = 5
133+
retry_delay = 2
134+
135+
for attempt in range(max_retries):
136+
try:
137+
response = self.session.post(token_url, data=data, timeout=10)
138+
139+
if response.status_code == 200:
140+
token_data = response.json()
141+
logger.info("Authentication successful")
142+
return token_data["access_token"]
143+
144+
if response.status_code == 503 or response.status_code == 502:
145+
if attempt < max_retries - 1:
146+
logger.warning(
147+
f"Keycloak not ready (HTTP {response.status_code}). "
148+
f"Retrying in {retry_delay} seconds... (attempt {attempt + 1}/{max_retries})",
149+
)
150+
time.sleep(retry_delay)
151+
continue
152+
153+
try:
154+
error_data = response.json()
155+
error_msg = error_data.get(
156+
"error_description",
157+
error_data.get("error", "Unknown error"),
158+
)
159+
except Exception:
160+
error_msg = (
161+
f"HTTP {response.status_code}: {response.text[:100]}"
162+
)
163+
164+
raise Exception(
165+
f"Authentication failed: {error_msg}. "
166+
f"Make sure the client '{CLIENT_ID}' has 'Direct Access Grants' enabled in Keycloak "
167+
f"and that USERNAME/PASSWORD are correct.",
168+
)
169+
except requests.exceptions.RequestException as e:
170+
if attempt < max_retries - 1:
171+
logger.warning(
172+
f"Connection error during authentication: {e}. "
173+
f"Retrying in {retry_delay} seconds... (attempt {attempt + 1}/{max_retries})",
174+
)
175+
time.sleep(retry_delay)
176+
continue
177+
raise
178+
179+
raise Exception("Authentication failed after multiple retries")

simulator/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "http://localhost:8080")
99
API_URL = os.getenv("API_URL", "http://localhost:8000/graphql")
1010
REALM = os.getenv("REALM", "tasks")
11+
USE_DIRECT_GRANT = os.getenv("USE_DIRECT_GRANT", "false").lower() == "true"
1112
CLIENT_ID = os.getenv("CLIENT_ID", "tasks-web")
13+
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "")
14+
USERNAME = os.getenv("USERNAME", "test")
15+
PASSWORD = os.getenv("PASSWORD", "test")
1216

1317
CALLBACK_PORT = 8999
1418
REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback"

simulator/graphql_client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from typing import Optional
2-
from authentication import InteractiveAuthenticator
3-
from config import API_URL, logger
2+
from authentication import DirectGrantAuthenticator, InteractiveAuthenticator
3+
from config import API_URL, PASSWORD, USE_DIRECT_GRANT, USERNAME, logger
44
import requests
55

66

77
class GraphQLClient:
88
def __init__(self):
99
self.token: Optional[str] = None
1010
self.session = requests.Session()
11-
self.authenticator = InteractiveAuthenticator(self.session)
11+
if USE_DIRECT_GRANT and USERNAME and PASSWORD:
12+
self.authenticator = DirectGrantAuthenticator(self.session)
13+
else:
14+
self.authenticator = InteractiveAuthenticator(self.session)
1215

1316
def query(self, query: str, variables: dict = None) -> dict:
1417
if not self.token:

simulator/location_manager.py

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
from typing import List, Optional
2-
from graphql_client import GraphQLClient
1+
import random
2+
33
from config import logger
44
from data import RandomDataGenerator
5-
import random
5+
from graphql_client import GraphQLClient
66

77

88
class LocationManager:
99
def __init__(self, client: GraphQLClient):
1010
self.client = client
11-
self.all_locations: List[dict] = []
12-
self.locations: List[str] = []
13-
self.beds: List[str] = []
14-
self.rooms: List[str] = []
15-
self.teams: List[str] = []
16-
self.wards: List[str] = []
17-
self.clinics: List[str] = []
11+
self.all_locations: list[dict] = []
12+
self.locations: list[str] = []
13+
self.beds: list[str] = []
14+
self.rooms: list[str] = []
15+
self.teams: list[str] = []
16+
self.wards: list[str] = []
17+
self.clinics: list[str] = []
1818

1919
def _log_errors(self, context: str, response: dict) -> None:
2020
if "errors" in response:
@@ -41,7 +41,9 @@ def load_locations(self) -> None:
4141
self.all_locations = data.get("locationNodes", [])
4242

4343
self.locations = [
44-
loc["id"] for loc in self.all_locations if loc["kind"] in ["BED", "ROOM"]
44+
loc["id"]
45+
for loc in self.all_locations
46+
if loc["kind"] in ["BED", "ROOM"]
4547
]
4648
self.beds = [
4749
loc["id"] for loc in self.all_locations if loc["kind"] == "BED"
@@ -61,11 +63,11 @@ def load_locations(self) -> None:
6163

6264
logger.info(
6365
f"Locations loaded: {len(self.beds)} beds, {len(self.rooms)} rooms, "
64-
f"{len(self.teams)} teams, {len(self.wards)} wards, {len(self.clinics)} clinics"
66+
f"{len(self.teams)} teams, {len(self.wards)} wards, {len(self.clinics)} clinics",
6567
)
6668

6769
def print_structure(self) -> None:
68-
logger.info("\n" + "=" * 60)
70+
logger.info("=" * 60)
6971
logger.info("LOCATION STRUCTURE")
7072
logger.info("=" * 60)
7173

@@ -94,25 +96,34 @@ def print_tree(node_id: str, prefix: str = "", is_last: bool = True):
9496
"ROOM": "🚪",
9597
"BED": "🛏️",
9698
}.get(node["kind"], "📍")
97-
print(f"{prefix}{connector}{kind_icon} {node['title']} ({node['kind']})")
99+
print(
100+
f"{prefix}{connector}{kind_icon} {node['title']} ({node['kind']})",
101+
)
98102

99-
children = sorted(children_map[node_id], key=lambda cid: nodes[cid]["title"])
103+
children = sorted(
104+
children_map[node_id],
105+
key=lambda cid: nodes[cid]["title"],
106+
)
100107
for i, child_id in enumerate(children):
101108
is_last_child = i == len(children) - 1
102109
new_prefix = prefix + (" " if is_last else "│ ")
103110
print_tree(child_id, new_prefix, is_last_child)
104111

105-
for i, root_id in enumerate(sorted(roots, key=lambda rid: nodes[rid]["title"])):
112+
for i, root_id in enumerate(
113+
sorted(roots, key=lambda rid: nodes[rid]["title"]),
114+
):
106115
print_tree(root_id, "", i == len(roots) - 1)
107116
print("\n")
108117

109118
def create_location(
110119
self,
111120
title: str,
112121
kind: str,
113-
parent_id: Optional[str] = None,
114-
) -> Optional[str]:
115-
logger.debug(f"Location creation requested: {title} ({kind}) - not implemented via API")
122+
parent_id: str | None = None,
123+
) -> str | None:
124+
logger.debug(
125+
f"Location creation requested: {title} ({kind}) - not implemented via API",
126+
)
116127
return None
117128

118129
def create_rooms_and_beds(
@@ -140,25 +151,27 @@ def ensure_hospital_structure(self) -> None:
140151

141152
logger.warning(
142153
"No locations found. Locations should be created via scaffold data "
143-
"(see backend/scaffold.py). The simulator will work with existing locations only."
154+
"(see backend/scaffold.py). The simulator will work with existing locations only.",
144155
)
145156
self.load_locations()
146157

147-
def add_team_to_ward(self, ward_id: Optional[str] = None) -> Optional[str]:
158+
def add_team_to_ward(self, ward_id: str | None = None) -> str | None:
148159
if not ward_id:
149160
if not self.wards:
150161
logger.warning("No wards available to add team to")
151162
return None
152163
ward_id = random.choice(self.wards)
153164

154-
# Get ward info
155-
ward = next((loc for loc in self.all_locations if loc["id"] == ward_id), None)
165+
ward = next(
166+
(loc for loc in self.all_locations if loc["id"] == ward_id),
167+
None,
168+
)
156169
if not ward:
157170
return None
158171

159-
# Generate team name
160172
available_teams = [
161-
name for name in RandomDataGenerator.team_names
173+
name
174+
for name in RandomDataGenerator.team_names
162175
if not any(
163176
loc["title"] == name and loc.get("parentId") == ward_id
164177
for loc in self.all_locations
@@ -171,21 +184,21 @@ def add_team_to_ward(self, ward_id: Optional[str] = None) -> Optional[str]:
171184

172185
logger.info(
173186
f"Would add new team '{team_name}' to ward '{ward['title']}' "
174-
f"(location creation via API not available - use scaffold data)"
187+
f"(location creation via API not available - use scaffold data)",
175188
)
176189
return None
177190

178-
def get_random_bed(self) -> Optional[str]:
191+
def get_random_bed(self) -> str | None:
179192
if not self.beds:
180193
return None
181194
return random.choice(self.beds)
182195

183-
def get_random_room(self) -> Optional[str]:
196+
def get_random_room(self) -> str | None:
184197
if not self.rooms:
185198
return None
186199
return random.choice(self.rooms)
187200

188-
def get_random_location(self) -> Optional[str]:
201+
def get_random_location(self) -> str | None:
189202
if not self.locations:
190203
return None
191204
return random.choice(self.locations)

simulator/patient_manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ def ensure_diagnosis_property(self) -> None:
8888

8989
def create_patient(self, admit_directly: bool = False) -> Tuple[Optional[str], Optional[str]]:
9090
if not self.location_manager.clinics:
91-
logger.warning("No clinics available to assign patient")
92-
return None, None
91+
self.location_manager.load_locations()
92+
if not self.location_manager.clinics:
93+
logger.warning("No clinics available to assign patient")
94+
return None, None
9395

9496
first, last = RandomDataGenerator.get_name()
9597
diagnosis = TreatmentPlanner.get_random_diagnosis()

0 commit comments

Comments
 (0)