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:
1 parent
ab553526f6
commit
78a498f1ce
20 files changed
+279
-78
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+5
-4
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user