Files
petrolyst-infra/petrolyst-insights/petrolyst_security.py
T
Nasim SadatandClaude Opus 5.5 da3f803947 Retire the Superset reader setup and the ATOMA dashboard (PD-13)
ATOMA's users use the Petrolyst app; Superset keeps admins, editors and the
app's embedded dashboards. Removed: the "ATOMA Reader" role and its rules
(petrolyst_reader.py, ReaderAccessMixin, reader.css, the head template, the
SQL-hiding patch), the atoma-reader mapping (it now maps to nothing in
Superset), the iframe allowance for the Map tab, and the "atoma" dashboard
with its Fleet/Map tab, D0 and dashboard.css. The bundle holds atoma-overview
and atoma-site only (byte-identical to before); the guest role keeps the
reader's 12 permissions in roles/atoma_guest.py. Brand files now name
petrolyst-web. SPEC marks the retired parts as history.

Checks (local): bundle checks pass; image rebuilt and healthy; import with
column drift 0 and unchanged embedding ids; the production config loads with
OAuth, embedding off, no reader hook; admin sees atoma-site with its tabs and
filter column; the app's browser tests pass 10/10. shellcheck not run (not
installed). Not deployed.

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

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 []),
}