Keycloak import for realm petrolyst: two clients, petrolyst with MFA for Superset and Traefik, petrolyst-app password only. It lists only app.petrolyst.com and traefik.petrolyst.com, and no customer names, as this repository is public. public.v_operator shows each customer's group. Dashboards overview and site, role Petrolyst Guest, filtered by row. Superset refuses a user with no mapped role. The realm travels with the release: each release's compose files name their realm and secret file, and a rollback needs only its target's secret. Not deployed. Checks earlier on this content: tests/run.sh 109 of 109 and read-only, dashboards import twice, column drift 0, compose config on old and new trees, the secret check on a scratch folder, a rehearsal on a throwaway Keycloak 26.4. Never a real deploy or rollback; shellcheck not installed today, clean when last run. Codex inspected once; its fixes are not re-inspected, on the owner's word. Bands: persistence, Superset. Rules: 1, 2, 3, 6, 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""Superset configuration for Petrolyst (ATOMA fuel monitoring).
|
|
|
|
Customization only, and no secrets: those are read from /run/secrets/<name>,
|
|
which stacks/fuel-monitoring/compose.yaml mounts from /opt/petrolyst/secrets/.
|
|
Baked into the image at /app/pythonpath/, which is on PYTHONPATH in
|
|
apache/superset:6.1.0, so Superset loads it without SUPERSET_CONFIG_PATH.
|
|
"""
|
|
|
|
import os
|
|
import pathlib
|
|
from copy import deepcopy
|
|
|
|
from flask_appbuilder.security.manager import AUTH_OAUTH
|
|
from sqlalchemy.engine.url import URL
|
|
|
|
from petrolyst_security import PetrolystSecurityManager
|
|
from superset import config as superset_defaults
|
|
|
|
|
|
def _read_secret(name: str, env_var: str | None = None) -> str:
|
|
"""Prefer /run/secrets/<name>; fall back to an env var for local dev.
|
|
|
|
Uses rstrip('\r\n') (not strip()) so passwords or keys that legitimately
|
|
contain leading/trailing spaces are not silently mutated. Raises on empty
|
|
values so boot fails loudly instead of accepting a blank secret.
|
|
"""
|
|
path = pathlib.Path(f"/run/secrets/{name}")
|
|
if path.is_file():
|
|
value = path.read_text().rstrip("\r\n")
|
|
else:
|
|
value = os.environ[env_var or name.upper()]
|
|
if not value:
|
|
raise RuntimeError(f"secret {name!r} must not be empty")
|
|
return value
|
|
|
|
|
|
SECRET_KEY = _read_secret("superset_secret_key", "SUPERSET_SECRET_KEY")
|
|
|
|
SQLALCHEMY_DATABASE_URI = URL.create(
|
|
"postgresql+psycopg2",
|
|
username="superset",
|
|
password=_read_secret("superset_db_password", "SUPERSET_DB_PASSWORD"),
|
|
host=os.environ.get("SUPERSET_DB_HOST", "superset-db"),
|
|
port=5432,
|
|
database="superset",
|
|
).render_as_string(hide_password=False)
|
|
|
|
# --- The data: fuel-db ----------------------------------------------------------
|
|
# Superset reads fuel-db directly as the read-only role fuel_ro;
|
|
# superset-init registers that connection, "Fuel", without a password. Passwords of data
|
|
# connections come from secret files at connect time, so none is kept in Superset's own
|
|
# database. A connection added in the UI gets no password this way.
|
|
_DATABASE_PASSWORD_SECRETS = {"fuel_ro": "fuel_db_ro_password"}
|
|
|
|
|
|
def _database_password(url: URL) -> str | None:
|
|
secret = _DATABASE_PASSWORD_SECRETS.get(url.username or "")
|
|
return _read_secret(secret) if secret else None
|
|
|
|
|
|
SQLALCHEMY_CUSTOM_PASSWORD_STORE = _database_password
|
|
|
|
# --- Sign-in through Keycloak (client "petrolyst") ----------------------------
|
|
# Users and roles are managed in Keycloak; there is no local admin account.
|
|
# Break-glass access: `superset fab ...` inside the container on the server.
|
|
KEYCLOAK_REALM_URL = os.environ.get(
|
|
"KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/petrolyst"
|
|
).rstrip("/")
|
|
|
|
AUTH_TYPE = AUTH_OAUTH
|
|
CUSTOM_SECURITY_MANAGER = PetrolystSecurityManager
|
|
OAUTH_PROVIDERS = [
|
|
{
|
|
"name": "keycloak",
|
|
"icon": "fa-key",
|
|
"token_key": "access_token",
|
|
"remote_app": {
|
|
"client_id": os.environ.get("KEYCLOAK_CLIENT_ID", "petrolyst"),
|
|
"client_secret": _read_secret(
|
|
"keycloak_petrolyst_client_secret", "KEYCLOAK_CLIENT_SECRET"
|
|
),
|
|
"server_metadata_url": f"{KEYCLOAK_REALM_URL}/.well-known/openid-configuration",
|
|
"api_base_url": f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/",
|
|
"client_kwargs": {
|
|
"scope": "openid email profile",
|
|
"code_challenge_method": "S256",
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
# Keys are Keycloak client roles of "petrolyst" (claim petrolyst_roles).
|
|
AUTH_ROLES_MAPPING = {
|
|
"superset-admin": ["Admin"],
|
|
"superset-editor": ["Alpha"],
|
|
"superset-viewer": ["Gamma"],
|
|
# Customers' users have a role of client "petrolyst-app", not of this one: refused here.
|
|
}
|
|
AUTH_ROLES_SYNC_AT_LOGIN = True
|
|
AUTH_USER_REGISTRATION = True
|
|
# Public has no permissions. A Keycloak user without a mapped role never gets
|
|
# this far: PetrolystSecurityManager refuses the sign-in and makes no account.
|
|
AUTH_USER_REGISTRATION_ROLE = "Public"
|
|
|
|
# Viewers (Gamma) see a dashboard once it lists their role.
|
|
# FILTERBAR_CLOSED_BY_DEFAULT: a dashboard's filter column opens collapsed, a
|
|
# narrow strip with an expand button (owner, 29 Oct); ?expand_filters=true
|
|
# opens it from a link.
|
|
FEATURE_FLAGS = {"DASHBOARD_RBAC": True, "FILTERBAR_CLOSED_BY_DEFAULT": True}
|
|
|
|
# deck.gl maps: OpenStreetMap raster tiles only. The default list adds Mapbox
|
|
# styles, which fail without MAPBOX_API_KEY. The default CSP already allows the
|
|
# tile host.
|
|
DECKGL_BASE_MAP = [
|
|
["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"],
|
|
]
|
|
|
|
# --- Behind Traefik -----------------------------------------------------------
|
|
ENABLE_PROXY_FIX = True
|
|
PREFERRED_URL_SCHEME = "https"
|
|
SESSION_COOKIE_SECURE = True
|
|
SESSION_COOKIE_HTTPONLY = True
|
|
SESSION_COOKIE_SAMESITE = "Lax"
|
|
TALISMAN_CONFIG = deepcopy(superset_defaults.TALISMAN_CONFIG)
|
|
TALISMAN_CONFIG["session_cookie_secure"] = True
|
|
|
|
# --- Redis: caches and rate limits (no Celery, so no results backend) ---------
|
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
|
|
|
CACHE_CONFIG = {
|
|
"CACHE_TYPE": "RedisCache",
|
|
"CACHE_DEFAULT_TIMEOUT": 300,
|
|
"CACHE_KEY_PREFIX": "superset_cache_",
|
|
"CACHE_REDIS_URL": f"{REDIS_URL}/0",
|
|
}
|
|
DATA_CACHE_CONFIG = {
|
|
"CACHE_TYPE": "RedisCache",
|
|
"CACHE_DEFAULT_TIMEOUT": 3600,
|
|
"CACHE_KEY_PREFIX": "superset_data_",
|
|
"CACHE_REDIS_URL": f"{REDIS_URL}/0",
|
|
}
|
|
FILTER_STATE_CACHE_CONFIG = {
|
|
"CACHE_TYPE": "RedisCache",
|
|
"CACHE_DEFAULT_TIMEOUT": 86400,
|
|
"CACHE_KEY_PREFIX": "superset_filter_",
|
|
"CACHE_REDIS_URL": f"{REDIS_URL}/0",
|
|
}
|
|
EXPLORE_FORM_DATA_CACHE_CONFIG = {
|
|
"CACHE_TYPE": "RedisCache",
|
|
"CACHE_DEFAULT_TIMEOUT": 86400,
|
|
"CACHE_KEY_PREFIX": "superset_explore_",
|
|
"CACHE_REDIS_URL": f"{REDIS_URL}/0",
|
|
}
|
|
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
|
|
|
|
# --- Petrolyst look and feel --------------------------------------------------
|
|
# Values from petrolyst-web/design/brand/07_UI_Tokens/petrolyst_tokens.css.
|
|
# Assets live in assets/branding/ and are served from /static/assets/branding/.
|
|
BRANDING = "/static/assets/branding"
|
|
|
|
APP_NAME = "Petrolyst"
|
|
APP_ICON = f"{BRANDING}/petrolyst_wordmark_fullcolor.svg"
|
|
FAVICONS = [
|
|
{"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"},
|
|
{"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"},
|
|
{"href": f"{BRANDING}/petrolyst_icon_180.png", "rel": "apple-touch-icon"},
|
|
]
|
|
|
|
THEME_DEFAULT = deepcopy(superset_defaults.THEME_DEFAULT)
|
|
THEME_DEFAULT["token"].update(
|
|
{
|
|
"brandAppName": APP_NAME,
|
|
"brandLogoAlt": "Petrolyst",
|
|
"brandLogoUrl": APP_ICON,
|
|
"brandLogoHref": "/",
|
|
# The wordmark alone (owner, 29 Oct): in the stacked logo, at 40px, its
|
|
# letters were about 5px high. Its own Y carries the brand accent. 24px
|
|
# (letters about 20px) and the margin fill the 60px navbar height. On the
|
|
# left, 15px more than the navbar's 16px: the P (1.4px inside the image)
|
|
# lines up with the dashboard's content at 32px.
|
|
"brandLogoHeight": "24px",
|
|
"brandLogoMargin": "18px 0 18px 15px",
|
|
# Action teal, not the raw brand teal #009688: the brand guide keeps
|
|
# raw teal off small text and controls.
|
|
"colorPrimary": "#00695C",
|
|
"colorLink": "#00695C",
|
|
"colorSuccess": "#0F7B6C",
|
|
"colorWarning": "#B45309",
|
|
"colorError": "#B3261E",
|
|
"colorInfo": "#2563EB",
|
|
"colorTextBase": "#0B1D33",
|
|
"colorBgLayout": "#F8FAFC",
|
|
"colorBorder": "#E2E8F0",
|
|
"borderRadius": 8,
|
|
"fontFamily": "Inter, system-ui, sans-serif",
|
|
}
|
|
)
|
|
|
|
THEME_DARK = deepcopy(THEME_DEFAULT)
|
|
THEME_DARK["algorithm"] = "dark"
|
|
THEME_DARK["token"].update(
|
|
{
|
|
"brandLogoUrl": f"{BRANDING}/petrolyst_wordmark_white.svg",
|
|
"colorPrimary": "#35C5B6",
|
|
"colorLink": "#35C5B6",
|
|
"colorSuccess": "#35C5B6",
|
|
"colorWarning": "#F59E0B",
|
|
"colorError": "#FF6B6B",
|
|
"colorInfo": "#60A5FA",
|
|
"colorTextBase": "#F8FAFC",
|
|
"colorBgBase": "#071425",
|
|
"colorBgLayout": "#071425",
|
|
"colorBgContainer": "#12263D",
|
|
"colorBorder": "#526D82",
|
|
"colorEditorSelection": superset_defaults.THEME_DARK["token"][
|
|
"colorEditorSelection"
|
|
],
|
|
}
|
|
)
|
|
|
|
# Chart colours. Validated with the dataviz skill's validator on both chart
|
|
# surfaces (#FFFFFF light, #12263D dark): all slots in both lightness bands and
|
|
# >= 3:1; worst adjacent CVD dE 13.7, normal-vision dE 26.2; the first three
|
|
# slots also pass all-pairs (scatter, maps). Brand teal leads; brand cyan
|
|
# #00B6E6 is snapped to #0097C4 because it is too light for the dark surface
|
|
# and below 3:1 on white. The other hues come from the validated reference set.
|
|
# Status meaning (warning, critical) never rides on these colours.
|
|
EXTRA_CATEGORICAL_COLOR_SCHEMES = [
|
|
{
|
|
"id": "petrolyst",
|
|
"description": "Petrolyst categorical palette, brand teal first",
|
|
"label": "Petrolyst",
|
|
"isDefault": True,
|
|
"colors": [
|
|
"#009688", # brand teal
|
|
"#d95926", # orange
|
|
"#9085e9", # violet
|
|
"#c98500", # amber
|
|
"#0097C4", # brand cyan, snapped
|
|
"#e34948", # red
|
|
"#3987e5", # blue
|
|
"#d55181", # magenta
|
|
],
|
|
}
|
|
]
|
|
|
|
# One hue, light to dark; the lightest step means "near zero" and may recede.
|
|
EXTRA_SEQUENTIAL_COLOR_SCHEMES = [
|
|
{
|
|
"id": "petrolyst_teal",
|
|
"description": "Petrolyst teal, light to dark",
|
|
"label": "Petrolyst Teal",
|
|
"isDiverging": False,
|
|
"isDefault": True,
|
|
"colors": [
|
|
"#E6F4F2",
|
|
"#B3DED8",
|
|
"#7FC8BE",
|
|
"#4CB2A4",
|
|
"#009688",
|
|
"#00695C",
|
|
"#004D44",
|
|
],
|
|
},
|
|
{
|
|
"id": "petrolyst_navy",
|
|
"description": "Petrolyst navy, light to dark",
|
|
"label": "Petrolyst Navy",
|
|
"isDiverging": False,
|
|
"isDefault": False,
|
|
"colors": [
|
|
"#E8EEF5",
|
|
"#C3D1E2",
|
|
"#8FA7C4",
|
|
"#5C7DA6",
|
|
"#2F5585",
|
|
"#183A63",
|
|
"#0B1D33",
|
|
],
|
|
},
|
|
]
|