Skip to content

Commit 59666ef

Browse files
Prefer header JWT when RBAC role-sync enabled
When RBAC/JWT-role sync is enabled, read the end-user JWT from the gateway-forwarded header named by get_jwt_auth_header() instead of the ibm-openrag-session cookie; preserve the existing cookie flow when RBAC is off. Strip a leading "Bearer " prefix if present and fall back to the cookie when role-sync is disabled. Added jwt_roles_enabled and get_jwt_auth_header imports and adjusted token selection logic in src/dependencies.py. Added unit tests (tests/unit/dependencies/test_ibm_header_auth.py) that cover RBAC-on header usage, RBAC-off cookie usage, and missing-header authentication failure.
1 parent cc2b075 commit 59666ef

2 files changed

Lines changed: 114 additions & 1 deletion

File tree

src/dependencies.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,9 @@ async def _get_ibm_user(request: Request, required: bool) -> Optional["User"]:
432432
for OpenSearch. Decoded and persisted to connections.json per user.
433433
1. ibm-openrag-session cookie — set by Traefik after validating credentials
434434
with AMS. JWT is decoded without re-validation (Traefik already validated).
435+
When RBAC/JWT-role sync is enabled, this JWT is instead read from the
436+
gateway-forwarded header named by ``get_jwt_auth_header()`` (the cookie is
437+
used only when RBAC is off); identity and roles both come from that token.
435438
2. ibm-auth-basic cookie — local dev fallback set by our ibm_login endpoint
436439
when Traefik is not present.
437440
@@ -440,11 +443,13 @@ async def _get_ibm_user(request: Request, required: bool) -> Optional["User"]:
440443
"""
441444
import auth.ibm_auth as ibm_auth
442445
from auth.ibm_auth import extract_ibm_credentials
446+
from auth.jwt_roles import jwt_roles_enabled
443447
from config.settings import (
444448
IBM_CREDENTIALS_HEADER,
445449
IBM_SESSION_COOKIE_NAME,
446450
PLATFORM_PASSWORD,
447451
PLATFORM_USERNAME,
452+
get_jwt_auth_header,
448453
)
449454

450455
# ── Option -1: Environment variable override (local dev/calls) ───────
@@ -471,7 +476,16 @@ async def _get_ibm_user(request: Request, required: bool) -> Optional["User"]:
471476
# ── Option 0: Configurable credentials header (Traefik production) ───
472477

473478
lh_credentials = request.headers.get(IBM_CREDENTIALS_HEADER, "")
474-
ibm_token = request.cookies.get(IBM_SESSION_COOKIE_NAME)
479+
# When RBAC/JWT-role sync is on, the gateway forwards the end-user JWT in the
480+
# configured header; use it as the source of identity and roles. When RBAC is
481+
# off, preserve the existing ibm-openrag-session cookie flow.
482+
if jwt_roles_enabled():
483+
raw_jwt = request.headers.get(get_jwt_auth_header(), "")
484+
ibm_token = (
485+
raw_jwt[7:].strip() if raw_jwt.startswith("Bearer ") else raw_jwt.strip()
486+
) or None
487+
else:
488+
ibm_token = request.cookies.get(IBM_SESSION_COOKIE_NAME)
475489
user_id = None
476490
email = None
477491
name = None
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""IBM AMS auth: JWT source switches from cookie to header under RBAC.
2+
3+
Covers the RBAC-gated token source in ``_get_ibm_user`` (``src/dependencies.py``):
4+
when JWT-role sync is enabled the end-user JWT is read from the gateway-forwarded
5+
header named by ``get_jwt_auth_header()``; when RBAC is off the existing
6+
``ibm-openrag-session`` cookie flow is preserved. ``decode_ibm_jwt`` is
7+
monkeypatched so no real token is needed.
8+
"""
9+
10+
import sys
11+
from pathlib import Path
12+
from types import SimpleNamespace
13+
14+
import pytest
15+
from fastapi import HTTPException
16+
17+
ROOT = Path(__file__).resolve().parent.parent.parent.parent
18+
SRC = ROOT / "src"
19+
if str(SRC) not in sys.path:
20+
sys.path.insert(0, str(SRC))
21+
22+
import auth.ibm_auth as ibm_auth # noqa: E402
23+
import config.settings as app_settings # noqa: E402
24+
from dependencies import _get_ibm_user # noqa: E402
25+
26+
COOKIE_NAME = "ibm-openrag-session"
27+
28+
29+
class _FakeRequest:
30+
"""Minimal stand-in for starlette Request used by ``_get_ibm_user``."""
31+
32+
def __init__(self, headers: dict | None = None, cookies: dict | None = None):
33+
self.headers = headers or {}
34+
self.cookies = cookies or {}
35+
self.state = SimpleNamespace()
36+
# Empty services -> the connector lookup in Option 1 no-ops.
37+
self.app = SimpleNamespace(state=SimpleNamespace(services={}))
38+
39+
40+
@pytest.fixture(autouse=True)
41+
def _env(monkeypatch):
42+
monkeypatch.setenv("OPENRAG_JWT_ROLES_CLAIM", "openrag_roles")
43+
monkeypatch.setenv("OPENRAG_ROLE_CLAIM_ADMIN", "admin")
44+
monkeypatch.setenv("OPENRAG_ROLE_CLAIM_DEVELOPER", "manager")
45+
monkeypatch.setenv("OPENRAG_ROLE_CLAIM_USER", "user")
46+
monkeypatch.delenv("OPENRAG_ROLE_CLAIM_VIEWER", raising=False)
47+
monkeypatch.setenv("OPENRAG_JWT_AUTH_HEADER", "X-OpenRAG-JWT")
48+
# Keep the Option -1 env override from short-circuiting the function.
49+
monkeypatch.setattr(app_settings, "PLATFORM_USERNAME", None)
50+
monkeypatch.setattr(app_settings, "PLATFORM_PASSWORD", None)
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_rbac_on_reads_jwt_from_header(monkeypatch):
55+
monkeypatch.setenv("OPENRAG_RBAC_ENFORCE", "true")
56+
claims = {"sub": "s1", "username": "alice", "display_name": "Alice", "openrag_roles": ["admin"]}
57+
monkeypatch.setattr(ibm_auth, "decode_ibm_jwt", lambda tok: claims if tok == "tok" else None)
58+
# Cookie present too — it must be ignored when RBAC is on.
59+
req = _FakeRequest(headers={"X-OpenRAG-JWT": "Bearer tok"}, cookies={COOKIE_NAME: "COOKIE"})
60+
61+
user = await _get_ibm_user(req, required=True)
62+
63+
assert user is not None
64+
assert user.user_id == "alice"
65+
assert user.name == "Alice"
66+
assert user.jwt_token == "Bearer tok" # built from the header token, not the cookie
67+
assert req.state.jwt_roles == ["admin"]
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_rbac_off_reads_jwt_from_cookie(monkeypatch):
72+
monkeypatch.setenv("OPENRAG_RBAC_ENFORCE", "false")
73+
claims = {"sub": "s2", "username": "bob"}
74+
monkeypatch.setattr(ibm_auth, "decode_ibm_jwt", lambda tok: claims if tok == "ctok" else None)
75+
# Header present too — it must be ignored when RBAC is off.
76+
req = _FakeRequest(headers={"X-OpenRAG-JWT": "Bearer HEADERTOK"}, cookies={COOKIE_NAME: "ctok"})
77+
78+
user = await _get_ibm_user(req, required=True)
79+
80+
assert user is not None
81+
assert user.user_id == "bob"
82+
assert user.jwt_token == "Bearer ctok"
83+
assert req.state.jwt_roles is None # legacy default-role path under RBAC off
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_rbac_on_missing_header_401(monkeypatch):
88+
monkeypatch.setenv("OPENRAG_RBAC_ENFORCE", "true")
89+
90+
def _boom(tok): # decode must not run when no header token is present
91+
raise AssertionError("decode_ibm_jwt should not be called without a header token")
92+
93+
monkeypatch.setattr(ibm_auth, "decode_ibm_jwt", _boom)
94+
# Cookie present but ignored under RBAC -> no token -> unauthenticated.
95+
req = _FakeRequest(headers={}, cookies={COOKIE_NAME: "ctok"})
96+
97+
with pytest.raises(HTTPException) as exc:
98+
await _get_ibm_user(req, required=True)
99+
assert exc.value.status_code == 401

0 commit comments

Comments
 (0)