From 57dd573e0a90a71b464fa49bbb68eafa0d03f700 Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 12:43:09 +0100 Subject: [PATCH 1/8] chore(docker): add oauth2-proxy for DEV purpose (refs #9) --- docker-compose.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index a4f7525..00ec38b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,32 @@ services: - pgdata:/var/lib/postgresql/data restart: unless-stopped + # allows to test oauth2-proxy on http://localhost:8001 + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:latest + command: ["/bin/oauth2-proxy", "--errors-to-info-log"] + deploy: + mode: replicated + replicas: ${AUTH2_PROXY_ENABLED:-0} + environment: + - OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180 + - OAUTH2_PROXY_REVERSE_PROXY=true + - OAUTH2_PROXY_UPSTREAMS=http://site:8000/ + # dummy secret for DEV + # see https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/#generating-a-cookie-secret + - OAUTH2_PROXY_COOKIE_SECRET=Sfl1tWxx7llsv0UrLzquT696f4Vytblg2reUe5DhH6k= + - OAUTH2_PROXY_REDIRECT_URL=http://localhost:8001/oauth2/callback + # + - OAUTH2_PROXY_PROVIDER=keycloak-oidc + - OAUTH2_PROXY_OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-https://sso.geopf.fr/realms/IGN-MUT} + - OAUTH2_PROXY_CLIENT_ID=${OIDC_CLIENT_ID:-geocontext-dev} + - OAUTH2_PROXY_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-NotCommitableSecret} + - OAUTH2_PROXY_ALLOWED_ROLE=* + - OAUTH2_PROXY_EMAIL_DOMAINS=* + ports: + - 8001:4180 + restart: unless-stopped + volumes: pgdata: name: geocontext-pgdata From 4576e4e6079f1049b72fab36659e75d61ccbb3f2 Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 13:56:00 +0100 Subject: [PATCH 2/8] chore(oauth2-proxy): retrieve forwarded headers (refs #9) --- demo_gradio.py | 11 ++++++++++- docker-compose.yaml | 8 +++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/demo_gradio.py b/demo_gradio.py index f882bfe..902f81f 100644 --- a/demo_gradio.py +++ b/demo_gradio.py @@ -6,7 +6,7 @@ logger = logging.getLogger("demo_gradio") import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI,Request from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles @@ -42,6 +42,15 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) +@app.get('/me') +async def me(req: Request): + # X-Forwarded-User, X-Forwarded-Email, X-Forwarded-Preferred-Username and X-Forwarded-Groups + id = req.headers.get('X-Forwarded-User','anonymous') + username = req.headers.get('X-Forwarded-Preferred-Username','anonymous') + email = req.headers.get('X-Forwarded-Email','anonymous@gpf.fr') + groups = req.headers.get('X-Forwarded-Groups','').split(',') + return {"id": id, "username": username, "email": email, "groups": groups} + @app.get('/health') async def health(): return {"status": "ok", "message": "app is running"} diff --git a/docker-compose.yaml b/docker-compose.yaml index 00ec38b..4352b1c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -40,7 +40,7 @@ services: command: ["/bin/oauth2-proxy", "--errors-to-info-log"] deploy: mode: replicated - replicas: ${AUTH2_PROXY_ENABLED:-0} + replicas: ${OAUTH2_PROXY_ENABLED:-0} environment: - OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180 - OAUTH2_PROXY_REVERSE_PROXY=true @@ -49,12 +49,14 @@ services: # see https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/#generating-a-cookie-secret - OAUTH2_PROXY_COOKIE_SECRET=Sfl1tWxx7llsv0UrLzquT696f4Vytblg2reUe5DhH6k= - OAUTH2_PROXY_REDIRECT_URL=http://localhost:8001/oauth2/callback - # + # OIDC provider configuration - OAUTH2_PROXY_PROVIDER=keycloak-oidc - OAUTH2_PROXY_OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-https://sso.geopf.fr/realms/IGN-MUT} - OAUTH2_PROXY_CLIENT_ID=${OIDC_CLIENT_ID:-geocontext-dev} - OAUTH2_PROXY_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-NotCommitableSecret} - - OAUTH2_PROXY_ALLOWED_ROLE=* + - OAUTH2_PROXY_OIDC_EMAIL_CLAIM=email + #- OAUTH2_PROXY_GROUPS_CLAIM=groups + # basic filtering - OAUTH2_PROXY_EMAIL_DOMAINS=* ports: - 8001:4180 From 16c21d48395b1d768cf1c77bc38e616a8349601e Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 14:30:42 +0100 Subject: [PATCH 3/8] chore(oauth2-proxy): cleanup and forward user for gradio... (refs #9) --- demo_gradio.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/demo_gradio.py b/demo_gradio.py index 902f81f..781652b 100644 --- a/demo_gradio.py +++ b/demo_gradio.py @@ -6,9 +6,10 @@ logger = logging.getLogger("demo_gradio") import uvicorn -from fastapi import FastAPI,Request +from fastapi import FastAPI,Request,Depends from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles +from auth import get_current_user, User from db import create_database @@ -43,13 +44,10 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) @app.get('/me') -async def me(req: Request): - # X-Forwarded-User, X-Forwarded-Email, X-Forwarded-Preferred-Username and X-Forwarded-Groups - id = req.headers.get('X-Forwarded-User','anonymous') - username = req.headers.get('X-Forwarded-Preferred-Username','anonymous') - email = req.headers.get('X-Forwarded-Email','anonymous@gpf.fr') - groups = req.headers.get('X-Forwarded-Groups','').split(',') - return {"id": id, "username": username, "email": email, "groups": groups} +async def me( + user: User = Depends(get_current_user) +) -> User : + return user @app.get('/health') async def health(): @@ -211,10 +209,10 @@ async def load_conversation_history(thread_id: str): # Button for new discussion new_discussion_btn = gr.Button("🆕 Nouvelle discussion", variant="secondary") - async def initialize_chat(thread_id: str|None): + async def initialize_chat(request: gr.Request, thread_id: str|None): """Initialise le chat avec l'historique existant si disponible""" - logger.info(f"initialize_chat(thread_id={thread_id})") + logger.info(f"initialize_chat(thread_id={thread_id}, username={request.username})") history = [] # if thread_id is empty and not provided, create a new thread @@ -335,7 +333,14 @@ async def initialize_chat(request: gr.Request): def redirect_to_gradio(): return RedirectResponse(url=f"/chatbot") -app = gr.mount_gradio_app(app, demo, path="/chatbot") +def get_gradio_user(request: Request): + """Retrieve user for Gradio (available as request.username)""" + + user = get_current_user(request) + return user.email + + +app = gr.mount_gradio_app(app, demo, path="/chatbot", auth_dependency=get_gradio_user) app = gr.mount_gradio_app(app, demo_share, path="/discussion") From 45b1c74314ed25d488db8bc3bb501560866a85bb Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 14:30:54 +0100 Subject: [PATCH 4/8] chore(oauth2-proxy): cleanup and forward user for gradio... (refs #9) --- auth.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 auth.py diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..2621570 --- /dev/null +++ b/auth.py @@ -0,0 +1,22 @@ +from fastapi import Request +from pydantic import BaseModel +from typing import List + +class User(BaseModel): + id: str + username: str + email: str + groups: List[str] + +def get_current_user( + request: Request +) -> User : + """Retrieve current user from forwarded headers in request""" + # X-Forwarded-User, X-Forwarded-Email, X-Forwarded-Preferred-Username and X-Forwarded-Groups + id = request.headers.get('X-Forwarded-User','anonymous') + username = request.headers.get('X-Forwarded-Preferred-Username','anonymous') + email = request.headers.get('X-Forwarded-Email','anonymous@gpf.fr') + + groups_str=request.headers.get('X-Forwarded-Groups',None) + groups = [] if groups_str is None else groups_str.split(',') + return User(id=id, username=username, email=email, groups=groups) From dab7f2ed0ad556e1827856cfe4625f7e8428cbb4 Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 16:10:58 +0100 Subject: [PATCH 5/8] chore(oauth2): logs messages (refs #9) --- demo_gradio.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/demo_gradio.py b/demo_gradio.py index 781652b..fb0896d 100644 --- a/demo_gradio.py +++ b/demo_gradio.py @@ -204,6 +204,8 @@ async def load_conversation_history(thread_id: str): ) # State for thread_id (localStorage) thread_state = gr.BrowserState(None) + # State for username + username_state = gr.State(None) # Component for sharing link share_output = gr.Markdown(value="", visible=True) # Button for new discussion @@ -212,7 +214,9 @@ async def load_conversation_history(thread_id: str): async def initialize_chat(request: gr.Request, thread_id: str|None): """Initialise le chat avec l'historique existant si disponible""" - logger.info(f"initialize_chat(thread_id={thread_id}, username={request.username})") + username = request.username + + logger.info(f"initialize_chat(thread_id={thread_id}, username={username})") history = [] # if thread_id is empty and not provided, create a new thread @@ -220,29 +224,31 @@ async def initialize_chat(request: gr.Request, thread_id: str|None): thread_id = f"thread-{uuid.uuid4().hex}" logger.info(f"thread_id not provided, create a new thread : {thread_id}") share_link = create_share_link(thread_id) - return history, thread_id, share_link + return history, username, thread_id, share_link logger.info(f"thread_id provided : {thread_id}, load history") share_link = create_share_link(thread_id) try: history = await load_conversation_history(thread_id) - logger.info(f"Historique chargé pour thread_id={thread_id}: {len(history)} messages") - return history, thread_id, share_link + logger.info(f"history loaded for thread_id={thread_id}: {len(history)} messages") + return history, username, thread_id, share_link except Exception as e: - logger.error(f"Erreur lors du chargement de l'historique pour {thread_id}: {e}") - return [], thread_id, share_link + logger.error(f"error loading history for thread_id={thread_id}: {e}") + return [], username, thread_id, share_link demo.load(initialize_chat, inputs=[thread_state], outputs=[ - chatbot, thread_state, share_output + chatbot, username_state, thread_state, share_output ]) - def user(user_message: str, thread_id: str, history: list): + def user(user_message: str, thread_id: str, username: str, history: list): if user_message is None or user_message.strip() == "": return "", thread_id, history - return "", thread_id, history + [{"role": "user", "content": user_message.strip()}] + message_content = user_message.strip() + logging.info(f"user({thread_id}, {username}): {message_content}") + return "", thread_id, username, history + [{"role": "user", "content": message_content}] async def bot(history: list, thread_id: str): global graph @@ -251,7 +257,7 @@ async def bot(history: list, thread_id: str): # required to invoke the graph with short term memory config = {"configurable": {"thread_id": thread_id}} - logging.info(f"bot({thread_id} - {user_message})") + logging.debug(f"bot({thread_id} - {user_message})") async for event in graph.astream({"messages": [{"role": "user", "content": user_message}]}, config=config): logger.debug("Event:", event) @@ -270,7 +276,7 @@ async def bot(history: list, thread_id: str): history[-1]["metadata"] = None yield history, thread_id - msg.submit(user, [msg, thread_state, chatbot], [msg, thread_state, chatbot], queue=False).then( + msg.submit(user, [msg, thread_state, username_state, chatbot], [msg, thread_state, username_state, chatbot], queue=False).then( bot, inputs=[chatbot,thread_state], outputs=[chatbot,thread_state] ) @@ -282,11 +288,12 @@ def create_share_link(thread_id: str): return f"**Lien de partage :** [/discussion?thread_id={thread_id}](/discussion?thread_id={thread_id})" return "" - @gr.on(new_discussion_btn.click, outputs=[chatbot, thread_state, share_output]) - def reset_thread_id(): + @gr.on(new_discussion_btn.click, inputs=[username_state], outputs=[chatbot, thread_state, share_output]) + def reset_thread_id(username: str): """Réinitialise le thread_id et démarre une nouvelle discussion""" + new_thread_id = f"thread-{uuid.uuid4().hex}" - logger.info(f"Nouvelle discussion créée avec thread_id: {new_thread_id}") + logger.info(f"reset_thread_id(username={username}, new_thread_id={new_thread_id})") share_link = create_share_link(new_thread_id) return [], new_thread_id, share_link From 8c97d2052442be84c5e7aadd872918e486e72d9b Mon Sep 17 00:00:00 2001 From: MBorne Date: Fri, 7 Nov 2025 16:31:01 +0100 Subject: [PATCH 6/8] chore(oauth2): cleanup gradio inputs/outputs and logs (refs #9) --- demo_gradio.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/demo_gradio.py b/demo_gradio.py index fb0896d..5bd74b7 100644 --- a/demo_gradio.py +++ b/demo_gradio.py @@ -216,25 +216,23 @@ async def initialize_chat(request: gr.Request, thread_id: str|None): username = request.username - logger.info(f"initialize_chat(thread_id={thread_id}, username={username})") history = [] # if thread_id is empty and not provided, create a new thread if thread_id is None or thread_id.strip() == "": thread_id = f"thread-{uuid.uuid4().hex}" - logger.info(f"thread_id not provided, create a new thread : {thread_id}") + logger.info(f"initialize_chat(thread_id={thread_id}, username={username}) : new thread created") share_link = create_share_link(thread_id) return history, username, thread_id, share_link - logger.info(f"thread_id provided : {thread_id}, load history") + logger.info(f"initialize_chat(thread_id={thread_id}, username={username}) : thread_id provided, loading history...") share_link = create_share_link(thread_id) - try: history = await load_conversation_history(thread_id) - logger.info(f"history loaded for thread_id={thread_id}: {len(history)} messages") + logger.info(f"initialize_chat(thread_id={thread_id}, username={username}) : history loaded with {len(history)} message(s)") return history, username, thread_id, share_link except Exception as e: - logger.error(f"error loading history for thread_id={thread_id}: {e}") + logger.error(f"initialize_chat(thread_id={thread_id}, username={username}) : error loading history for thread_id : {e}") return [], username, thread_id, share_link @@ -243,16 +241,21 @@ async def initialize_chat(request: gr.Request, thread_id: str|None): ]) def user(user_message: str, thread_id: str, username: str, history: list): + """handle user message and append it to history""" + if user_message is None or user_message.strip() == "": - return "", thread_id, history + return "", history message_content = user_message.strip() logging.info(f"user({thread_id}, {username}): {message_content}") - return "", thread_id, username, history + [{"role": "user", "content": message_content}] + return "", history + [{"role": "user", "content": message_content}] async def bot(history: list, thread_id: str): + """answer the last user message in history by invoking the agent""" + global graph + # retrieve the last user message user_message = history[-1]['content'] # required to invoke the graph with short term memory @@ -270,27 +273,26 @@ async def bot(history: list, thread_id: str): gradio_message = to_gradio_message(last_message) if gradio_message is not None: history.append(gradio_message) - yield history, thread_id + yield history # Remove metadata for the final message history[-1]["metadata"] = None - yield history, thread_id + yield history - msg.submit(user, [msg, thread_state, username_state, chatbot], [msg, thread_state, username_state, chatbot], queue=False).then( - bot, inputs=[chatbot,thread_state], outputs=[chatbot,thread_state] + msg.submit(user, [msg, thread_state, username_state, chatbot], [msg, chatbot], queue=False).then( + bot, inputs=[chatbot,thread_state], outputs=[chatbot] ) - # Mettre à jour le lien de partage quand le thread_state change @gr.on(thread_state.change, inputs=[thread_state], outputs=[share_output]) def create_share_link(thread_id: str): - """Met à jour le lien de partage basé sur le thread_id""" + """Update share link on change for thread_id""" if thread_id and thread_id.strip(): return f"**Lien de partage :** [/discussion?thread_id={thread_id}](/discussion?thread_id={thread_id})" return "" @gr.on(new_discussion_btn.click, inputs=[username_state], outputs=[chatbot, thread_state, share_output]) def reset_thread_id(username: str): - """Réinitialise le thread_id et démarre une nouvelle discussion""" + """Reset thread_id to start a new conversation""" new_thread_id = f"thread-{uuid.uuid4().hex}" logger.info(f"reset_thread_id(username={username}, new_thread_id={new_thread_id})") From 267c2d7daccd52278b59b8dacf2cfd71db95c9d7 Mon Sep 17 00:00:00 2001 From: MBorne Date: Mon, 24 Nov 2025 15:08:04 +0100 Subject: [PATCH 7/8] chore(logs): remove logs for /health and /health/db (refs #31) --- demo_gradio.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/demo_gradio.py b/demo_gradio.py index fffe99c..5ffcf39 100644 --- a/demo_gradio.py +++ b/demo_gradio.py @@ -377,6 +377,14 @@ def get_gradio_user(request: Request): app = gr.mount_gradio_app(app, demo_share, path="/discussion") +class HealthCheckFilter(logging.Filter): + """Remove /health and /health/* from application server logs""" + def filter(self, record: logging.LogRecord) -> bool: + return record.getMessage().find("/health") == -1 + +logging.getLogger("uvicorn.access").addFilter(HealthCheckFilter()) + + if __name__ == "__main__": logging.info("Demo is running on http://localhost:8000") uvicorn.run( From 3634d2abc5bf08582dfaa4007d353a15413cf078 Mon Sep 17 00:00:00 2001 From: MBorne Date: Mon, 24 Nov 2025 16:29:20 +0100 Subject: [PATCH 8/8] chore(merge): resync branch with main... --- docker-compose.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 54e2065..2f2c1ad 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,11 +36,7 @@ services: # allows to test oauth2-proxy on http://localhost:8001 oauth2-proxy: -<<<<<<< HEAD - image: quay.io/oauth2-proxy/oauth2-proxy:latest -======= image: quay.io/oauth2-proxy/oauth2-proxy:v7.13.0-alpine ->>>>>>> main command: ["/bin/oauth2-proxy", "--errors-to-info-log"] deploy: mode: replicated