Skip to content

Commit 718cd18

Browse files
authored
Expose variables from Settings (#964)
* Expose settings * Update imports * Update correct expose settings * Update with SecretStr * Fix URI expose * Add test and extend sanitize * Update linting * Add documentation and bump version * update doc * Fix tests * update doc * Update after review * Add env variable to expose secrets * Fix testing and linting * fix test * Fix linting * Update comment * Update after review * Add docs * Update after review
1 parent fc4c57e commit 718cd18

File tree

10 files changed

+285
-5
lines changed

10 files changed

+285
-5
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 4.0.4
2+
current_version = 4.1.0rc1
33
commit = False
44
tag = False
55
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(rc(?P<build>\d+))?
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Settings overview in Orchestrator
2+
3+
You can use the `api/settings/overview` endpoint to get an overview of the settings that are used in the application.
4+
This endpoint provides a JSON response that contains the settings that are defined in the application. The settings are
5+
grouped by their names and sensitive values are masked for security reasons.
6+
Per default, the application settings are used to configure the application. The settings are defined in the
7+
`orchestrator.settings.py` module and can be used to configure the application.
8+
9+
An example of the settings is shown below:
10+
11+
```python
12+
from orchestrator.settings import BaseSettings
13+
14+
15+
class AppSettings(BaseSettings):
16+
TESTING: bool = True
17+
SESSION_SECRET: OrchSecretStr = "".join(secrets.choice(string.ascii_letters) for i in range(16)) # type: ignore
18+
CORS_ORIGINS: str = "*"
19+
...
20+
EXPOSE_SETTINGS: bool = False
21+
EXPOSE_OAUTH_SETTINGS: bool = False
22+
23+
24+
app_settings = AppSettings()
25+
26+
if app_settings.EXPOSE_SETTINGS:
27+
expose_settings("app_settings", app_settings) # type: ignore
28+
29+
if app_settings.EXPOSE_OAUTH_SETTINGS:
30+
expose_settings("oauth2lib_settings", oauth2lib_settings) # type: ignore
31+
```
32+
33+
What you see above is the default settings for the application. The settings are defined in the
34+
`orchestrator.settings.py` module and can be used to configure the application.
35+
The `EXPOSE_SETTINGS` and `EXPOSE_OAUTH_SETTINGS` flags are used to control whether the settings should be exposed via
36+
the `api/settings/overview` endpoint, the result looks like this:
37+
38+
```json
39+
[
40+
{
41+
"name": "app_settings",
42+
"variables": [
43+
{
44+
"env_name": "TESTING",
45+
"env_value": false
46+
},
47+
{
48+
"env_name": "SESSION_SECRET",
49+
"env_value": "**********"
50+
},
51+
{
52+
"env_name": "CORS_ORIGINS",
53+
"env_value": "*"
54+
}
55+
]
56+
}
57+
]
58+
```
59+
60+
The `app_settings` in the example above is a name of the settings class that is registered to be exposed.
61+
62+
## Exposing your settings
63+
64+
In order to expose your settings, you need to register them using the `expose_settings()` function. This function takes
65+
two arguments: the name of the settings class and the instance of the settings class.
66+
67+
```python
68+
from orchestrator.settings import expose_settings, BaseSettings
69+
70+
71+
class MySettings(BaseSettings):
72+
debug: bool = True
73+
74+
75+
my_settings = MySettings()
76+
77+
expose_settings("my_settings", my_settings)
78+
```
79+
80+
## Masking Secrets
81+
82+
The following rules apply when exposing settings:
83+
84+
### Rules for Masking Secrets
85+
86+
- Keys containing `"password"` or `"secret"` in their names are masked.
87+
- `SecretStr` from `from pydantic import SecretStr` are masked.
88+
- `SecretStr` from `from orchestrator.utils.expose_settings import SecretStr` are masked.
89+
- `PostgresDsn` from `from pydantic import PostgresDsn` are masked.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ nav:
182182
- App.py: reference-docs/app/app.md
183183
- Python Version: reference-docs/python.md
184184
- Scaling: reference-docs/app/scaling.md
185+
- Settings: reference-docs/app/settings_overview.md
185186
# - Tasks: reference-docs/tasks.md
186187
# - Tests: reference-docs/tests.md
187188
- Workflows:

orchestrator/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
"""This is the orchestrator workflow engine."""
1515

16-
__version__ = "4.0.4"
16+
__version__ = "4.1.0rc1"
1717

1818
from orchestrator.app import OrchestratorCore
1919
from orchestrator.settings import app_settings

orchestrator/api/api_v1/endpoints/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@
2222
from oauth2_lib.fastapi import OIDCUserModel
2323
from orchestrator.api.error_handling import raise_status
2424
from orchestrator.db import EngineSettingsTable
25-
from orchestrator.schemas import EngineSettingsBaseSchema, EngineSettingsSchema, WorkerStatus
25+
from orchestrator.schemas import (
26+
EngineSettingsBaseSchema,
27+
EngineSettingsSchema,
28+
WorkerStatus,
29+
)
2630
from orchestrator.security import authenticate
2731
from orchestrator.services import processes, settings
2832
from orchestrator.services.settings import generate_engine_global_status
33+
from orchestrator.services.settings_env_variables import get_all_exposed_settings
2934
from orchestrator.settings import ExecutorType, app_settings
35+
from orchestrator.utils.expose_settings import SettingsExposedSchema
3036
from orchestrator.utils.json import json_dumps
3137
from orchestrator.utils.redis import delete_keys_matching_pattern
3238
from orchestrator.utils.redis_client import create_redis_asyncio_client
@@ -169,3 +175,8 @@ def generate_engine_status_response(
169175
result = EngineSettingsSchema.model_validate(engine_settings)
170176
result.global_status = generate_engine_global_status(engine_settings)
171177
return result
178+
179+
180+
@router.get("/overview", response_model=list[SettingsExposedSchema])
181+
def get_exposed_settings() -> list[SettingsExposedSchema]:
182+
return get_all_exposed_settings()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2019-2025 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from typing import Any, Dict, Type
15+
16+
from pydantic import SecretStr as PydanticSecretStr
17+
from pydantic_core import MultiHostUrl
18+
from pydantic_settings import BaseSettings
19+
20+
from orchestrator.utils.expose_settings import SecretStr as OrchSecretStr
21+
from orchestrator.utils.expose_settings import SettingsEnvVariablesSchema, SettingsExposedSchema
22+
23+
EXPOSED_ENV_SETTINGS_REGISTRY: Dict[str, Type[BaseSettings]] = {}
24+
MASK = "**********"
25+
26+
27+
def expose_settings(settings_name: str, base_settings: Type[BaseSettings]) -> Type[BaseSettings]:
28+
"""Decorator to register settings classes."""
29+
EXPOSED_ENV_SETTINGS_REGISTRY[settings_name] = base_settings
30+
return base_settings
31+
32+
33+
def mask_value(key: str, value: Any) -> Any:
34+
key_lower = key.lower()
35+
36+
if "secret" in key_lower or "password" in key_lower:
37+
# Mask sensitive information
38+
return MASK
39+
40+
if isinstance(value, PydanticSecretStr):
41+
# Need to convert SecretStr to str for serialization
42+
return str(value)
43+
44+
if isinstance(value, OrchSecretStr):
45+
return MASK
46+
47+
# PostgresDsn is just MultiHostUrl with extra metadata (annotations)
48+
if isinstance(value, MultiHostUrl):
49+
# Convert PostgresDsn to str for serialization
50+
return MASK
51+
52+
return value
53+
54+
55+
def get_all_exposed_settings() -> list[SettingsExposedSchema]:
56+
"""Return all registered settings as dicts."""
57+
58+
def _get_settings_env_variables(base_settings: Type[BaseSettings]) -> list[SettingsEnvVariablesSchema]:
59+
"""Get environment variables from settings."""
60+
return [
61+
SettingsEnvVariablesSchema(env_name=key, env_value=mask_value(key, value))
62+
for key, value in base_settings.model_dump().items() # type: ignore
63+
]
64+
65+
return [
66+
SettingsExposedSchema(name=name, variables=_get_settings_env_variables(base_settings))
67+
for name, base_settings in EXPOSED_ENV_SETTINGS_REGISTRY.items()
68+
]

orchestrator/settings.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from pydantic_settings import BaseSettings
2121

2222
from oauth2_lib.settings import oauth2lib_settings
23+
from orchestrator.services.settings_env_variables import expose_settings
24+
from orchestrator.utils.expose_settings import SecretStr as OrchSecretStr
2325
from pydantic_forms.types import strEnum
2426

2527

@@ -30,7 +32,7 @@ class ExecutorType(strEnum):
3032

3133
class AppSettings(BaseSettings):
3234
TESTING: bool = True
33-
SESSION_SECRET: str = "".join(secrets.choice(string.ascii_letters) for i in range(16)) # noqa: S311
35+
SESSION_SECRET: OrchSecretStr = "".join(secrets.choice(string.ascii_letters) for i in range(16)) # type: ignore
3436
CORS_ORIGINS: str = "*"
3537
CORS_ALLOW_METHODS: list[str] = ["GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "HEAD"]
3638
CORS_ALLOW_HEADERS: list[str] = ["If-None-Match", "Authorization", "If-Match", "Content-Type"]
@@ -55,7 +57,7 @@ class AppSettings(BaseSettings):
5557
MAIL_PORT: int = 25
5658
MAIL_STARTTLS: bool = False
5759
CACHE_URI: RedisDsn = "redis://localhost:6379/0" # type: ignore
58-
CACHE_HMAC_SECRET: str | None = None # HMAC signing key, used when pickling results in the cache
60+
CACHE_HMAC_SECRET: OrchSecretStr | None = None # HMAC signing key, used when pickling results in the cache
5961
REDIS_RETRY_COUNT: NonNegativeInt = Field(
6062
2, description="Number of retries for redis connection errors/timeouts, 0 to disable"
6163
) # More info: https://redis-py.readthedocs.io/en/stable/retry.html
@@ -87,10 +89,17 @@ class AppSettings(BaseSettings):
8789
ENABLE_PROMETHEUS_METRICS_ENDPOINT: bool = False
8890
VALIDATE_OUT_OF_SYNC_SUBSCRIPTIONS: bool = False
8991
FILTER_BY_MODE: Literal["partial", "exact"] = "exact"
92+
EXPOSE_SETTINGS: bool = False
93+
EXPOSE_OAUTH_SETTINGS: bool = False
9094

9195

9296
app_settings = AppSettings()
9397

9498
# Set oauth2lib_settings variables to the same (default) value of settings
9599
oauth2lib_settings.SERVICE_NAME = app_settings.SERVICE_NAME
96100
oauth2lib_settings.ENVIRONMENT = app_settings.ENVIRONMENT
101+
102+
if app_settings.EXPOSE_SETTINGS:
103+
expose_settings("app_settings", app_settings) # type: ignore
104+
if app_settings.EXPOSE_OAUTH_SETTINGS:
105+
expose_settings("oauth2lib_settings", oauth2lib_settings) # type: ignore

orchestrator/utils/expose_settings.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2019-2025 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
"""Utility module for exposing settings in a structured format.
14+
15+
Unfortunately, this module needs to be imported from the utils and cannot be added to the schemas folder.
16+
This is due to circular import issues with the combination of schemas/settings.
17+
"""
18+
19+
from typing import Any
20+
21+
from pydantic import BaseModel
22+
from pydantic_core import core_schema
23+
24+
25+
class SecretStr(str):
26+
"""A string that is treated as a secret, for example, passwords or API keys.
27+
28+
This class is used to indicate that the string should not be logged or displayed in plaintext.
29+
"""
30+
31+
@classmethod
32+
def __get_pydantic_core_schema__(cls, source_type, handler): # type: ignore
33+
# This method is used to define how the SecretStr type should be handled by Pydantic.
34+
# With this implementation, it will fail validation.
35+
return core_schema.no_info_plain_validator_function(cls)
36+
37+
38+
class SettingsEnvVariablesSchema(BaseModel):
39+
env_name: str
40+
env_value: Any
41+
42+
43+
class SettingsExposedSchema(BaseModel):
44+
name: str
45+
variables: list[SettingsEnvVariablesSchema]

test/unit_tests/api/test_settings.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
from unittest import mock
33
from unittest.mock import Mock
44

5+
from pydantic import SecretStr
6+
from pydantic_settings import BaseSettings
57
from sqlalchemy.exc import SQLAlchemyError
68

79
from orchestrator.db import db
810
from orchestrator.services.settings import get_engine_settings
11+
from orchestrator.services.settings_env_variables import expose_settings, get_all_exposed_settings
912

1013

1114
def test_get_engine_status(test_client):
@@ -74,3 +77,22 @@ def test_reset_search_index_error(test_client, generic_subscription_1, generic_s
7477
ex.attach_mock(session_execute_mock, "execute")
7578
response = test_client.post("/api/settings/search-index/reset")
7679
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
80+
81+
82+
def test_get_exposed_settings(test_client):
83+
class MySettings(BaseSettings):
84+
db_password: SecretStr = "test_password" # noqa: S105
85+
86+
my_settings = MySettings()
87+
expose_settings("my_settings", my_settings)
88+
assert len(get_all_exposed_settings()) == 1
89+
90+
response = test_client.get("/api/settings/overview")
91+
assert response.status_code == HTTPStatus.OK
92+
93+
exposed_settings = response.json()
94+
95+
# Find the env_name db_password and ensure it is masked is **********
96+
session_secret = next((var for var in exposed_settings[0]["variables"] if var["env_name"] == "db_password"), None)
97+
assert session_secret is not None
98+
assert session_secret["env_value"] == "**********"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pydantic import PostgresDsn
2+
from pydantic import SecretStr as PydanticSecretStr
3+
from pydantic_settings import BaseSettings
4+
5+
from orchestrator.services.settings_env_variables import MASK, expose_settings, get_all_exposed_settings
6+
from orchestrator.utils.expose_settings import SecretStr as OrchSecretStr
7+
8+
9+
def test_expose_settings():
10+
11+
class MySettings(BaseSettings):
12+
api_key: OrchSecretStr = "test_api_key"
13+
db_password: PydanticSecretStr = "test_password" # noqa: S105
14+
debug_mode: bool = True
15+
secret_test: str = "test_secret" # noqa: S105
16+
uri: PostgresDsn = "postgresql://user:password@localhost/dbname"
17+
18+
my_settings = MySettings()
19+
expose_settings("my_settings", my_settings)
20+
21+
exposed_settings = get_all_exposed_settings()
22+
23+
assert len(exposed_settings) == 1
24+
my_settings_index = 0
25+
26+
assert exposed_settings[my_settings_index].name == "my_settings"
27+
28+
assert len(exposed_settings[my_settings_index].variables) == 5
29+
30+
# Assert that sensitive values are masked
31+
assert exposed_settings[my_settings_index].variables[0].env_value == MASK # api_key
32+
assert exposed_settings[my_settings_index].variables[1].env_value == MASK # db_password
33+
assert exposed_settings[my_settings_index].variables[2].env_value is True # debug_mode
34+
assert exposed_settings[my_settings_index].variables[3].env_value == MASK # secret_test
35+
assert exposed_settings[my_settings_index].variables[4].env_value == MASK # uri

0 commit comments

Comments
 (0)