Trial state of 29 Oct 2026: ATOMA reader setup, app dashboards, embedding notes
Baseline before the clean-up (PD-13): the PD-11 reader setup and the PD-12 app dashboards as they ran on the local stack, uncommitted until now. Not deployed, not independently reviewed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97284052c1
commit
1b8e57adb4
24 files changed
+2777
-136
No files matched your search
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user