Traefik as the server-wide edge (TLS via Let's Encrypt, dashboard on traefik.petrolyst.com behind oauth2-proxy and Keycloak). The fuel-monitoring stack: Superset on atoma.petrolyst.com with Keycloak login and Petrolyst branding, Trino reading the TimescaleDB fuel-db as catalog "fuel". Customization lives in petrolyst-insights/, deployment in the other folders. Deploys run server-side (deploy/deploy.sh) from a Gitea workflow or manually; releases are immutable, Superset metadata is backed up before migrations, and deploy/rollback.sh restores it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
3.8 KiB
Python
93 lines
3.8 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.
|
|
"""
|
|
|
|
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 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(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 []),
|
|
}
|