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>
This commit is contained in:
Nasim SadatandClaude Opus 5.5 committed 2026-10-29 21:10:19 +01:00
1 parent 1b8e57adb4
commit da3f803947
22 files changed
+164 -702

No files matched your search

+4 -6
View File
@@ -110,11 +110,10 @@ deploy secrets.
enrols first. Other clients of the realm are unaffected. There is no silent SSO between enrols first. Other clients of the realm are unaffected. There is no silent SSO between
Superset and the dashboard. Superset and the dashboard.
4. Give people roles: Users → (user) → Role mapping → Assign role → filter by clients → 4. Give people roles: Users → (user) → Role mapping → Assign role → filter by clients →
`petrolyst`. Group mappings work too. ATOMA's users get `atoma-reader` and nothing else. `petrolyst`. Group mappings work too. ATOMA's users get `atoma-reader` and nothing else:
They then see only the ATOMA dashboard: no menus, nothing to edit, and Settings offers only it opens the Petrolyst app (petrolyst-platform) and maps to no Superset role, so in
Logout (Superset role "ATOMA Reader", `petrolyst-insights/roles/atoma_reader.py`). Superset they see nothing. A realm that already has the client gets the role by hand
A realm that already has the client gets the new role by hand (Clients → petrolyst → (Clients → petrolyst → Roles → Create role) or by importing again with "Skip".
Roles → Create role) or by importing again with "Skip".
5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy 5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy
refuses unverified addresses. refuses unverified addresses.
@@ -165,7 +164,6 @@ becomes the first Superset admin; there is no local admin account.
| fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` | | fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` |
| Logs | `docker compose -p fuel-monitoring logs -f superset` (or `-p traefik`) | | Logs | `docker compose -p fuel-monitoring logs -f superset` (or `-p traefik`) |
| Superset break-glass | `docker exec -it fuel-monitoring-superset-1 superset fab ...` | | Superset break-glass | `docker exec -it fuel-monitoring-superset-1 superset fab ...` |
| Reset the role "ATOMA Reader" (after an edit in the UI) | `docker exec fuel-monitoring-superset-1 /app/.venv/bin/python /app/petrolyst-roles/atoma_reader.py`; a deploy does it whenever the Superset image changes |
**Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this **Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this
repository: repository:
+1 -3
View File
@@ -21,7 +21,7 @@ KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/
PETROLYST_ROOT=$ROOT PETROLYST_ROOT=$ROOT
export ATOMA_HOST TRAEFIK_HOST KEYCLOAK_REALM_URL PETROLYST_ROOT export ATOMA_HOST TRAEFIK_HOST KEYCLOAK_REALM_URL PETROLYST_ROOT
# --host pins the daemon, as in petrolyst-ui/deploy/deploy.sh: a stale # --host pins the daemon, as in petrolyst-web/deploy/deploy.sh: a stale
# `docker context use` must not send this deploy to another daemon. # `docker context use` must not send this deploy to another daemon.
DOCKER=(docker --host unix:///var/run/docker.sock) DOCKER=(docker --host unix:///var/run/docker.sock)
@@ -33,8 +33,6 @@ SUPERSET_INPUTS=(
stacks/fuel-monitoring/superset/init.sh stacks/fuel-monitoring/superset/init.sh
petrolyst-insights/superset_config.py petrolyst-insights/superset_config.py
petrolyst-insights/petrolyst_security.py petrolyst-insights/petrolyst_security.py
petrolyst-insights/petrolyst_reader.py
petrolyst-insights/templates
petrolyst-insights/roles petrolyst-insights/roles
petrolyst-insights/assets/branding petrolyst-insights/assets/branding
petrolyst-insights/patches petrolyst-insights/patches
+1 -1
View File
@@ -49,7 +49,7 @@
{ "name": "superset-admin", "description": "Superset Admin at atoma.petrolyst.com" }, { "name": "superset-admin", "description": "Superset Admin at atoma.petrolyst.com" },
{ "name": "superset-editor", "description": "Superset Alpha: build charts and dashboards" }, { "name": "superset-editor", "description": "Superset Alpha: build charts and dashboards" },
{ "name": "superset-viewer", "description": "Superset Gamma: view dashboards shared with the role" }, { "name": "superset-viewer", "description": "Superset Gamma: view dashboards shared with the role" },
{ "name": "atoma-reader", "description": "Superset ATOMA Reader: read the ATOMA dashboard only" }, { "name": "atoma-reader", "description": "ATOMA's users: the Petrolyst app (no role in Superset)" },
{ "name": "traefik-admin", "description": "Traefik dashboard at traefik.petrolyst.com" } { "name": "traefik-admin", "description": "Traefik dashboard at traefik.petrolyst.com" }
] ]
} }
+11 -13
View File
@@ -14,12 +14,10 @@ and where it runs lives in `stacks/`, `traefik/`, `deploy/` and `host/`.
| Path | What it is | In the image at | | Path | What it is | In the image at |
|---|---|---| |---|---|---|
| `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` | | `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; takes the reader rules | `/app/pythonpath/` | | `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` |
| `petrolyst_reader.py` | readers ("ATOMA Reader"): reach only dashboards granted to the role, own nothing, get no SQL; land on the ATOMA dashboard; `register` is `FLASK_APP_MUTATOR` | `/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/` |
| `templates/` | `head_custom_extra.html`: loads `reader.css` for readers on every page | `/app/pythonpath/templates/` | | `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 (`PETROLYST_OPERATOR_LOGO`) | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
| `roles/` | `atoma_reader.py`: the role "ATOMA Reader" with exactly its permissions, run by `superset-init`; `atoma_guest.py`: the role "ATOMA Guest" for the Petrolyst app's embedded dashboards, run by hand (see "Embedding") | `/app/petrolyst-roles/` | | `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); none at present | `/app/patches/petrolyst/`, applied during the build |
| `assets/branding/` | logos (full colour and white): the wordmark alone for the navbar, and the stacked logo; favicons, app icons; `reader.css`; ATOMA's logo for the fleet map | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README): `fix_redact_query_for_readers.py` | `/app/patches/petrolyst/`, applied during the build |
| `dashboards/` | exported dashboards and import scripts (see its README) | not in the image | | `dashboards/` | exported dashboards and import scripts (see its README) | not in the image |
How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build
@@ -54,7 +52,7 @@ On the server they come from `/opt/petrolyst/secrets/` (see the deployment READM
| `superset-admin` | Admin | | `superset-admin` | Admin |
| `superset-editor` | Alpha | | `superset-editor` | Alpha |
| `superset-viewer` | Gamma | | `superset-viewer` | Gamma |
| `atoma-reader` | ATOMA Reader: the ATOMA dashboard only, read-only (SPEC.md §6) | | `atoma-reader` | none (Public): ATOMA's users use the Petrolyst app, not Superset (PD-13) |
| none | Public (no permissions) | | none | Public (no permissions) |
- `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a - `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a
@@ -64,7 +62,7 @@ On the server they come from `/opt/petrolyst/secrets/` (see the deployment READM
## Embedding (local trial only) ## Embedding (local trial only)
The Petrolyst app (PD-12, petrolyst-platform `app/` and `services/app/`) shows dashboards inside The Petrolyst app (PD-12, petrolyst-platform `app/` and `server/`) shows dashboards inside
its Insights lens with Superset guest passes. This is switched on **only in the local scratch its Insights lens with Superset guest passes. This is switched on **only in the local scratch
stack** (`.local/fmdb-scratch`); `superset_config.py` here leaves it off, and loads without the stack** (`.local/fmdb-scratch`); `superset_config.py` here leaves it off, and loads without the
guest secret. guest secret.
@@ -72,10 +70,10 @@ guest secret.
- The scratch stack's `superset_config_local.py` turns on `EMBEDDED_SUPERSET`, names the guest - 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 = "ATOMA Guest"`, reads the signing secret with
`_read_secret("superset_guest_token_secret")` and expects the audience `petrolyst-app`. The `_read_secret("superset_guest_token_secret")` and expects the audience `petrolyst-app`. The
app service signs the guest passes itself with the same secret. Petrolyst server signs the guest passes itself with the same secret.
- `roles/atoma_guest.py` gives "ATOMA Guest" the same permissions as "ATOMA Reader". Which - `roles/atoma_guest.py` gives "ATOMA 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, dashboard a guest sees comes from the pass, not the role. It is not in `init.sh`; run it once,
and again whenever the permissions in `atoma_reader.py` change: 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/atoma_guest.py
@@ -85,8 +83,8 @@ Production embedding is an open point of PD-12 (`.context/design-decisions.md`).
## Look and feel ## Look and feel
Source: `petrolyst-ui/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos Source: `petrolyst-web/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos
`public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-ui commit `public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-web (then petrolyst-ui) commit
`2b2e694`. Copy again when the brand changes. `2b2e694`. Copy again when the brand changes.
- Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC` - Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC`
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-ui/design/brand/02_Logo/Vector_Master/ <!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_logo_standard_fullcolor_no_tagline.svg without the Y mark above it, paths and petrolyst_logo_standard_fullcolor_no_tagline.svg without the Y mark above it, paths and
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). --> colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-ui/design/brand/02_Logo/Vector_Master/ <!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_logo_standard_white_no_tagline.svg without the Y mark above it, paths and petrolyst_logo_standard_white_no_tagline.svg without the Y mark above it, paths and
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). --> colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->
<g fill="#FFFFFF"><path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/> <g fill="#FFFFFF"><path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -1,154 +0,0 @@
/* Readers only (petrolyst_reader.py, templates/head_custom_extra.html). Hides what Superset 6.1
shows whatever the permissions. Cosmetic: what protects is the role and the reader rules. */
/* The dashboard's title row: name, favourite, refresh, owner, last change, "…". */
[data-test="dashboard-header-container"] {
display: none !important;
}
/* ATOMA's Fleet tab: no filter column and its resize handle (no filter applies there); the
column is empty then, so the map takes the full width. Site details keeps it. Also hidden
until the dashboard's grid is there, so it does not flash up while the Fleet tab loads.
dashboards/atoma/build_bundle.py checks that this names the Fleet tab. */
body:has(.ant-tabs-tab-active[data-node-key="TAB-fleet"]) [data-test="dashboard-filters-panel"],
body:has(.ant-tabs-tab-active[data-node-key="TAB-fleet"]) div:has(+ [data-test="dashboard-filters-panel"]),
body:not(:has([data-test="grid-container"])) [data-test="dashboard-filters-panel"],
body:not(:has([data-test="grid-container"])) div:has(+ [data-test="dashboard-filters-panel"]) {
display: none !important;
}
/* Superset sizes blocks in pixels and measures again only about 0.3 s after loading, so the
map would start beside a column that is not there and then widen. Its block spans all 12
columns (build_bundle.py checks it), so the full width is always right. Its column grows to
the row's width; the blocks inside it follow.
Height: down to the window's bottom edge instead of the layout's 800 px. Above the block are
123 px (navbar, margin, tab row) and below it 16, so the map ends 32 px above the edge, as it
does from the sides. At least 592 px: the map's own minimum height, 560, and the padding. */
.grid-row > .dragdroppable-column:has(#MARKDOWN-fleet-map) {
flex-grow: 1;
}
#MARKDOWN-fleet-map > .resizable-container {
width: 100% !important;
max-width: 100% !important;
height: max(592px, 100vh - 139px) !important;
}
/* Navbar: "+" (new) and the theme switcher. */
li.ant-menu-submenu:has(> .ant-menu-submenu-title[data-menu-id$="-new-dropdown"]),
li.ant-menu-submenu:has(> .ant-menu-submenu-title[data-menu-id$="-theme-sub-menu"]) {
display: none !important;
}
/* Settings: Info and the About group (Superset's name and version); User → Logout remains. */
li.ant-menu-item[data-menu-id$="-info"],
li.ant-menu-item-group:has([data-menu-id$="-about-info"]),
li.ant-menu-item-divider:has(+ li.ant-menu-item-group [data-menu-id$="-about-info"]) {
display: none !important;
}
/* ATOMA's top-level tabs as a lens switch in the navbar, centred: Map and Insights (owner,
29 Oct). Pinned to the window, a scroll-linked animation moves it up as fast as the navbar
scrolls away. Without that support, or narrower than 900 px, the tab row stays in the page.
With the switch, the map's block grows by the tab row's 46 px: 93 px are left above and
below it. */
@supports (animation-timeline: scroll()) {
@media (min-width: 900px) {
#TABS-main > .ant-tabs-nav {
position: fixed;
top: 9px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
margin: 0;
padding: 4px;
border-radius: 12px;
background: rgba(11, 29, 51, 0.06);
animation: petrolyst-lens-scroll linear both;
animation-timeline: scroll(root block);
animation-range: 0 61px;
}
#TABS-main > .ant-tabs-nav::before,
#TABS-main > .ant-tabs-nav .ant-tabs-ink-bar,
#TABS-main > .ant-tabs-nav .anchor-link-container {
display: none;
}
#TABS-main > .ant-tabs-nav .ant-tabs-nav-list {
gap: 4px;
}
#TABS-main > .ant-tabs-nav .ant-tabs-tab {
margin: 0 !important;
padding: 7px 18px 7px 14px !important;
border-radius: 9px !important;
font-size: 15px;
font-weight: 600;
color: #3b4a5c;
}
#TABS-main > .ant-tabs-nav .ant-tabs-tab-active {
background: #0b1d33 !important;
}
#TABS-main > .ant-tabs-nav .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #fff !important;
}
#TABS-main > .ant-tabs-nav [data-test="span-title"]::before {
content: "";
display: inline-block;
width: 18px;
height: 18px;
margin-right: 8px;
vertical-align: -3px;
background: currentColor;
-webkit-mask: var(--lens-icon) center / contain no-repeat;
mask: var(--lens-icon) center / contain no-repeat;
}
#TABS-main .ant-tabs-tab[data-node-key="TAB-fleet"] {
--lens-icon: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='black'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M9%204%203%206v14l6-2%206%202%206-2V4l-6%202-6-2z'/%3E%3Cpath%20d='M9%204v14M15%206v14'/%3E%3C/svg%3E");
}
#TABS-main .ant-tabs-tab[data-node-key="TAB-site"] {
--lens-icon: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='black'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M4%2020V10M10%2020V4M16%2020v-7M22%2020H2'/%3E%3C/svg%3E");
}
#MARKDOWN-fleet-map > .resizable-container {
height: max(592px, 100vh - 93px) !important;
}
/* A third lens, AI, as a teaser: greyed, tagged PRO, not a tab and not clickable (owner,
29 Oct; no AI lens is built until Botswerk exists). Drawn by the tab list itself, so the
dashboard's layout stays Map and Insights. From 1100 px only: narrower, the wider switch
would cover the logo. */
@media (min-width: 1100px) {
#TABS-main > .ant-tabs-nav .ant-tabs-nav-list::before {
content: "AI" / "AI, available with Petrolyst Pro";
order: 1;
display: flex;
align-items: center;
padding: 7px 58px 7px 40px;
border-radius: 9px;
font-size: 15px;
font-weight: 600;
line-height: 22px;
color: #8a96a3;
cursor: not-allowed;
background: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%238a96a3'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M9.937%2015.5A2%202%200%200%200%208.5%2014.063l-6.135-1.582a.5.5%200%200%201%200-.962L8.5%209.936A2%202%200%200%200%209.937%208.5l1.582-6.135a.5.5%200%200%201%20.963%200L14.063%208.5A2%202%200%200%200%2015.5%209.937l6.135%201.581a.5.5%200%200%201%200%20.964L15.5%2014.063a2%202%200%200%200-1.437%201.437l-1.582%206.135a.5.5%200%200%201-.963%200z'/%3E%3Cpath%20d='M20%203v4M22%205h-4'/%3E%3C/svg%3E") 14px center / 18px 18px no-repeat;
}
#TABS-main > .ant-tabs-nav .ant-tabs-nav-list::after {
content: "PRO" / "";
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
padding: 3px 7px;
border-radius: 999px;
background: #0f7b6c;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
line-height: 1;
pointer-events: none;
}
}
}
}
@keyframes petrolyst-lens-scroll {
to {
transform: translate(-50%, -61px);
}
}
+13 -25
View File
@@ -3,16 +3,11 @@
Superset dashboards as code and the operator scripts that import them. Not baked into the Superset dashboards as code and the operator scripts that import them. Not baked into the
image, so changes here do not rebuild or restart Superset. image, so changes here do not rebuild or restart Superset.
- `atoma/`: the ATOMA dashboard, with a Map tab (the fleet map) and an Insights tab (one site's - `atoma/`: ATOMA's two dashboards for the Petrolyst app (PD-12, PD-13; SPEC.md §10):
details), shown to readers as a lens switch in the navbar. `SPEC.md` is `atoma-overview` (fleet totals) and `atoma-site` (one site, its four sections as tabs). The
the design; `build_bundle.py` generates the import bundle and `import_bundle.py` imports it app embeds them; admins open them in Superset, with the filter column. `SPEC.md` is the
(overwriting the objects the bundle defines). `dashboard.css` is the dashboard's own CSS, design; `build_bundle.py` generates the import bundle and `import_bundle.py` imports it
which the bundle carries. (overwriting the objects the bundle defines).
The Fleet tab is an iframe of the fleet-map view from the petrolyst-platform repository. It
needs that view and its read service served on Superset's own origin under `/views`, and the
image's `superset_config.py` with the iframe setting (SPEC.md §6). Without them the tab stays
empty, and the Site details tab works as before.
Build and import: Build and import:
@@ -25,23 +20,16 @@ image, so changes here do not rebuild or restart Superset.
Exit status 2 means a dataset's columns differ from its view: regenerate `atoma/columns.tsv` Exit status 2 means a dataset's columns differ from its view: regenerate `atoma/columns.tsv`
(command in its header), rebuild and import again. (command in its header), rebuild and import again.
The import attaches the role "ATOMA Reader" to the dashboard (a bundle cannot carry roles), An import never deletes. Where they exist, delete these by hand after importing: the
so the role must exist first: `superset-init` creates it (`petrolyst-insights/roles/`). The dashboards from before 28 Oct ("ATOMA - Sites", "ATOMA - Site Detail") and their charts M1,
Fleet tab shows ATOMA's logo from `petrolyst-insights/assets/branding/atoma_logo.svg`, in the M3, M4 and M5; the ATOMA dashboard (slug `atoma`, retired 29 Oct with PD-13) and its chart D0
image. "Site"; the one-tab dashboards `atoma-site-summary`, `-fuel`, `-runtime` and `-baseline`
(replaced by `atoma-site` on 29 Oct); and the role "ATOMA Reader".
An import never deletes. On an instance that still has the two dashboards from before The dashboards get no role; the app shows them with guest passes (see "Embedding" in the
28 Oct 2026 ("ATOMA - Sites" and "ATOMA - Site Detail") and their charts M1, M3, M4 and M5, parent README; local stack only). Add `--embedded-out FILE --embed-domain <app origin>` to the
delete those by hand after importing.
The bundle also carries the Petrolyst app's dashboards (PD-12, SPEC.md §10): `atoma-overview`
(fleet totals) and `atoma-site` (one site, its four sections as tabs; it replaced
`atoma-site-summary`, `-fuel`, `-runtime` and `-baseline` on 29 Oct: delete those by hand
where they exist).
They get no role; the app shows them with guest passes (see "Embedding" in the parent
README; local stack only). Add `--embedded-out FILE --embed-domain <app origin>` to the
import to make them embeddable from the app's origin; FILE then receives import to make them embeddable from the app's origin; FILE then receives
`{slug: embedding id}` for the app service. The ids stay the same across imports. Local `{slug: embedding id}` for the Petrolyst server. The ids stay the same across imports. Local
stack: stack:
docker exec fmdb-scratch-superset /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \ docker exec fmdb-scratch-superset /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \
+36 -26
View File
@@ -1,13 +1,17 @@
# ATOMA dashboard: Fleet map and Site details # ATOMA dashboards: Overview and Site, for the Petrolyst app
**Status: built and tested on a local Superset (P2, 24 Oct 2026); merged into one dashboard with **Status (29 Oct 2026, PD-13): two dashboards, `atoma-overview` and `atoma-site` (§10), built and
the fleet-map view on 28 Oct 2026 (§9); not imported live.** tested on a local Superset; not imported live.** The Petrolyst app embeds them, and admins open
- `build_bundle.py` generates the dashboard, every dataset and every chart from this them in Superset with the filter column.
- `build_bundle.py` generates the dashboards, every dataset and every chart from this
specification, as one Superset import bundle; `import_bundle.py` imports it (§6). specification, as one Superset import bundle; `import_bundle.py` imports it (§6).
- The local tests changed parts of this design. Each change is marked *(P2)* and §8 lists them. - **Retired 29 Oct (PD-13):** the "ATOMA" dashboard (slug `atoma`) with its Map tab (the React
- The merge of 28 Oct (owner's decisions PD-5 and PD-6, `Petrolist/.context/design-decisions.md`) fleet-map view of petrolyst-platform) and Insights tab, its D0 header row, and the Superset
replaced the Sites Map dashboard by a Fleet tab holding the fleet-map view of petrolyst-platform. reader setup ("ATOMA Reader", `reader.css`). ATOMA's users use the Petrolyst app. §2, §3, §9 and
Changed parts are marked *(28 Oct)*; §9 lists the results. the readers' part of §6 describe them and stay as history. §4's data rules and four tabs are
current: they are `atoma-site`'s tabs, without D0 (the app shows the site's header).
- Earlier changes are marked *(P2)* (local tests of 24 Oct, §8) and *(28 Oct)* (one dashboard
with the fleet-map view, §9).
- Nothing is imported into the live Superset without the owner's go-ahead (phase P3). - Nothing is imported into the live Superset without the owner's go-ahead (phase P3).
**Datasets.** The `public.v_*` views of **Datasets.** The `public.v_*` views of
@@ -20,8 +24,7 @@ the fleet-map view on 28 Oct 2026 (§9); not imported live.**
- the site-specific facts left out here (positions, distances, pilot sites) are there too; - the site-specific facts left out here (positions, distances, pilot sites) are there too;
- Q-numbers refer to its §7, and phases P0–P5 to its §8. - Q-numbers refer to its §7, and phases P0–P5 to its §8.
One dashboard (two until 28 Oct) replaces the earlier tab-per-site design; nothing in it is Nothing in the dashboards is specific to a site.
specific to a site.
## 1. Capabilities of the installed Superset 6.1.0 ## 1. Capabilities of the installed Superset 6.1.0
@@ -53,7 +56,7 @@ still holds: the view builds the same `?native_filters=` link.
No custom frontend and no patch are needed. No custom frontend and no patch are needed.
## 2. Navigation flow ## 2. Navigation flow *(retired, PD-13: history)*
*(28 Oct)* One dashboard, two top-level tabs. *(29 Oct)* They are named **Map** (`TAB-fleet`) and *(28 Oct)* One dashboard, two top-level tabs. *(29 Oct)* They are named **Map** (`TAB-fleet`) and
**Insights** (`TAB-site`), the dashboard's lenses; earlier text in this document calls them Fleet and **Insights** (`TAB-site`), the dashboard's lenses; earlier text in this document calls them Fleet and
@@ -93,7 +96,7 @@ Until 28 Oct there were two dashboards: "ATOMA - Sites" (slug `atoma-sites`: a d
filters, a sites list) and "ATOMA - Site Detail" (slug `atoma-site-detail`), linked by "Open site filters, a sites list) and "ATOMA - Site Detail" (slug `atoma-site-detail`), linked by "Open site
detail" and "← Sites map". detail" and "← Sites map".
## 3. Fleet tab: the fleet-map view *(28 Oct)* ## 3. Fleet tab: the fleet-map view *(28 Oct; retired, PD-13: history)*
The Fleet tab holds one Markdown block with an iframe of the fleet-map view, a page of The Fleet tab holds one Markdown block with an iframe of the fleet-map view, a page of
petrolyst-platform (`views/src/fleet-map/`) served under `/views/` on Superset's own origin. The petrolyst-platform (`views/src/fleet-map/`) served under `/views/` on Superset's own origin. The
@@ -135,7 +138,10 @@ thousands of sites stay fast.
**Sites without an accepted location** are not plotted. The key counts them, and the Site filter **Sites without an accepted location** are not plotted. The key counts them, and the Site filter
in Site details lists every site, placed or not. in Site details lists every site, placed or not.
## 4. Site details tab (any site) ## 4. Site details: the four tabs of `atoma-site` (any site)
*(PD-13)* What follows about D0 (the site header row) and the Fleet tab is history: the app shows
the site's header, and its Map lens replaced the Fleet tab.
**Filters** **Filters**
- **Site** (`NATIVE_FILTER-site`): - **Site** (`NATIVE_FILTER-site`):
@@ -248,10 +254,12 @@ is grouped or filtered by `metric` and `population_key` (both in `v_interval`),
across methods or generator sets. The site's L/h figure itself comes only from a baseline version across methods or generator sets. The site's L/h figure itself comes only from a baseline version
(`v_site`), never from this metric. There is no AVG metric on ratio columns. (`v_site`), never from this metric. There is no AVG metric on ratio columns.
**Site details link** *(28 Oct)*. The fleet-map view builds it (petrolyst-platform **Site details link** *(28 Oct; history since PD-13)*. The fleet-map view built it
`views/src/shared/deepLink.ts`) from its iframe address: the dashboard path, the Site filter's id (petrolyst-platform `views/src/shared/deepLink.ts`, retired) from its iframe address: the
and the tab. The rison shape is the one the sites list used until 28 Oct, confirmed in P2, and the dashboard path, the Site filter's id and the tab. The Petrolyst app uses the same rison shape for
view's tests compare it with that list's Trino output: the Site filter's preset (`app/src/app/core/site-filter.ts`). The rison shape is the one the sites
list used until 28 Oct, confirmed in P2, and the view's tests compared it with that list's Trino
output:
``` ```
/superset/dashboard/atoma/?native_filters=<url-encoded:> /superset/dashboard/atoma/?native_filters=<url-encoded:>
@@ -312,7 +320,8 @@ appears on every Superset page, including the stock dashboard list: a capability
same sign-in *(28 Oct: not built yet; locally it has none)*. No Gamma role until the same sign-in *(28 Oct: not built yet; locally it has none)*. No Gamma role until the
ATOMA-facing edit. ATOMA-facing edit.
**ATOMA's readers** *(owner, 29 Oct)*. The Superset role **"ATOMA Reader"** (Keycloak client role **ATOMA's readers** *(owner, 29 Oct; retired the same day, PD-13: readers use the Petrolyst
app; kept as history)*. The Superset role **"ATOMA Reader"** (Keycloak client role
`atoma-reader`) reads the ATOMA dashboard and nothing else. `atoma-reader`) reads the ATOMA dashboard and nothing else.
- **The role** (`petrolyst-insights/roles/atoma_reader.py`): exactly 12 permissions, set by - **The role** (`petrolyst-insights/roles/atoma_reader.py`): exactly 12 permissions, set by
`superset-init` whenever the image changes, and reset by hand with the command in the README. `superset-init` whenever the image changes, and reset by hand with the command in the README.
@@ -357,6 +366,9 @@ ATOMA-facing edit.
## 7. Acceptance checks ## 7. Acceptance checks
*(PD-13)* Written for the retired ATOMA dashboard. Where they name D0 or the fleet map, the app's
site header and Map lens take their place (petrolyst-platform's browser tests).
Run in P2 against a local Superset, and repeated as a smoke test in P3. Run in P2 against a local Superset, and repeated as a smoke test in P3.
1. **New site, no new objects.** Count dashboards, charts and datasets in the metadata database. 1. **New site, no new objects.** Count dashboards, charts and datasets in the metadata database.
@@ -425,7 +437,7 @@ The marker palette is unchanged from §3. The dataviz palette check flags slate
grey, which is intended for "insufficient evidence", and amber at 2.99:1 contrast. The label is grey, which is intended for "insufficient evidence", and amber at 2.99:1 contrast. The label is
always also shown as text, in the legend, the tooltip and M3. always also shown as text, in the legend, the tooltip and M3.
## 9. Fleet map results (local, 28 Oct 2026) ## 9. Fleet map results (local, 28 Oct 2026) *(history)*
Local Superset 6.1.0 rebuilt with the iframe setting, behind a local Traefik that serves Superset Local Superset 6.1.0 rebuilt with the iframe setting, behind a local Traefik that serves Superset
and the petrolyst-platform read service on one origin (as on the server); the fuel-db of P2. and the petrolyst-platform read service on one origin (as on the server); the fuel-db of P2.
@@ -547,11 +559,11 @@ These changes have not had an independent inspection (the owner skipped Codex).
unchanged), sign-in through Keycloak, the server's routing of `/views`, sign-in for the read unchanged), sign-in through Keycloak, the server's routing of `/views`, sign-in for the read
service, thousands of sites, and dark mode. service, thousands of sites, and dark mode.
## 10. The Petrolyst app's dashboards *(PD-12, local trial, 29 Oct 2026)* ## 10. The Petrolyst app's dashboards *(PD-12, PD-13; local, 29 Oct 2026)*
The Petrolyst app (petrolyst-platform `app/`, PD-12) shows Superset dashboards inside its The Petrolyst app (petrolyst-platform `app/`, PD-12) shows Superset dashboards inside its
Insights lens, with guest passes from its app service. `build_bundle.py` builds them beside the Insights lens, with guest passes from the Petrolyst server (petrolyst-platform `server/`).
unchanged ATOMA dashboard: `build_bundle.py` builds them (since PD-13 the only dashboards in the bundle):
| Slug | Content | Filters (set by the app; Superset's filter column is hidden) | | Slug | Content | Filters (set by the app; Superset's filter column is hidden) |
|---|---|---| |---|---|---|
@@ -569,11 +581,9 @@ unchanged ATOMA dashboard:
recorded values across sites (`OVERVIEW_METRICS`); per-site rates (L/h, L/day) are never recorded values across sites (`OVERVIEW_METRICS`); per-site rates (L/h, L/day) are never
averaged; money is grouped by `currency_text`, a missing currency is its own row, and no averaged; money is grouped by `currency_text`, a missing currency is its own row, and no
total crosses currencies. `build_bundle.py` checks both. total crosses currencies. `build_bundle.py` checks both.
- They carry no CSS; `dashboard.css` and `reader.css` are checked against the ATOMA dashboard - They carry no CSS.
only.
- `import_bundle.py --embedded-out FILE --embed-domain URL` makes them embeddable from the app's - `import_bundle.py --embedded-out FILE --embed-domain URL` makes them embeddable from the app's
origin and writes `{slug: embedding id}` for the app service; the reader role stays on the origin and writes `{slug: embedding id}` for the Petrolyst server.
ATOMA dashboard only.
- Embedding is switched on in the local stack only (`.local/fmdb-scratch`, - Embedding is switched on in the local stack only (`.local/fmdb-scratch`,
`superset_config_local.py`); production keeps it off until PD-12's open points are settled. `superset_config_local.py`); production keeps it off until PD-12's open points are settled.
- **Checked (local):** a self-signed guest pass opens its own dashboard and nothing else (another - **Checked (local):** a self-signed guest pass opens its own dashboard and nothing else (another
@@ -1,10 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate the ATOMA dashboard as one Superset 6.1 import bundle. """Generate ATOMA's dashboards for the Petrolyst app as one Superset 6.1 import bundle.
Implements SPEC.md in this folder: the "ATOMA" dashboard with a Map tab (the Implements SPEC.md in this folder: the two dashboards the Petrolyst app embeds, "ATOMA · Overview"
fleet-map view of petrolyst-platform, embedded as an iframe) and an Insights tab, (atoma-overview) and "ATOMA · Site" (atoma-site, the four detail tabs), one dataset per
one dataset per public.v_* view and every chart. Nothing in the bundle names public.v_* view and every chart. Nothing in the bundle names a site.
a site.
build_bundle.py --database-uuid <uuid of "Fuel (Trino)"> [--out atoma-dashboards.zip] build_bundle.py --database-uuid <uuid of "Fuel (Trino)"> [--out atoma-dashboards.zip]
@@ -17,9 +16,8 @@ unchanged).
Before writing, the bundle is checked: every column a chart or filter uses 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 chart in a Site filter's scope has site_label, the
fleet map's address names this dashboard, its Site filter and the Insights tab, Overview sums recorded values only and money only per currency, and no row is
every layout id that dashboard.css or reader.css names exists, and no row is wider wider than 12. Requires PyYAML.
than 12. Requires PyYAML.
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,36 +41,11 @@ DATABASE_NAME = "Fuel (Trino)"
DATABASE_URI = "trino://superset@trino:8080/fuel" # as superset-init registers it DATABASE_URI = "trino://superset@trino:8080/fuel" # as superset-init registers it
CATALOG, SCHEMA = "fuel", "public" CATALOG, SCHEMA = "fuel", "public"
SLUG = "atoma"
SITE_FILTER = "NATIVE_FILTER-site" SITE_FILTER = "NATIVE_FILTER-site"
PERIOD_FILTER = "NATIVE_FILTER-period" PERIOD_FILTER = "NATIVE_FILTER-period"
FLEET_TAB = "TAB-fleet"
SITE_TAB = "TAB-site"
# The tabs' names: the lenses Map and Insights (owner, 29 Oct); readers see them as a switch
# in the navbar (assets/branding/reader.css).
FLEET_TAB_TITLE = "Map"
SITE_TAB_TITLE = "Insights"
# SPEC §3: the Map tab is the fleet-map view of petrolyst-platform, served on the same
# origin under /views (superset_config.py lets the iframe through the Markdown sanitiser).
# Its parameters name where "Site details" leads: this dashboard, its Site filter and the
# Insights tab, with that tab's name for the map's key. The view builds the native_filters
# link from them.
# The operator's logo in the map's bottom right corner: a file in assets/branding/, served by
# Superset under /static/assets/branding/ (same origin, so the view's CSP allows it).
OPERATOR_LOGO = "atoma_logo.svg"
# The dashboard's own CSS (the Insights tab's inner tabs as section buttons), and the
# readers' stylesheet, which names this layout's ids too (the lens switch, the map's size).
DASHBOARD_CSS = HERE / "dashboard.css"
READER_CSS = HERE.parent.parent / "assets" / "branding" / "reader.css"
FLEET_MAP_SRC = (f"/views/fleet-map/?detail=/superset/dashboard/{SLUG}/"
f"&amp;filter={SITE_FILTER}&amp;tab={SITE_TAB}&amp;tab_label={SITE_TAB_TITLE}"
f"&amp;logo=/static/assets/branding/{OPERATOR_LOGO}&amp;logo_alt=ATOMA")
FLEET_MAP = (f'<iframe src="{FLEET_MAP_SRC}" title="Fleet map" loading="lazy" '
'style="display:block;width:100%;height:100%;border:0"></iframe>')
# SPEC §3: neutral hues, no red or green, keyed by v_site.baseline_status_label. The # SPEC §3: neutral hues, no red or green, keyed by v_site.baseline_status_label. The
# fleet-map view uses the same colours (petrolyst-platform views/src/shared/status.ts). # Petrolyst app's map uses the same colours (petrolyst-platform app/src/app/core/status.ts).
STATUS_COLOURS = { STATUS_COLOURS = {
"Approved baseline": "#009688", "Approved baseline": "#009688",
"Eligible - not yet approved": "#3987e5", "Eligible - not yet approved": "#3987e5",
@@ -96,8 +69,8 @@ MAIN_DTTM = {
"v_electricity_cost": "bill_month", "v_electricity_cost": "bill_month",
} }
# Calculated columns: (name, Trino SQL, type, label). The fleet-map view's read service # Calculated columns: (name, Trino SQL, type, label). The Petrolyst server's site_status
# (petrolyst-platform services/read, site_status) shows the same two texts. # (petrolyst-platform server/) shows the same two texts.
CALCULATED = { CALCULATED = {
"v_site": [ "v_site": [
("lph_text", "COALESCE(CAST(lph_estimate AS varchar), 'not available')", "VARCHAR", "L/h"), ("lph_text", "COALESCE(CAST(lph_estimate AS varchar), 'not available')", "VARCHAR", "L/h"),
@@ -588,9 +561,6 @@ def detail_charts() -> list[Chart]:
return [ return [
# Handlebars would render these as cards, but it compiles templates with # Handlebars would render these as cards, but it compiles templates with
# eval, which Superset's own CSP blocks. One-row tables need no CSP change. # eval, which Superset's own CSP blocks. One-row tables need no CSP change.
table("D0", "Site", "v_site", [
"site_code", "site_name", "province", "pilot_status", "baseline_status_text",
"location_status"]),
table("S1", "Fuel per running hour", "v_site", [ table("S1", "Fuel per running hour", "v_site", [
"lph_text", "lph_method", "lph_window_start", "lph_window_end", "baseline_status_text", "lph_text", "lph_method", "lph_window_start", "lph_window_end", "baseline_status_text",
"baseline_validity_note", "baseline_decision_ref", "baseline_conditions", "baseline_validity_note", "baseline_decision_ref", "baseline_conditions",
@@ -799,7 +769,6 @@ TAB_NOTES = {
"the map or the sites list. The Viewing period does not apply to this tab.", "the map or the sites list. The Viewing period does not apply to this tab.",
} }
DETAIL_HEADER = [("D0", 12, 24)]
DETAIL_TABS = [ DETAIL_TABS = [
("summary", "Summary", [ ("summary", "Summary", [
[("MARKDOWN:notes-summary", 12, 17)], [("MARKDOWN:notes-summary", 12, 17)],
@@ -982,8 +951,8 @@ def base_metadata(layout: Layout, filters: list[dict[str, Any]], cross_filters:
"map_label_colors": {}, "map_label_colors": {},
"color_scheme_domain": [], "color_scheme_domain": [],
"cross_filters_enabled": cross_filters, "cross_filters_enabled": cross_filters,
# Filters in Superset's left column (owner, 29 Oct). Readers don't see it on the Fleet # Filters in Superset's left column (owner, 29 Oct); the Petrolyst app hides the column
# tab (assets/branding/reader.css), so their map gets the full width. # when it embeds a dashboard and sets the filters itself.
"filter_bar_orientation": "VERTICAL", "filter_bar_orientation": "VERTICAL",
"native_filter_configuration": filters, "native_filter_configuration": filters,
"chart_configuration": {}, "chart_configuration": {},
@@ -1021,30 +990,12 @@ def build(database_uuid: str, columns_path: Path) -> dict[str, Any]:
for view in sorted(views): for view in sorted(views):
files[f"datasets/Fuel_Trino/{view}.yaml"] = dataset_yaml(view, views[view], database_uuid) files[f"datasets/Fuel_Trino/{view}.yaml"] = dataset_yaml(view, views[view], database_uuid)
charts = {c.key: c for c in detail_charts()}
# SPEC §2: [Map] [Insights]; the site header and the four detail tabs live in
# Insights, so the Map tab shows the map only.
layout = Layout("ATOMA", charts)
layout.markdown["fleet-map"] = FLEET_MAP
layout.markdown.update({f"notes-{slug}": text for slug, text in TAB_NOTES.items()})
main_tabs = layout.add("TABS-main", "TABS", "GRID_ID", {})
layout.rows([[("MARKDOWN:fleet-map", 12, 100)]], layout.tab(FLEET_TAB, FLEET_TAB_TITLE, main_tabs), "fleet")
site_tab = layout.tab(SITE_TAB, SITE_TAB_TITLE, main_tabs)
layout.rows([DETAIL_HEADER], site_tab, "header")
layout.tabs(DETAIL_TABS, site_tab, "TABS-detail")
metadata = base_metadata(layout, detail_filters(layout.ids), cross_filters=False)
metadata["label_colors"].update(STOCK_STATUS_COLOURS)
files["dashboards/ATOMA.yaml"] = dashboard_yaml("atoma", "ATOMA", SLUG, layout, metadata,
DASHBOARD_CSS.read_text())
dashboards = [(layout, charts, metadata, (DASHBOARD_CSS, READER_CSS))]
# PD-12: the Petrolyst app embeds the Overview and one site dashboard, whose four detail # PD-12: the Petrolyst app embeds the Overview and one site dashboard, whose four detail
# tabs are Superset's own (owner, 29 Oct). The app shows the site header and chooses site # tabs are Superset's own (owner, 29 Oct). The app shows the site header and chooses site
# and period, so these have no D0 and no CSS. # and period. Admins open the same dashboards in Superset, with the filter column.
overview = {c.key: c for c in overview_charts()} overview = {c.key: c for c in overview_charts()}
all_charts = {**charts, **overview} 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")]: for key, title in [(OVERVIEW_SLUG, "Overview"), (SITE_SLUG, "Site")]:
page = Layout(f"ATOMA · {title}", all_charts) page = Layout(f"ATOMA · {title}", all_charts)
page.markdown.update({f"notes-{k}": v for k, v in TAB_NOTES.items()}) page.markdown.update({f"notes-{k}": v for k, v in TAB_NOTES.items()})
@@ -1061,7 +1012,7 @@ def build(database_uuid: str, columns_path: Path) -> dict[str, Any]:
page_metadata["label_colors"].update(STOCK_STATUS_COLOURS) 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, f"ATOMA · {title}", key, page,
page_metadata) page_metadata)
dashboards.append((page, placed, page_metadata, ())) dashboards.append((page, placed, page_metadata))
for chart in all_charts.values(): for chart in all_charts.values():
name = re.sub(r"\W+", "_", f"{chart.key}_{chart.title}").strip("_") name = re.sub(r"\W+", "_", f"{chart.key}_{chart.title}").strip("_")
@@ -1072,7 +1023,7 @@ def build(database_uuid: str, columns_path: Path) -> dict[str, Any]:
def check(files: dict[str, Any], views: dict[str, list[tuple[str, str]]], def check(files: dict[str, Any], views: dict[str, list[tuple[str, str]]],
dashboards: list[tuple[Layout, dict[str, Chart], dict[str, Any], tuple[Path, ...]]], dashboards: list[tuple[Layout, dict[str, Chart], dict[str, Any]]],
overview: dict[str, Chart]) -> None: overview: dict[str, Chart]) -> None:
problems = [] problems = []
columns = {v: {c for c, _ in cols} | {c[0] for c in CALCULATED.get(v, [])} columns = {v: {c for c, _ in cols} | {c[0] for c in CALCULATED.get(v, [])}
@@ -1083,11 +1034,7 @@ def check(files: dict[str, Any], views: dict[str, list[tuple[str, str]]],
for view, col in MAIN_DTTM.items(): for view, col in MAIN_DTTM.items():
if col not in columns.get(view, set()): if col not in columns.get(view, set()):
problems.append(f"{view}: main time column {col} missing") problems.append(f"{view}: main time column {col} missing")
for layout, charts, metadata, stylesheets in dashboards: for layout, charts, metadata in dashboards:
for css in stylesheets:
for ref in sorted(set(re.findall(r"#([A-Z]+-[\w-]+)", css.read_text()))):
if ref not in layout.nodes:
problems.append(f"{css.name}: #{ref} is not in the layout")
placed = set(layout.ids) placed = set(layout.ids)
if placed != set(charts): if placed != set(charts):
problems.append(f"charts not placed or unknown: {sorted(placed ^ set(charts))}") problems.append(f"charts not placed or unknown: {sorted(placed ^ set(charts))}")
@@ -1104,26 +1051,6 @@ def check(files: dict[str, Any], views: dict[str, list[tuple[str, str]]],
for key in in_scope: for key in in_scope:
if col not in columns.get(charts[key].view, set()): if col not in columns.get(charts[key].view, set()):
problems.append(f"{f['id']}: {key} ({charts[key].view}) lacks {col}") problems.append(f"{f['id']}: {key} ({charts[key].view}) lacks {col}")
# The fleet map's "Site details" link is built from its iframe address.
fleet = layout.nodes.get("MARKDOWN-fleet-map")
if fleet is not None:
filter_ids = {f["id"] for f in metadata["native_filter_configuration"]}
for needle, what in [(f"detail=/superset/dashboard/{SLUG}/", "this dashboard"),
(f"filter={SITE_FILTER}", "the Site filter"),
(f"tab={SITE_TAB}", "the Insights tab"),
(f"tab_label={SITE_TAB_TITLE}", "the Insights tab's name")]:
if needle not in fleet["meta"]["code"]:
problems.append(f"fleet map: its address does not name {what}")
if SITE_FILTER not in filter_ids or SITE_TAB not in layout.nodes:
problems.append("fleet map: the Site filter or the Insights tab is missing")
if not (HERE.parent.parent / "assets" / "branding" / OPERATOR_LOGO).is_file():
problems.append(f"fleet map: assets/branding/{OPERATOR_LOGO} is missing")
if metadata.get("filter_bar_orientation") != "VERTICAL":
problems.append("fleet map: the filters must sit in the left column")
if f'data-node-key="{FLEET_TAB}"' not in READER_CSS.read_text():
problems.append(f"fleet map: reader.css does not hide the filters on {FLEET_TAB}")
if fleet["meta"]["width"] != 12:
problems.append("fleet map: reader.css stretches it, so it must span all 12 columns")
# SPEC §10: the Overview sums recorded values only, and money only per currency. # SPEC §10: the Overview sums recorded values only, and money only per currency.
for chart in overview.values(): for chart in overview.values():
metrics = chart.params.get("metrics") or [chart.params["metric"]] metrics = chart.params.get("metrics") or [chart.params["metric"]]
@@ -1,33 +0,0 @@
/* The ATOMA dashboard's own CSS, for everyone who opens it (build_bundle.py puts it into the
bundle and checks that every #id here is in the layout).
Site details' inner tabs (TABS-detail) as a row of section buttons, so they read as a level
below Fleet | Site details (owner, 29 Oct). Aligned with the content's left edge.
Translucent colours: right in the light and the dark theme. */
#TABS-detail > .ant-tabs-nav {
margin: 0 16px 16px;
}
#TABS-detail > .ant-tabs-nav::before {
border-bottom: 0;
}
#TABS-detail > .ant-tabs-nav .ant-tabs-ink-bar {
display: none;
}
#TABS-detail > .ant-tabs-nav .ant-tabs-nav-list {
gap: 4px;
padding: 4px;
border-radius: 10px;
background: rgba(127, 127, 127, 0.1);
}
#TABS-detail > .ant-tabs-nav .ant-tabs-tab {
margin: 0 !important;
padding: 6px 14px !important;
border-radius: 8px !important;
font-size: 13px;
}
#TABS-detail > .ant-tabs-nav .ant-tabs-tab-active {
background: rgba(0, 150, 136, 0.14) !important;
}
#TABS-detail > .ant-tabs-nav .ant-tabs-tab-active .ant-tabs-tab-btn {
font-weight: 600;
}
@@ -1,4 +1,4 @@
"""Import the ATOMA dashboard bundle into Superset, overwriting what it defines. """Import the ATOMA dashboards bundle into Superset, overwriting what it defines.
Runs inside the Superset container, with the image's Python: Runs inside the Superset container, with the image's Python:
@@ -17,15 +17,10 @@ live view and any drift is reported (exit status 2).
The database is never created or changed: the bundle's database uuid must The database is never created or changed: the bundle's database uuid must
already exist under the name "Fuel (Trino)", otherwise nothing is imported. already exist under the name "Fuel (Trino)", otherwise nothing is imported.
A bundle cannot carry a dashboard's roles, so afterwards the role "ATOMA Reader" The dashboards (atoma-overview, atoma-site) get no role: admins see them in Superset, and
is attached to the ATOMA dashboard: with DASHBOARD_RBAC that is how ATOMA's the Petrolyst app shows them with guest passes (PD-12). With --embedded-out FILE
readers see it. The role comes from roles/atoma_reader.py (run by superset-init); --embed-domain URL, each of them is made embeddable from that origin (its embedding id stays
without it nothing is imported. the same across imports), and FILE receives {slug: embedding id} for the Petrolyst server:
The Petrolyst app's dashboards (PD-12: atoma-overview, atoma-site) get no role:
the app shows them with guest passes. 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 app service:
docker exec <container> /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \ docker exec <container> /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \
--user <username> --embedded-out /tmp/embedded-dashboards.json --embed-domain http://127.0.0.1:8098 --user <username> --embedded-out /tmp/embedded-dashboards.json --embed-domain http://127.0.0.1:8098
@@ -41,14 +36,12 @@ import zipfile
import yaml import yaml
DATABASE_NAME = "Fuel (Trino)" DATABASE_NAME = "Fuel (Trino)"
READER_ROLE = "ATOMA Reader"
READER_SLUG = "atoma" # the readers' dashboard; the others are the app's (PD-12)
EMBEDDED_PREFIX = "atoma-" EMBEDDED_PREFIX = "atoma-"
STEPS = [("datasets", "SqlaTable"), ("charts", "Slice"), ("dashboards", "Dashboard")] STEPS = [("datasets", "SqlaTable"), ("charts", "Slice"), ("dashboards", "Dashboard")]
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Import the ATOMA dashboard bundle.") parser = argparse.ArgumentParser(description="Import the ATOMA dashboards bundle.")
parser.add_argument("bundle", nargs="?") parser.add_argument("bundle", nargs="?")
parser.add_argument("--user", help="existing Superset user who will own new objects") parser.add_argument("--user", help="existing Superset user who will own new objects")
parser.add_argument("--show-database", action="store_true", parser.add_argument("--show-database", action="store_true",
@@ -85,10 +78,6 @@ def main() -> int:
if user is None: if user is None:
print(f"no Superset user {args.user!r}", file=sys.stderr) print(f"no Superset user {args.user!r}", file=sys.stderr)
return 1 return 1
reader_role = security_manager.find_role(READER_ROLE)
if reader_role is None:
print(f'no role "{READER_ROLE}": run roles/atoma_reader.py first', file=sys.stderr)
return 1
with zipfile.ZipFile(args.bundle) as bundle: with zipfile.ZipFile(args.bundle) as bundle:
contents = get_contents_from_bundle(bundle) contents = get_contents_from_bundle(bundle)
databases = [yaml.safe_load(t) for p, t in contents.items() if p.startswith("databases/")] databases = [yaml.safe_load(t) for p, t in contents.items() if p.startswith("databases/")]
@@ -115,17 +104,13 @@ def main() -> int:
if path.startswith("dashboards/"): if path.startswith("dashboards/"):
dashboard = db.session.query(Dashboard).filter_by( dashboard = db.session.query(Dashboard).filter_by(
uuid=yaml.safe_load(text)["uuid"]).one() uuid=yaml.safe_load(text)["uuid"]).one()
if dashboard.slug == READER_SLUG: if dashboard.slug.startswith(EMBEDDED_PREFIX) and args.embedded_out:
if reader_role not in dashboard.roles:
dashboard.roles.append(reader_role)
elif dashboard.slug.startswith(EMBEDDED_PREFIX) and args.embedded_out:
from superset.daos.dashboard import EmbeddedDashboardDAO from superset.daos.dashboard import EmbeddedDashboardDAO
config = EmbeddedDashboardDAO.upsert(dashboard, [args.embed_domain]) config = EmbeddedDashboardDAO.upsert(dashboard, [args.embed_domain])
db.session.flush() db.session.flush()
embedded[dashboard.slug] = str(config.uuid) embedded[dashboard.slug] = str(config.uuid)
db.session.commit() db.session.commit()
print(f'dashboards: "{READER_SLUG}" readable for "{READER_ROLE}"')
if args.embedded_out: if args.embedded_out:
with open(args.embedded_out, "w") as out: with open(args.embedded_out, "w") as out:
json.dump(dict(sorted(embedded.items())), out, indent=2) json.dump(dict(sorted(embedded.items())), out, indent=2)
+1 -4
View File
@@ -1,10 +1,7 @@
# patches/ # patches/
Runtime patches applied while the Superset image is built Runtime patches applied while the Superset image is built
(`stacks/fuel-monitoring/superset/Dockerfile`): (`stacks/fuel-monitoring/superset/Dockerfile`). Empty until Petrolyst needs one.
- `fix_redact_query_for_readers.py`: chart-data responses carry no SQL for readers, as Superset
already does for embedded guests (`petrolyst_reader.py`).
Same convention as media-insights: Same convention as media-insights:
@@ -1,54 +0,0 @@
"""Readers' chart-data responses carry no SQL.
Superset 6.1.0 drops each query's SQL from chart-data JSON for embedded guest users only
(superset/charts/data/api.py, ChartDataRestApi._send_chart_response). This extends that
condition with the security manager's redacts_query(), when it has one: the Petrolyst managers
return True for readers (petrolyst_reader.ReaderAccessMixin). It runs when the response is
written, so cached results and result_type=query are covered too.
python3 fix_redact_query_for_readers.py apply (idempotent)
python3 fix_redact_query_for_readers.py --verify exit 1 unless applied
"""
import sys
from pathlib import Path
TARGET = Path("/app/superset/charts/data/api.py")
BEFORE = """\
if security_manager.is_guest_user():
for query in queries:
query.pop("query", None)
"""
AFTER = """\
# Petrolyst (patches/fix_redact_query_for_readers.py): readers too.
if security_manager.is_guest_user() or getattr(
security_manager, "redacts_query", lambda: False
)():
for query in queries:
query.pop("query", None)
"""
def main(argv: list[str]) -> int:
text = TARGET.read_text()
if "--verify" in argv:
compile(text, str(TARGET), "exec")
if text.count(AFTER) == 1 and BEFORE not in text:
print(f"{TARGET}: readers' SQL redaction in place")
return 0
print(f"{TARGET}: readers' SQL redaction missing", file=sys.stderr)
return 1
if text.count(AFTER) == 1:
print(f"{TARGET}: already patched")
return 0
if text.count(BEFORE) != 1:
print(f"{TARGET}: the guest-only redaction was not found once; Superset changed?",
file=sys.stderr)
return 1
TARGET.write_text(text.replace(BEFORE, AFTER))
print(f"{TARGET}: patched")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-150
View File
@@ -1,150 +0,0 @@
"""Readers: ATOMA's users, who only read the ATOMA dashboard.
A reader is a signed-in user whose roles are "ATOMA Reader" and nothing more ("Public" aside,
which carries no permissions here). A user who also has Admin, Alpha or Gamma is not a reader.
The role itself (roles/atoma_reader.py) grants no menus and nothing to write; this module adds
what a role cannot express. Checked against apache/superset:6.1.0.
- Superset lets an owner past the role check, and anyone onto a dashboard that has neither
roles nor datasets. ReaderAccessMixin lets readers reach a dashboard only through its roles
(DASHBOARD_RBAC), and the dashboard list shows them only those.
- Chart-data responses carry each query's SQL for everyone but embedded guests. Readers get none:
ReaderAccessMixin.redacts_query, read by patches/fix_redact_query_for_readers.py.
- Readers land on the ATOMA dashboard instead of Superset's home page, and any other page they
open (a chart or dashboard list, SQL Lab, Explore, user info) leads there too. Every page
loads reader.css for them (templates/head_custom_extra.html). It hides what Superset shows
whatever the permissions: the dashboard's title row, the theme switcher, Info and About in
Settings; and the filter column on ATOMA's Fleet tab. Its address names its content, since
browsers keep Superset's static files for a year.
superset_config.py sets FLASK_APP_MUTATOR = register; both security managers (Keycloak in
petrolyst_security.py, database login on the local stack) take ReaderAccessMixin.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
from flask import Flask, g, has_request_context, redirect, request
from flask_login import current_user
from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.wrappers import Response as WerkzeugResponse
READER_ROLE = "ATOMA Reader"
# Roles a reader may have besides READER_ROLE without stopping being one.
NEUTRAL_ROLES = {"Public"}
LANDING = "/superset/dashboard/atoma/"
# The only pages a reader opens; any other page leads to LANDING. Requests that are not page
# loads (the API, static files) keep their own permission checks.
READER_PAGES = ("/superset/dashboard/", "/login/", "/logout/")
TEMPLATES = Path(__file__).with_name("templates")
def _signed_in_user() -> Any:
user = getattr(g, "user", None)
if user is None and has_request_context():
user = current_user
return user
def is_reader(user: Any = None) -> bool:
"""True for a signed-in user whose roles are READER_ROLE and nothing that grants more."""
user = user if user is not None else _signed_in_user()
if user is None or not getattr(user, "is_authenticated", False):
return False
names = {role.name for role in getattr(user, "roles", None) or []}
return READER_ROLE in names and names <= {READER_ROLE, *NEUTRAL_ROLES}
class ReaderAccessMixin:
"""Reader rules for a SupersetSecurityManager subclass; list it before that base."""
def raise_for_ownership(self, resource: Any) -> None:
# is_owner() goes through here: a reader owns nothing, so no owner path to a
# dashboard, chart or dataset remains (also for a user who owned things before).
if is_reader():
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
message=f"A reader does not own {resource}",
level=ErrorLevel.ERROR,
)
)
super().raise_for_ownership(resource) # type: ignore[misc]
def raise_for_access(self, *args: Any, **kwargs: Any) -> None:
# The dashboard page, its API, permalinks and chart data (via can_access_dashboard)
# all check a dashboard here.
dashboard = kwargs.get("dashboard", args[0] if args else None)
if dashboard is not None and is_reader() and not self._reader_may_open(dashboard):
from superset.exceptions import SupersetSecurityException
raise SupersetSecurityException(
self.get_dashboard_access_error_object(dashboard) # type: ignore[attr-defined]
)
super().raise_for_access(*args, **kwargs) # type: ignore[misc]
def _reader_may_open(self, dashboard: Any) -> bool:
"""Only DASHBOARD_RBAC: published, and one of its roles is the reader's."""
mine = {role.id for role in self.get_user_roles()} # type: ignore[attr-defined]
return bool(dashboard.published) and bool(mine & {role.id for role in dashboard.roles})
def redacts_query(self) -> bool:
"""Whether chart-data responses leave out the SQL (embedded guests aside)."""
return is_reader()
def _land_readers() -> WerkzeugResponse | None:
if request.method != "GET" or request.path.startswith(READER_PAGES) or not is_reader():
return None
# A page load in a browser, or Superset's home page from anything.
if request.headers.get("Sec-Fetch-Dest") == "document" or request.path in (
"/",
"/superset/welcome/",
):
return redirect(LANDING)
return None
def _limit_dashboard_list() -> None:
"""The dashboard list shows readers only published dashboards that carry their roles."""
from superset import security_manager
from superset.dashboards.filters import DashboardAccessFilter
from superset.models.dashboard import Dashboard
original = DashboardAccessFilter.apply
if getattr(original, "petrolyst_readers", False):
return
def apply(self: DashboardAccessFilter, query: Any, value: Any) -> Any:
query = original(self, query, value)
if is_reader():
role_ids = [role.id for role in security_manager.get_user_roles()]
query = query.filter(
Dashboard.published.is_(True),
Dashboard.roles.any(security_manager.role_model.id.in_(role_ids)),
)
return query
apply.petrolyst_readers = True # type: ignore[attr-defined]
DashboardAccessFilter.apply = apply # type: ignore[method-assign]
def register(app: Flask) -> None:
"""FLASK_APP_MUTATOR: runs once Superset is initialized."""
# Our templates first: head_custom_extra.html, which Superset's spa.html includes on every
# page, is the only file there.
app.jinja_env.loader = ChoiceLoader([FileSystemLoader(str(TEMPLATES)), app.jinja_env.loader])
app.jinja_env.globals["petrolyst_is_reader"] = is_reader
# Static files are cached for a year (Cache-Control max-age), so the address carries the
# file's hash: a changed reader.css reaches readers on their next page load.
css = Path(app.static_folder or "") / "assets" / "branding" / "reader.css"
version = hashlib.sha256(css.read_bytes()).hexdigest()[:12]
app.jinja_env.globals["petrolyst_reader_css_version"] = version
app.before_request(_land_readers)
_limit_dashboard_list()
+1 -5
View File
@@ -11,9 +11,6 @@ the stock behaviour:
login page on /login/, which only shows a "Sign in with keycloak" button. 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 - /logout/ also ends the Keycloak session, so the next visit asks for the
password and OTP again. password and OTP again.
It also takes the reader rules (petrolyst_reader.ReaderAccessMixin), as does the
local stack's database-login manager.
""" """
import logging import logging
@@ -27,7 +24,6 @@ from flask_appbuilder.utils.base import get_safe_redirect
from flask_login import logout_user from flask_login import logout_user
from werkzeug.wrappers import Response as WerkzeugResponse from werkzeug.wrappers import Response as WerkzeugResponse
from petrolyst_reader import ReaderAccessMixin
from superset.security import SupersetSecurityManager from superset.security import SupersetSecurityManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,7 +64,7 @@ class PetrolystAuthOAuthView(AuthOAuthView):
return redirect(f"{end_session}?{urlencode(params)}") return redirect(f"{end_session}?{urlencode(params)}")
class PetrolystSecurityManager(ReaderAccessMixin, SupersetSecurityManager): class PetrolystSecurityManager(SupersetSecurityManager):
authoauthview = PetrolystAuthOAuthView authoauthview = PetrolystAuthOAuthView
# Without this, Superset registers its React login page on /login/ before # Without this, Superset registers its React login page on /login/ before
# FAB's OAuth view, and that page wins the route. # FAB's OAuth view, and that page wins the route.
+65 -8
View File
@@ -1,23 +1,80 @@
"""The role "ATOMA Guest": what the Petrolyst app's embedded dashboards may do (PD-12 trial). """The role "ATOMA 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 Superset gives every embedded viewer (a guest pass signed for one dashboard) the role named by
GUEST_ROLE_NAME. That role gets the same permissions as "ATOMA Reader": reading a dashboard GUEST_ROLE_NAME. Which dashboard a guest sees comes from the pass, not from the role. Superset
and its charts' data, nothing else. Which dashboard a guest sees comes from the pass, not from leaves the SQL out of chart-data responses for guests.
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), 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: 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/atoma_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.
The list was established on the local stack by reading the ATOMA dashboard as a reader (the
former "ATOMA Reader" role, retired with PD-13): everything a dashboard asks the server for, and
nothing more. No menu access, nothing to write, no Explore, CSV, sharing, drill or query view.
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
from atoma_reader import PERMISSIONS, sync
ROLE = "ATOMA Guest" ROLE = "ATOMA Guest"
PERMISSIONS = [
("can_read", "Dashboard"), # the dashboard and its charts' definitions
("can_dashboard", "Superset"), # the dashboard page
("can_dashboard_permalink", "Superset"), # a dashboard permalink
("can_read", "Chart"), # chart data
("can_time_range", "Api"), # the Viewing period filter
("can_read", "DashboardFilterStateRestApi"), # native filter state
("can_write", "DashboardFilterStateRestApi"),
("can_read", "DashboardPermalinkRestApi"),
("can_read", "CurrentUserRestApi"), # who is signed in
("can_read", "SecurityRestApi"), # the CSRF token
("can_get", "MenuApi"),
("can_log", "Superset"), # Superset's event log
]
def sync(name: str, permissions: list[tuple[str, str]]) -> int:
"""Give the role `name` exactly `permissions`, creating it if needed."""
from superset.app import create_app
app = create_app()
with app.app_context():
from superset import db, security_manager as sm
wanted = []
for permission, view in permissions:
pvm = sm.find_permission_view_menu(permission, view)
if pvm is None:
print(f"no permission {permission} on {view}: run `superset init` first?",
file=sys.stderr)
return 1
wanted.append(pvm)
role = sm.find_role(name) or sm.add_role(name)
added = [pvm for pvm in wanted if pvm not in role.permissions]
removed = [pvm for pvm in role.permissions if pvm not in wanted]
for pvm in added:
sm.add_permission_role(role, pvm)
for pvm in removed:
sm.del_permission_role(role, pvm)
db.session.commit()
print(f'role "{name}": {len(wanted)} permissions, {len(added)} added, '
f"{len(removed)} removed")
for pvm in removed:
print(f" removed {pvm.permission.name} on {pvm.view_menu.name}")
return 0
def main() -> int:
return sync(ROLE, PERMISSIONS)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(sync(ROLE, PERMISSIONS)) sys.exit(main())
-76
View File
@@ -1,76 +0,0 @@
"""The role "ATOMA Reader": read the dashboards granted to it, nothing else.
Runs inside the Superset container, after `superset init` (stacks/fuel-monitoring/superset/init.sh):
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/atoma_reader.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. Readers reach a
dashboard only when it lists this role (DASHBOARD_RBAC; dashboards/atoma/import_bundle.py
attaches it), and petrolyst_reader.py adds the rules a role cannot express.
The list was established on the local stack by reading the ATOMA dashboard as a reader:
everything the dashboard asks the server for, and nothing more. No menu access, nothing to
write, no Explore, CSV, sharing, drill or query view.
"""
from __future__ import annotations
import sys
ROLE = "ATOMA Reader"
PERMISSIONS = [
("can_read", "Dashboard"), # the dashboard and its charts' definitions
("can_dashboard", "Superset"), # the dashboard page
("can_dashboard_permalink", "Superset"), # a dashboard permalink
("can_read", "Chart"), # chart data
("can_time_range", "Api"), # the Viewing period filter
("can_read", "DashboardFilterStateRestApi"), # native filter state
("can_write", "DashboardFilterStateRestApi"),
("can_read", "DashboardPermalinkRestApi"),
("can_read", "CurrentUserRestApi"), # who is signed in
("can_read", "SecurityRestApi"), # the CSRF token
("can_get", "MenuApi"),
("can_log", "Superset"), # Superset's event log
]
def sync(name: str, permissions: list[tuple[str, str]]) -> int:
"""Give the role `name` exactly `permissions`, creating it if needed (atoma_guest.py too)."""
from superset.app import create_app
app = create_app()
with app.app_context():
from superset import db, security_manager as sm
wanted = []
for permission, view in permissions:
pvm = sm.find_permission_view_menu(permission, view)
if pvm is None:
print(f"no permission {permission} on {view}: run `superset init` first?",
file=sys.stderr)
return 1
wanted.append(pvm)
role = sm.find_role(name) or sm.add_role(name)
added = [pvm for pvm in wanted if pvm not in role.permissions]
removed = [pvm for pvm in role.permissions if pvm not in wanted]
for pvm in added:
sm.add_permission_role(role, pvm)
for pvm in removed:
sm.del_permission_role(role, pvm)
db.session.commit()
print(f'role "{name}": {len(wanted)} permissions, {len(added)} added, '
f"{len(removed)} removed")
for pvm in removed:
print(f" removed {pvm.permission.name} on {pvm.view_menu.name}")
return 0
def main() -> int:
return sync(ROLE, PERMISSIONS)
if __name__ == "__main__":
sys.exit(main())
+4 -20
View File
@@ -13,7 +13,6 @@ from copy import deepcopy
from flask_appbuilder.security.manager import AUTH_OAUTH from flask_appbuilder.security.manager import AUTH_OAUTH
from sqlalchemy.engine.url import URL from sqlalchemy.engine.url import URL
import petrolyst_reader
from petrolyst_security import PetrolystSecurityManager from petrolyst_security import PetrolystSecurityManager
from superset import config as superset_defaults from superset import config as superset_defaults
@@ -80,8 +79,7 @@ AUTH_ROLES_MAPPING = {
"superset-admin": ["Admin"], "superset-admin": ["Admin"],
"superset-editor": ["Alpha"], "superset-editor": ["Alpha"],
"superset-viewer": ["Gamma"], "superset-viewer": ["Gamma"],
# ATOMA's users: read the ATOMA dashboard, nothing else (petrolyst_reader.py). # "atoma-reader" opens the Petrolyst app only; here it maps to nothing (Public).
"atoma-reader": ["ATOMA Reader"],
} }
AUTH_ROLES_SYNC_AT_LOGIN = True AUTH_ROLES_SYNC_AT_LOGIN = True
AUTH_USER_REGISTRATION = True AUTH_USER_REGISTRATION = True
@@ -95,27 +93,13 @@ AUTH_USER_REGISTRATION_ROLE = "Public"
# opens it from a link. # opens it from a link.
FEATURE_FLAGS = {"DASHBOARD_RBAC": True, "FILTERBAR_CLOSED_BY_DEFAULT": True} FEATURE_FLAGS = {"DASHBOARD_RBAC": True, "FILTERBAR_CLOSED_BY_DEFAULT": True}
# Readers ("ATOMA Reader"): land on the ATOMA dashboard, see it without the
# title row and the menus Superset shows to everyone, and reach no other
# dashboard (petrolyst_reader.py).
FLASK_APP_MUTATOR = petrolyst_reader.register
# deck.gl maps: OpenStreetMap raster tiles only. The default list adds Mapbox # 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 # styles, which fail without MAPBOX_API_KEY. The default CSP already allows the
# tile host. The ATOMA map credits OSM in a Markdown block beside it. # tile host.
DECKGL_BASE_MAP = [ DECKGL_BASE_MAP = [
["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"], ["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"],
] ]
# Markdown blocks may hold an iframe: the ATOMA Fleet tab embeds the fleet-map view
# from petrolyst-platform (/views/fleet-map/). Only these attributes survive. The CSP
# (default-src 'self', no frame-src) keeps frames to this origin, so a dashboard
# editor cannot embed another site.
HTML_SANITIZATION_SCHEMA_EXTENSIONS = {
"tagNames": ["iframe"],
"attributes": {"iframe": ["src", "title", "loading", "style"]},
}
# --- Behind Traefik ----------------------------------------------------------- # --- Behind Traefik -----------------------------------------------------------
ENABLE_PROXY_FIX = True ENABLE_PROXY_FIX = True
PREFERRED_URL_SCHEME = "https" PREFERRED_URL_SCHEME = "https"
@@ -155,7 +139,7 @@ EXPLORE_FORM_DATA_CACHE_CONFIG = {
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1" RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
# --- Petrolyst look and feel -------------------------------------------------- # --- Petrolyst look and feel --------------------------------------------------
# Values from petrolyst-ui/design/brand/07_UI_Tokens/petrolyst_tokens.css. # Values from petrolyst-web/design/brand/07_UI_Tokens/petrolyst_tokens.css.
# Assets live in assets/branding/ and are served from /static/assets/branding/. # Assets live in assets/branding/ and are served from /static/assets/branding/.
BRANDING = "/static/assets/branding" BRANDING = "/static/assets/branding"
@@ -178,7 +162,7 @@ THEME_DEFAULT["token"].update(
# letters were about 5px high. Its own Y carries the brand accent. 24px # 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 # (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) # left, 15px more than the navbar's 16px: the P (1.4px inside the image)
# lines up with the dashboard's content and a reader's fleet map at 32px. # lines up with the dashboard's content at 32px.
"brandLogoHeight": "24px", "brandLogoHeight": "24px",
"brandLogoMargin": "18px 0 18px 15px", "brandLogoMargin": "18px 0 18px 15px",
# Action teal, not the raw brand teal #009688: the brand guide keeps # Action teal, not the raw brand teal #009688: the brand guide keeps
@@ -1,6 +0,0 @@
{#- Petrolyst: replaces Superset's empty head_custom_extra.html, which spa.html includes on every
page (petrolyst_reader.register puts this folder first). Readers get reader.css; its address
names its content, as browsers keep static files for a year. -#}
{% if petrolyst_is_reader() %}
<link rel="stylesheet" href="{{ assets_prefix }}/static/assets/branding/reader.css?v={{ petrolyst_reader_css_version }}">
{% endif %}
+1 -2
View File
@@ -11,8 +11,7 @@ USER root
RUN uv pip install --no-cache --python /app/.venv/bin/python \ RUN uv pip install --no-cache --python /app/.venv/bin/python \
authlib==1.9.1 psycopg2-binary==2.9.13 "trino[sqlalchemy]==0.340.0" authlib==1.9.1 psycopg2-binary==2.9.13 "trino[sqlalchemy]==0.340.0"
COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py petrolyst_reader.py /app/pythonpath/ COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py /app/pythonpath/
COPY --from=insights --chown=superset:superset templates/ /app/pythonpath/templates/
COPY --from=insights --chown=superset:superset roles/ /app/petrolyst-roles/ COPY --from=insights --chown=superset:superset roles/ /app/petrolyst-roles/
COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/ COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/
COPY --from=insights patches/ /app/patches/petrolyst/ COPY --from=insights patches/ /app/patches/petrolyst/
-2
View File
@@ -12,5 +12,3 @@ superset init
superset set-database-uri \ superset set-database-uri \
--database_name "Fuel (Trino)" \ --database_name "Fuel (Trino)" \
--uri "trino://superset@trino:8080/fuel" --uri "trino://superset@trino:8080/fuel"
# The role "ATOMA Reader", set to exactly its permissions (petrolyst-insights/roles/).
/app/.venv/bin/python /app/petrolyst-roles/atoma_reader.py