Skip to content

Commit c6bd020

Browse files
Keycloak middleware refactor and decorator additions
1 parent 30c9ecb commit c6bd020

File tree

12 files changed

+464
-144
lines changed

12 files changed

+464
-144
lines changed

frontend/src/pages/pages-config-list.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export class PageConfigList extends LitElement {
105105
this.loading = false;
106106
} catch (error) {
107107
console.error('Failed to fetch model configs:', error);
108-
this.error = `Failed to retrieve forecast configurations`;
108+
this.error = `Failed to retrieve the forecast configurations`;
109109
this.modelConfigs = [];
110110
this.configAssets = [];
111111
this.loading = false;
@@ -134,8 +134,8 @@ export class PageConfigList extends LitElement {
134134
await APIService.deleteModelConfig(this.realm, config.id);
135135
this.modelConfigs = this.modelConfigs?.filter((c) => c.id !== config.id);
136136
} catch (error) {
137-
showSnackbar(undefined, `Failed to delete config: ${error}`);
138-
console.error('Failed to delete config:', error);
137+
showSnackbar(undefined, `Failed to delete the config`);
138+
console.error('Failed to delete the config:', error);
139139
}
140140
}
141141
}

src/service_ml_forecast/api/model_config_route.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,21 @@
2828
from fastapi import APIRouter, Depends, HTTPException
2929
from fastapi.responses import JSONResponse
3030

31-
from service_ml_forecast.dependencies import get_config_service, oauth2_scheme
31+
from service_ml_forecast.clients.openremote.client_roles import ClientRoles
32+
from service_ml_forecast.dependencies import OAUTH2_SCHEME, OPENREMOTE_KC_RESOURCE, get_config_service
33+
from service_ml_forecast.middlewares.keycloak.decorators import realm_allowed, roles_allowed
34+
from service_ml_forecast.middlewares.keycloak.middleware import KeycloakMiddleware
35+
from service_ml_forecast.middlewares.keycloak.models import UserContext
3236
from service_ml_forecast.models.model_config import ModelConfig
3337
from service_ml_forecast.services.model_config_service import ModelConfigService
3438

35-
router = APIRouter(prefix="/api/{realm}/configs", tags=["Forecast Configs"])
39+
router = APIRouter(
40+
prefix="/api/{realm}/configs",
41+
tags=["Forecast Configs"],
42+
dependencies=[
43+
Depends(OAUTH2_SCHEME),
44+
],
45+
)
3646

3747

