Files
petrolyst-infra/petrolyst-insights/petrolyst_security.py
T
Nasim SadatandClaude Opus 5.5 1b8e57adb4 Trial state of 29 Oct 2026: ATOMA reader setup, app dashboards, embedding notes
Baseline before the clean-up (PD-13): the PD-11 reader setup and the PD-12 app
dashboards as they ran on the local stack, uncommitted until now. Not deployed,
not independently reviewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:50:26 +01:00

97 lines
4.0 KiB
Python

"""Keycloak sign-in for Superset at atoma.petrolyst.com.
Set as CUSTOM_SECURITY_MANAGER in superset_config.py. Checked against
Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Three changes to
the stock behaviour:
- User info and roles come from the ID token that Keycloak issues for client
"petrolyst" (claim petrolyst_roles). FAB's built-in Keycloak branch would read
a "groups" claim from the userinfo endpoint instead.
- /login/ goes straight to Keycloak. Superset 6.1 normally registers its React
login page on /login/, which only shows a "Sign in with keycloak" button.
- /logout/ also ends the Keycloak session, so the next visit asks for the
password and OTP again.
It also takes the reader rules (petrolyst_reader.ReaderAccessMixin), as does the
local stack's database-login manager.
"""
import logging
from typing import Any, Optional
from urllib.parse import urlencode
from flask import g, redirect, request, url_for
from flask_appbuilder import expose
from flask_appbuilder.security.views import AuthOAuthView
from flask_appbuilder.utils.base import get_safe_redirect
from flask_login import logout_user
from werkzeug.wrappers import Response as WerkzeugResponse
from petrolyst_reader import ReaderAccessMixin
from superset.security import SupersetSecurityManager
logger = logging.getLogger(__name__)
PROVIDER = "keycloak"
ROLE_CLAIM = "petrolyst_roles"
class PetrolystAuthOAuthView(AuthOAuthView):
@expose("/login/")
@expose("/login/<provider>")
def login(self, provider: Optional[str] = None) -> WerkzeugResponse:
authenticated = g.user is not None and g.user.is_authenticated
if provider is None and not authenticated:
# FAB encodes the query arguments into the OAuth state and runs
# get_safe_redirect on "next" again after the callback; checking it
# here as well keeps an unsafe value out of the state altogether.
args = {}
if request.args.get("next"):
args["next"] = get_safe_redirect(request.args["next"])
return redirect(url_for(".login", provider=PROVIDER, **args))
return super().login(provider)
@expose("/logout/")
def logout(self) -> WerkzeugResponse:
logout_user()
remote = self.appbuilder.sm.oauth_remotes[PROVIDER]
end_session = remote.load_server_metadata().get("end_session_endpoint")
if not end_session:
logger.warning("Keycloak metadata has no end_session_endpoint")
return redirect(self.appbuilder.get_url_for_index)
# client_id instead of id_token_hint: Keycloak then asks the user to
# confirm the logout once, and no ID token has to live in the cookie.
params = {
"client_id": remote.client_id,
"post_logout_redirect_uri": request.host_url,
}
return redirect(f"{end_session}?{urlencode(params)}")
class PetrolystSecurityManager(ReaderAccessMixin, SupersetSecurityManager):
authoauthview = PetrolystAuthOAuthView
# Without this, Superset registers its React login page on /login/ before
# FAB's OAuth view, and that page wins the route.
register_superset_auth_view = False
def oauth_user_info(
self, provider: str, response: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
if provider != PROVIDER:
raise ValueError(f"unsupported OAuth provider {provider!r}")
# Authlib verifies the ID token (signature, issuer, audience, nonce)
# and puts its claims under "userinfo".
claims = (response or {}).get("userinfo")
if not claims:
claims = self.oauth_remotes[provider].userinfo()
username = claims.get("preferred_username")
if not username:
raise ValueError("Keycloak token has no preferred_username")
return {
"username": username,
"email": claims.get("email", ""),
"first_name": claims.get("given_name", ""),
"last_name": claims.get("family_name", ""),
"role_keys": list(claims.get(ROLE_CLAIM) or []),
}