Sign-in by customer: realm petrolyst, row rules, guest role (PD-21)

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>
This commit is contained in:
Nasim SadatandClaude Opus 5.5 committed 2026-10-30 18:31:14 +01:00
1 parent ab553526f6
commit 78a498f1ce
20 files changed
+279 -78

No files matched your search

+6 -6
View File
@@ -15,7 +15,7 @@ and where it runs lives in `stacks/`, `traefik/`, `deploy/` and `host/`.
|---|---|---|
| `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` |
| `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` |
| `roles/` | `atoma_guest.py`: the role "ATOMA Guest" for the Petrolyst app's embedded dashboards, with exactly its permissions; run by hand (see "Embedding") | `/app/petrolyst-roles/` |
| `roles/` | `petrolyst_guest.py`: the role "Petrolyst Guest" for the Petrolyst app's embedded dashboards, with exactly its permissions; run by hand (see "Embedding") | `/app/petrolyst-roles/` |
| `assets/branding/` | logos (full colour and white): the wordmark alone for the navbar, and the stacked logo; favicons, app icons; ATOMA's logo, which the Petrolyst app shows in its map's corner (the server reads this file itself: `PETROLYST_OPERATOR_LOGO_FILE`) | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); none at present | `/app/patches/petrolyst/`, applied during the build |
| `dashboards/` | exported dashboards and import scripts (see its README) | not in the image |
@@ -35,13 +35,13 @@ value.
|---|---|
| `superset_secret_key` | Flask `SECRET_KEY` |
| `superset_db_password` | password of user `superset` on the metadata database |
| `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst` |
| `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst`, realm `petrolyst`; on the server in `shared/keycloak_petrolyst_realm_client_secret` |
On the server they come from `/opt/petrolyst/secrets/` (see the deployment README).
## Sign-in and roles
- Provider: Keycloak realm `thedigitalbridge` at `iam.thedigitalbridge.io`, client `petrolyst`,
- Provider: Keycloak realm `petrolyst` at `iam.thedigitalbridge.io`, client `petrolyst`,
authorization code flow with PKCE S256. The realm URL can be overridden with
`KEYCLOAK_REALM_URL`.
- Roles come from the client roles of `petrolyst`, which a protocol mapper puts into the claim
@@ -68,14 +68,14 @@ stack** (`.local/fmdb-scratch`); `superset_config.py` here leaves it off, and lo
guest secret.
- The scratch stack's `superset_config_local.py` turns on `EMBEDDED_SUPERSET`, names the guest
role `GUEST_ROLE_NAME = "ATOMA Guest"`, reads the signing secret with
role `GUEST_ROLE_NAME = "Petrolyst Guest"`, reads the signing secret with
`_read_secret("superset_guest_token_secret")` and expects the audience `petrolyst-app`. The
Petrolyst server signs the guest passes itself with the same secret.
- `roles/atoma_guest.py` gives "ATOMA Guest" exactly what a dashboard needs to be read. Which
- `roles/petrolyst_guest.py` gives "Petrolyst Guest" exactly what a dashboard needs to be read. Which
dashboard a guest sees comes from the pass, not the role. It is not in `init.sh`; run it once,
and again whenever its permissions change:
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/atoma_guest.py
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
- The app's dashboards and their embedding ids: `dashboards/README.md`.
+1 -1
View File
@@ -4,7 +4,7 @@ Superset dashboards as code and the operator scripts that import them. Not baked
image, so changes here do not rebuild or restart Superset.
- `atoma/`: ATOMA's two dashboards for the Petrolyst app (PD-12, PD-13; SPEC.md §10):
`atoma-overview` (fleet totals) and `atoma-site` (one site, its four sections as tabs). The
`overview` (fleet totals) and `site` (one site, its four sections as tabs), named `atoma-overview` and `atoma-site` until 30 Oct. The
app embeds them; admins open them in Superset, with the filter column. `SPEC.md` is the
design; `build_bundle.py` generates the import bundle and `import_bundle.py` imports it
(overwriting the objects the bundle defines).
+2 -1
View File
@@ -1,6 +1,7 @@
# ATOMA dashboards: Overview and Site, for the Petrolyst app
**Status (29 Oct 2026, PD-13): two dashboards, `atoma-overview` and `atoma-site` (§10), built and
**Status (30 Oct 2026, PD-21: every customer sees these two, limited by row; slugs `overview` and `site`, formerly
`atoma-overview` and `atoma-site`, which the rest of this file still uses). Two dashboards (§10), built and
tested on a local Superset; not imported live.** The Petrolyst app embeds them, and admins open
them in Superset with the filter column.
- `build_bundle.py` generates the dashboards, every dataset and every chart from this
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Generate ATOMA's dashboards for the Petrolyst app as one Superset 6.1 import bundle.
"""Generate the dashboards of the Petrolyst app as one Superset 6.1 import bundle.
Implements SPEC.md in this folder: the two dashboards the Petrolyst app embeds, "ATOMA · Overview"
(atoma-overview) and "ATOMA · Site" (atoma-site, the four detail tabs), one dataset per
public.v_* view and every chart. Nothing in the bundle names a site.
Implements SPEC.md in this folder: the two dashboards the app embeds, "Overview" (overview) and
"Site" (site, the four detail tabs), one dataset per public.v_* view and every chart. Every
customer sees the same dashboards, limited by row: the app's guest pass names the customer (PD-16).
Nothing in the bundle names a site or a customer.
build_bundle.py --database-uuid <uuid of "Fuel"> [--out atoma-dashboards.zip]
@@ -15,7 +16,8 @@ dashboards (the stock dashboard import leaves existing datasets and charts
unchanged).
Before writing, the bundle is checked: every column a chart or filter uses
exists in its dataset, every chart in a Site filter's scope has site_label, the
exists in its dataset, every dataset has operator_uid (the guest pass's row rule needs
it), every chart in a Site filter's scope has site_label, the
Overview sums recorded values only and money only per currency, and no row is
wider than 12. Requires PyYAML.
"""
@@ -496,8 +498,11 @@ def timeseries_common(x: str, metrics: list[Any], groupby: list[str], grain: str
# --- Overview (PD-12, embedded in the Petrolyst app) ---------------------------------------
OVERVIEW_SLUG = "atoma-overview"
SITE_SLUG = "atoma-site" # one embedded dashboard with the DETAIL_TABS as its tabs
OVERVIEW_SLUG = "overview"
SITE_SLUG = "site" # one embedded dashboard with the DETAIL_TABS as its tabs
# A dashboard's identity, apart from its slug: the names it was first built under, so a re-import
# updates the existing two (and keeps their embedding ids) rather than adding two new ones.
DASHBOARD_KEYS = {OVERVIEW_SLUG: "atoma-overview", SITE_SLUG: "atoma-site"}
# SPEC §10: only sums and counts of recorded values, never an average of per-site rates; money
# only grouped by currency. The value says whether the metric is money.
@@ -935,7 +940,7 @@ def dashboard_yaml(key: str, title: str, slug: str, layout: Layout, metadata: di
"certified_by": None,
"certification_details": None,
"published": True,
"uuid": uid("dashboard", key),
"uuid": uid("dashboard", DASHBOARD_KEYS[key]),
"position": layout.nodes,
"metadata": metadata,
"version": VERSION,
@@ -1000,7 +1005,7 @@ def build(database_uuid: str, columns_path: Path) -> dict[str, Any]:
all_charts = {**{c.key: c for c in detail_charts()}, **overview}
dashboards: list[tuple[Layout, dict[str, Chart], dict[str, Any]]] = []
for key, title in [(OVERVIEW_SLUG, "Overview"), (SITE_SLUG, "Site")]:
page = Layout(f"ATOMA · {title}", all_charts)
page = Layout(title, all_charts)
page.markdown.update({f"notes-{k}": v for k, v in TAB_NOTES.items()})
page.markdown["notes-overview"] = OVERVIEW_NOTES
if key == OVERVIEW_SLUG:
@@ -1013,7 +1018,7 @@ def build(database_uuid: str, columns_path: Path) -> dict[str, Any]:
placed = {k: all_charts[k] for k in page.ids}
page_metadata = base_metadata(page, filters, cross_filters=False)
page_metadata["label_colors"].update(STOCK_STATUS_COLOURS)
files[f"dashboards/{key}.yaml"] = dashboard_yaml(key, f"ATOMA · {title}", key, page,
files[f"dashboards/{key}.yaml"] = dashboard_yaml(key, title, key, page,
page_metadata)
dashboards.append((page, placed, page_metadata))
@@ -1031,6 +1036,12 @@ def check(files: dict[str, Any], views: dict[str, list[tuple[str, str]]],
problems = []
columns = {v: {c for c, _ in cols} | {c[0] for c in CALCULATED.get(v, [])}
for v, cols in views.items()}
for view, cols in columns.items():
if "operator_uid" not in cols:
problems.append(f"{view}: no operator_uid, so the guest pass's row rule cannot hold")
for name, (_, _, sql) in VIRTUAL.items():
if "operator_uid" not in sql:
problems.append(f"{name}: its SQL does not select operator_uid")
for view in list(MAIN_DTTM) + list(CALCULATED) + list(METRICS):
if view not in columns:
problems.append(f"{view}: configured but not in the column snapshot")
@@ -17,7 +17,7 @@ live view and any drift is reported (exit status 2).
The database is never created or changed: the bundle's database uuid must
already exist under the name "Fuel", otherwise nothing is imported.
The dashboards (atoma-overview, atoma-site) get no role: admins see them in Superset, and
The dashboards (overview, site) get no role: admins see them in Superset, and
the Petrolyst app shows them with guest passes (PD-12). With --embedded-out FILE
--embed-domain URL, each of them is made embeddable from that origin (its embedding id stays
the same across imports), and FILE receives {slug: embedding id} for the Petrolyst server:
@@ -36,7 +36,7 @@ import zipfile
import yaml
DATABASE_NAME = "Fuel"
EMBEDDED_PREFIX = "atoma-"
EMBEDDED_SLUGS = {"overview", "site"}
STEPS = [("datasets", "SqlaTable"), ("charts", "Slice"), ("dashboards", "Dashboard")]
@@ -104,12 +104,17 @@ def main() -> int:
if path.startswith("dashboards/"):
dashboard = db.session.query(Dashboard).filter_by(
uuid=yaml.safe_load(text)["uuid"]).one()
if dashboard.slug.startswith(EMBEDDED_PREFIX) and args.embedded_out:
if dashboard.slug in EMBEDDED_SLUGS and args.embedded_out:
from superset.daos.dashboard import EmbeddedDashboardDAO
config = EmbeddedDashboardDAO.upsert(dashboard, [args.embed_domain])
db.session.flush()
embedded[dashboard.slug] = str(config.uuid)
# An empty or short list would stop the app's dashboards from opening.
if args.embedded_out and set(embedded) != EMBEDDED_SLUGS:
db.session.rollback()
print(f"embedded: expected {sorted(EMBEDDED_SLUGS)}, got {sorted(embedded)}", file=sys.stderr)
return 3
db.session.commit()
if args.embedded_out:
with open(args.embedded_out, "w") as out:
+11 -1
View File
@@ -1,7 +1,7 @@
"""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
Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Four changes to
the stock behaviour:
- User info and roles come from the ID token that Keycloak issues for client
@@ -11,6 +11,8 @@ the stock behaviour:
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.
- A Keycloak user with no mapped Superset role is refused, and no account is
made. Customers' users belong in the Petrolyst app, never here (PD-21).
"""
import logging
@@ -70,6 +72,14 @@ class PetrolystSecurityManager(SupersetSecurityManager):
# FAB's OAuth view, and that page wins the route.
register_superset_auth_view = False
def auth_user_oauth(self, userinfo: dict[str, Any]) -> Any:
# Only the mapped roles count: FAB's own calculation always adds the
# registration role, so it is never empty.
if not self.get_roles_from_keys(userinfo.get("role_keys", [])):
logger.warning("Keycloak sign-in refused: no mapped Superset role")
return None
return super().auth_user_oauth(userinfo)
def oauth_user_info(
self, provider: str, response: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
@@ -1,14 +1,15 @@
"""The role "ATOMA Guest": what the Petrolyst app's embedded dashboards may do (PD-12).
"""The role "Petrolyst Guest": what the Petrolyst app's embedded dashboards may do (PD-12).
Superset gives every embedded viewer (a guest pass signed for one dashboard) the role named by
GUEST_ROLE_NAME. Which dashboard a guest sees comes from the pass, not from the role. Superset
GUEST_ROLE_NAME. Which dashboard and which customer's rows a guest sees come from the pass, not from
the role. Superset
leaves the SQL out of chart-data responses for guests.
Embedding is switched on in the local stack only (.local/fmdb-scratch, superset_config_local.py),
so this script is not part of init.sh; run it by hand inside the Superset container, once, and
again whenever PERMISSIONS change:
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/atoma_guest.py
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
Creates the role if needed and gives it exactly PERMISSIONS: missing ones are added, any other
is removed (also one added by hand in the UI). A second run changes nothing.
@@ -22,7 +23,7 @@ from __future__ import annotations
import sys
ROLE = "ATOMA Guest"
ROLE = "Petrolyst Guest"
PERMISSIONS = [
("can_read", "Dashboard"), # the dashboard and its charts' definitions
("can_dashboard", "Superset"), # the dashboard page
+4 -4
View File
@@ -64,7 +64,7 @@ SQLALCHEMY_CUSTOM_PASSWORD_STORE = _database_password
# 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/thedigitalbridge"
"KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/petrolyst"
).rstrip("/")
AUTH_TYPE = AUTH_OAUTH
@@ -94,12 +94,12 @@ AUTH_ROLES_MAPPING = {
"superset-admin": ["Admin"],
"superset-editor": ["Alpha"],
"superset-viewer": ["Gamma"],
# "atoma-reader" opens the Petrolyst app only; here it maps to nothing (Public).
# 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 Superset role signs in
# and sees nothing.
# 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.