Skip to content

Commit 770cf5e

Browse files
Add lifespan OpenSearch bootstrap via service JWT
Introduce a one-shot OpenSearch security bootstrap during FastAPI lifespan that derives the admin username from a platform-issued service JWT. Adds PLATFORM_SERVICE_JWT and OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP settings, validates the presence of the token, decodes the admin username (new admin_username_from_service_jwt helper), waits for OpenSearch, and runs setup_opensearch_security before other startup tasks. Also update startup_tasks to skip OpenSearch setup when the lifespan bootstrap flag is enabled, and add logging and error handling for missing/invalid tokens.
1 parent a4b2da8 commit 770cf5e

4 files changed

Lines changed: 69 additions & 1 deletion

File tree

src/app/lifespan.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from fastapi import FastAPI
1313

1414
from config.settings import (
15+
OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP,
16+
PLATFORM_SERVICE_JWT,
1517
RBAC_CACHE_BACKEND,
1618
RBAC_PERMISSION_CACHE_TTL_SECONDS,
1719
UVICORN_WORKER_COUNT,
@@ -191,6 +193,32 @@ async def run_startup(app: FastAPI):
191193
await mcp_lifespan_ctx.__aenter__()
192194
logger.info("FastMCP lifespan started")
193195

196+
# One-shot OpenSearch security bootstrap driven by the platform's
197+
# service JWT. Runs synchronously (before startup_tasks) so the
198+
# admin role mapping is in place before any other startup work
199+
# talks to OpenSearch. The corresponding call inside startup_tasks
200+
# is suppressed when this flag is on.
201+
if OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP:
202+
if not PLATFORM_SERVICE_JWT:
203+
raise RuntimeError(
204+
"OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP is enabled but "
205+
"PLATFORM_SERVICE_JWT is not set"
206+
)
207+
from auth.ibm_auth import admin_username_from_service_jwt
208+
from utils.opensearch_init import wait_for_opensearch
209+
from utils.opensearch_utils import setup_opensearch_security
210+
211+
admin_username = admin_username_from_service_jwt(PLATFORM_SERVICE_JWT)
212+
if not admin_username:
213+
raise RuntimeError(
214+
"PLATFORM_SERVICE_JWT has no 'username' or 'sub' claim; "
215+
"cannot bootstrap OpenSearch security"
216+
)
217+
await wait_for_opensearch()
218+
logger.info("Bootstrapping OpenSearch security", admin_username=admin_username)
219+
await setup_opensearch_security(clients.opensearch, admin_username=admin_username)
220+
logger.info("OpenSearch security bootstrap completed", admin_username=admin_username)
221+
194222
# Start index initialization in background to avoid blocking OIDC endpoints
195223
t1 = asyncio.create_task(startup_tasks(services))
196224
app.state.background_tasks.add(t1)

src/auth/ibm_auth.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ def decode_ibm_jwt(token: str) -> dict | None:
3131
return None
3232

3333

34+
def admin_username_from_service_jwt(token: str) -> str | None:
35+
"""Return the admin username carried by a platform-issued service JWT.
36+
37+
Decodes *token* unsigned (the platform issues it; we only parse claims)
38+
and returns `username` if present, falling back to `sub`. Matches the
39+
claim precedence used by the auth dependency in dependencies.py.
40+
Returns None if the token cannot be decoded or has neither claim.
41+
"""
42+
try:
43+
claims = jwt.decode(token, options={"verify_signature": False})
44+
except jwt.InvalidTokenError as exc:
45+
logger.warning("Service JWT decode failed", error=str(exc))
46+
return None
47+
return claims.get("username") or claims.get("sub")
48+
49+
3450
async def fetch_ibm_public_key(url: str):
3551
"""Fetch IBM's JWT public key PEM from *url* and cache it."""
3652
global _cached_public_key

src/config/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@
6767
IBM_AUTH_ENABLED = os.getenv("IBM_AUTH_ENABLED", "false").lower() in ("true", "1", "yes")
6868
PLATFORM_USERNAME = os.getenv("PLATFORM_USERNAME")
6969
PLATFORM_PASSWORD = os.getenv("PLATFORM_PASSWORD")
70+
# Platform-issued service JWT. When present and
71+
# OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP is on, lifespan decodes this
72+
# token to derive the admin username used to bootstrap the OpenSearch
73+
# security context (roles + all_access mapping).
74+
PLATFORM_SERVICE_JWT = os.getenv("PLATFORM_SERVICE_JWT")
7075
IBM_JWT_PUBLIC_KEY_URL = os.getenv("IBM_JWT_PUBLIC_KEY_URL", "")
7176
IBM_SESSION_COOKIE_NAME = os.getenv("IBM_SESSION_COOKIE_NAME", "ibm-openrag-session")
7277
IBM_CREDENTIALS_HEADER = os.getenv("IBM_CREDENTIALS_HEADER", "X-IBM-LH-Credentials")
@@ -106,6 +111,18 @@ def _resolve_skip_os_security_default() -> str:
106111
"OPENRAG_SKIP_OS_SECURITY_SETUP", _resolve_skip_os_security_default()
107112
).lower() in ("true", "1", "yes")
108113

114+
# Run setup_opensearch_security once during FastAPI lifespan startup,
115+
# using the admin username derived from PLATFORM_SERVICE_JWT. Intended
116+
# for platform-managed deployments (saas / on_prem) where the platform
117+
# issues a service token that identifies the admin user that must be
118+
# pinned into the all_access role mapping. Default off.
119+
#
120+
# When this flag is true the corresponding call inside startup_tasks()
121+
# is suppressed — bootstrap is the single source of truth on startup.
122+
OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP = os.getenv(
123+
"OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP", "false"
124+
).lower() in ("true", "1", "yes")
125+
109126
# Enable FastAPI's `debug` mode (verbose tracebacks in HTTP error responses
110127
# on the FastAPI app instance). Named explicitly so it isn't confused with
111128
# logging-level "debug" or other unrelated debug flags.

src/services/startup_orchestrator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from config.settings import (
1010
DISABLE_INGEST_WITH_LANGFLOW,
1111
FETCH_OPENRAG_DOCS_AT_STARTUP,
12+
OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP,
1213
OPENRAG_SKIP_OS_SECURITY_SETUP,
1314
clients,
1415
get_openrag_config,
@@ -69,12 +70,18 @@ async def startup_tasks(services):
6970
# Setup OpenSearch security (roles and mappings) after connection is established.
7071
# Skip entirely when the platform manages the security context externally
7172
# (SaaS / CPD): the call would otherwise either fail with 403/401 or
72-
# overwrite a curated config.
73+
# overwrite a curated config. Also skip when the lifespan-level
74+
# bootstrap (driven by PLATFORM_SERVICE_JWT) has already handled it.
7375
if OPENRAG_SKIP_OS_SECURITY_SETUP:
7476
logger.info(
7577
"Skipping OpenSearch security setup at startup "
7678
"(OPENRAG_SKIP_OS_SECURITY_SETUP=true)"
7779
)
80+
elif OPENRAG_BOOTSTRAP_OS_SECURITY_ON_STARTUP:
81+
logger.info(
82+
"Skipping OpenSearch security setup in startup_tasks "
83+
"(handled by lifespan bootstrap)"
84+
)
7885
else:
7986
try:
8087
from utils.opensearch_utils import setup_opensearch_security

0 commit comments

Comments
 (0)