3848
@router.post(
@@ -42,10 +52,13 @@
4252
HTTPStatus.OK: {"description": "Model config has been created"},
4353
HTTPStatus.CONFLICT: {"description": "Model config already exists"},
4454
HTTPStatus.UNAUTHORIZED: {"description": "Unauthorized"},
55+
HTTPStatus.FORBIDDEN: {"description": "Forbidden - insufficient permissions"},
4556
},
4657
)
58+
@realm_allowed
59+
@roles_allowed(resource=OPENREMOTE_KC_RESOURCE, roles=[ClientRoles.WRITE_ADMIN_ROLE])
4760
async def create_model_config(
48-
token: Annotated[str, Depends(oauth2_scheme)],
61+
user: Annotated[UserContext, Depends(KeycloakMiddleware.get_user_context)],
4962
realm: str,
5063
model_config: ModelConfig,
5164
config_service: ModelConfigService = Depends(get_config_service),
@@ -60,10 +73,13 @@ async def create_model_config(
6073
HTTPStatus.OK: {"description": "Model config has been retrieved"},
6174
HTTPStatus.NOT_FOUND: {"description": "Model config not found"},
6275
HTTPStatus.UNAUTHORIZED: {"description": "Unauthorized"},
76+
HTTPStatus.FORBIDDEN: {"description": "Forbidden - insufficient permissions"},
6377
},
6478
)
79+
@realm_allowed
80+
@roles_allowed(resource=OPENREMOTE_KC_RESOURCE, roles=[ClientRoles.READ_ADMIN_ROLE])
6581
async def get_model_config(
66-
token: Annotated[str, Depends(oauth2_scheme)],
82+
user: Annotated[UserContext, Depends(KeycloakMiddleware.get_user_context)],
6783
realm: str,
6884
id: UUID,
6985
config_service: ModelConfigService = Depends(get_config_service),
@@ -77,10 +93,13 @@ async def get_model_config(
7793
responses={
7894
HTTPStatus.OK: {"description": "List of model configs has been retrieved"},
7995
HTTPStatus.UNAUTHORIZED: {"description": "Unauthorized"},
96+
HTTPStatus.FORBIDDEN: {"description": "Forbidden - insufficient permissions"},
8097
},
8198
)
99+
@realm_allowed
100+
@roles_allowed(resource=OPENREMOTE_KC_RESOURCE, roles=[ClientRoles.READ_ADMIN_ROLE])
82101
async def get_model_configs(
83-
token: Annotated[str, Depends(oauth2_scheme)],
102+
user: Annotated[UserContext, Depends(KeycloakMiddleware.get_user_context)],
84103
realm: str,
85104
config_service: ModelConfigService = Depends(get_config_service),
86105
) -> list[ModelConfig]:
@@ -94,10 +113,13 @@ async def get_model_configs(
94113
HTTPStatus.OK: {"description": "Model config has been updated"},
95114
HTTPStatus.NOT_FOUND: {"description": "Model config not found"},
96115
HTTPStatus.UNAUTHORIZED: {"description": "Unauthorized"},
116+
HTTPStatus.FORBIDDEN: {"description": "Forbidden - insufficient permissions"},
97117
},
98118
)
119+
@realm_allowed
120+
@roles_allowed(resource=OPENREMOTE_KC_RESOURCE, roles=[ClientRoles.WRITE_ADMIN_ROLE])
99121
async def update_model_config(
100-
token: Annotated[str, Depends(oauth2_scheme)],
122+
user: Annotated[UserContext, Depends(KeycloakMiddleware.get_user_context)],
101123
realm: str,
102124
id: UUID,
103125
model_config: ModelConfig,
@@ -116,10 +138,13 @@ async def update_model_config(
116138
HTTPStatus.OK: {"description": "Model config has been deleted"},
117139
HTTPStatus.NOT_FOUND: {"description": "Model config not found"},
118140
HTTPStatus.UNAUTHORIZED: {"description": "Unauthorized"},
141+
HTTPStatus.FORBIDDEN: {"description": "Forbidden - insufficient permissions"},
119142
},
120143
)
144+
@realm_allowed
145+
@roles_allowed(resource=OPENREMOTE_KC_RESOURCE, roles=[ClientRoles.WRITE_ADMIN_ROLE])
121146
async def delete_model_config(
122-
token: Annotated[str, Depends(oauth2_scheme)],
147+
user: Annotated[UserContext, Depends(KeycloakMiddleware.get_user_context)],
123148
realm: str,
124149
id: UUID,
125150
config_service: ModelConfigService = Depends(get_config_service),
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class ClientRoles:
2+
READ_LOGS_ROLE = "read:logs"
3+
READ_USERS_ROLE = "read:users"
4+
READ_ADMIN_ROLE = "read:admin"
5+
READ_MAP_ROLE = "read:map"
6+
READ_ASSETS_ROLE = "read:assets"
7+
READ_RULES_ROLE = "read:rules"
8+
READ_INSIGHTS_ROLE = "read:insights"
9+
READ_ALARMS_ROLE = "read:alarms"
10+
READ_SERVICES_ROLE = "read:services"
11+
WRITE_SERVICES_ROLE = "write:services"
12+
WRITE_USER_ROLE = "write:user"
13+
WRITE_ADMIN_ROLE = "write:admin"
14+
WRITE_LOGS_ROLE = "write:logs"
15+
WRITE_ASSETS_ROLE = "write:assets"
16+
WRITE_ATTRIBUTES_ROLE = "write:attributes"
17+
WRITE_RULES_ROLE = "write:rules"

src/service_ml_forecast/dependencies.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@
2121
The injectors are used to inject the services into the FastAPI app, or other dependencies.
2222
"""
2323

24+
import logging
25+
2426
from fastapi.security import OAuth2PasswordBearer
2527

2628
from service_ml_forecast.clients.openremote.openremote_client import OpenRemoteClient
2729
from service_ml_forecast.config import ENV
2830
from service_ml_forecast.services.model_config_service import ModelConfigService
2931
from service_ml_forecast.services.openremote_service import OpenRemoteService
3032

33+
logger = logging.getLogger(__name__)
34+
3135
__openremote_client = OpenRemoteClient(
3236
openremote_url=ENV.ML_OR_URL,
3337
keycloak_url=ENV.ML_OR_KEYCLOAK_URL,
@@ -54,13 +58,36 @@ def get_openremote_service() -> OpenRemoteService:
5458
return __openremote_service
5559

5660

61+
def get_openremote_issuers() -> list[str] | None:
62+
"""Get valid issuers from OpenRemote realms.
63+
64+
Returns:
65+
List of valid issuer URLs or None if realms cannot be retrieved.
66+
"""
67+
try:
68+
openremote_service = get_openremote_service()
69+
realms = openremote_service.get_realms()
70+
71+
if realms is None:
72+
return None
73+
74+
urls = []
75+
for realm in realms:
76+
urls.append(f"{ENV.ML_OR_URL}/auth/realms/{realm.name}")
77+
return urls
78+
except Exception as e:
79+
logger.error(f"Error getting issuers from OpenRemote: {e}", exc_info=True)
80+
return None
81+
82+
83+
# --- Constants ---
84+
OPENREMOTE_KC_RESOURCE = "openremote"
85+
5786
# --- OAuth2 Scheme ---
5887
# This is used to allow authorization via the Docs and Redoc pages
59-
# Also allows us to extract the token easily from the Authorization header
60-
# Does not validate the token, this is done in the KeycloakMiddleware!
61-
__realm_name = "master"
62-
oauth2_scheme = OAuth2PasswordBearer(
63-
tokenUrl=f"{ENV.ML_OR_KEYCLOAK_URL}/realms/{__realm_name}/protocol/openid-connect/token",
88+
# Does not validate the token, this should be done via a middleware or manually
89+
OAUTH2_SCHEME = OAuth2PasswordBearer(
90+
tokenUrl=f"{ENV.ML_OR_KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
6491
scopes={"openid": "OpenID Connect", "profile": "User profile", "email": "User email"},
6592
description="Login into the OpenRemote Management -- Expected Client ID: 'openremote'",
6693
auto_error=False,

src/service_ml_forecast/main.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,16 @@
2828
from service_ml_forecast.api import model_config_route, web_route
2929
from service_ml_forecast.api.route_exception_handlers import register_exception_handlers
3030
from service_ml_forecast.config import ENV
31-
from service_ml_forecast.dependencies import get_openremote_service
31+
from service_ml_forecast.dependencies import get_openremote_issuers, get_openremote_service
3232
from service_ml_forecast.logging_config import LOGGING_CONFIG
33-
from service_ml_forecast.middlewares.keycloak_middleware import KeycloakMiddleware
33+
from service_ml_forecast.middlewares.keycloak.middleware import KeycloakMiddleware
3434
from service_ml_forecast.services.model_scheduler import ModelScheduler
3535

3636
# Load the logging configuration
3737
logging.config.dictConfig(LOGGING_CONFIG)
3838

3939
logger = logging.getLogger(__name__)
4040

41-
IS_DEV = ENV.is_development()
42-
4341

4442
# FastAPI Lifecycle, handles startup and shutdown tasks
4543
@asynccontextmanager
@@ -62,6 +60,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
6260
lifespan=lifespan,
6361
)
6462

63+
6564
# --- API Docs ---
6665
if not ENV.ML_API_PUBLISH_DOCS:
6766
app.docs_url = None
@@ -70,20 +69,17 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
7069

7170

7271
# --- Middlewares ---
73-
# Last in the chain -- GZIP
7472
app.add_middleware(GZipMiddleware, minimum_size=1000)
7573

76-
# Second to last in the chain -- Keycloak
7774
if ENV.ML_API_MIDDLEWARE_KEYCLOAK:
7875
app.add_middleware(
7976
KeycloakMiddleware,
80-
keycloak_url=ENV.ML_OR_KEYCLOAK_URL,
8177
excluded_routes=["/docs", "/redoc", "/openapi.json", "/ui"],
78+
issuer_provider=get_openremote_issuers,
8279
)
8380
else:
8481
logger.warning("Keycloak middleware disabled! This is NOT recommended in production!")
8582

86-
# First in the chain -- CORS
8783
app.add_middleware(
8884
CORSMiddleware,
8985
allow_origins=ENV.ML_WEBSERVER_ORIGINS,
@@ -111,6 +107,9 @@ def initialize_background_services() -> None:
111107

112108
# Entrypoint for the service
113109
if __name__ == "__main__":
110+
# Check if the application is running in development mode
111+
IS_DEV = ENV.is_development()
112+
114113
if IS_DEV:
115114
logger.warning("Application is running in development mode -- DO NOT USE IN PRODUCTION")
116115

src/service_ml_forecast/middlewares/keycloak/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2025, OpenRemote Inc.
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as
5+
# published by the Free Software Foundation, either version 3 of the
6+
# License, or (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Affero General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
#
16+
# SPDX-License-Identifier: AGPL-3.0-or-later
17+
18+
"""
19+
Keycloak constants.
20+
21+
These constants are used to configure the Keycloak middleware, decorators
22+
hold common error messages.
23+
"""
24+
25+
# Constants for JWT and JWKS configuration
26+
JWKS_CACHE_TTL_SECONDS = 600 # 10 minutes
27+
JWKS_REQUEST_TIMEOUT_SECONDS = 10.0
28+
JWKS_ENDPOINT_PATH = "/protocol/openid-connect/certs"
29+
30+
# JWT token constants
31+
JWT_ALGORITHM_RS256 = "RS256"
32+
JWT_KEY_TYPE_RSA = "RSA"
33+
JWT_KEY_USE_SIGNATURE = "sig"
34+
35+
# Common error messages
36+
ERROR_MISSING_AUTH_HEADER = "Missing or malformed Authorization header"
37+
ERROR_INVALID_TOKEN = "Invalid token"
38+
ERROR_TOKEN_EXPIRED = "Token has expired"
39+
ERROR_INSUFFICIENT_PERMISSIONS = "Insufficient permissions"
40+
ERROR_REALM_REQUIRED = "Realm is required"
41+
ERROR_USER_NOT_AUTHENTICATED = "User not authenticated"
42+
ERROR_INTERNAL_SERVER_ERROR = "Internal server error"
43+
ERROR_UNEXPECTED_ERROR = "An unexpected error occurred"

0 commit comments

Comments
 (0)