From 29ff9f292afdad2ad339b8421c0a75e356cf51f0 Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Mon, 13 Jul 2026 15:38:33 +0200 Subject: [PATCH 1/8] Add GlobalAdminUser model for Redash Global auth Separate admin user for global redash. env.py imports redash_global.models so migrations pick up the table, keeping the main app decoupled from redash_global. --- migrations/env.py | 4 ++ .../b8b7fd6b4ffd_add_globaladminuser.py | 36 +++++++++++++++++ redash_global/models.py | 28 +++++++++++++ tests/models/test_global_admin_users.py | 40 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 migrations/versions/b8b7fd6b4ffd_add_globaladminuser.py create mode 100644 redash_global/models.py create mode 100644 tests/models/test_global_admin_users.py diff --git a/migrations/env.py b/migrations/env.py index 1e80c143ae..c71c34a62c 100755 --- a/migrations/env.py +++ b/migrations/env.py @@ -19,6 +19,10 @@ # target_metadata = mymodel.Base.metadata from flask import current_app +# Import redash_global models so their tables are registered on db.metadata +# and picked up by autogenerate. +import redash_global.models # noqa: F401,E402 + db_url_escaped = current_app.config.get("SQLALCHEMY_DATABASE_URI").replace("%", "%%") config.set_main_option("sqlalchemy.url", db_url_escaped) target_metadata = current_app.extensions["migrate"].db.metadata diff --git a/migrations/versions/b8b7fd6b4ffd_add_globaladminuser.py b/migrations/versions/b8b7fd6b4ffd_add_globaladminuser.py new file mode 100644 index 0000000000..bedb01cb73 --- /dev/null +++ b/migrations/versions/b8b7fd6b4ffd_add_globaladminuser.py @@ -0,0 +1,36 @@ +"""add GlobalAdminUser + +Revision ID: b8b7fd6b4ffd +Revises: 66335454929d +Create Date: 2026-07-08 22:39:39.652218 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b8b7fd6b4ffd' +down_revision = '66335454929d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('global_admin_users', + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('global_admin_users') + # ### end Alembic commands ### diff --git a/redash_global/models.py b/redash_global/models.py new file mode 100644 index 0000000000..3288cd8939 --- /dev/null +++ b/redash_global/models.py @@ -0,0 +1,28 @@ +from flask_login import UserMixin +from passlib.apps import custom_app_context as pwd_context +from sqlalchemy_utils.models import generic_repr + +from redash.models.base import Column, db, primary_key +from redash.models.mixins import TimestampMixin + + +@generic_repr("id", "username") +class GlobalAdminUser(UserMixin, TimestampMixin, db.Model): + __tablename__ = "global_admin_users" + + id = primary_key("GlobalAdminUser") + username = Column(db.String(255), unique=True, nullable=False) + password_hash = Column(db.String(128), nullable=False) + + def hash_password(self, password): + """Hash and store the given password using passlib.""" + self.password_hash = pwd_context.hash(password) + + def verify_password(self, password): + """Verify a password against the stored hash.""" + return self.password_hash and pwd_context.verify(password, self.password_hash) + + @classmethod + def get_by_username(cls, username): + """Return the user with the given username, or None.""" + return cls.query.filter(cls.username == username).first() diff --git a/tests/models/test_global_admin_users.py b/tests/models/test_global_admin_users.py new file mode 100644 index 0000000000..df20e817c1 --- /dev/null +++ b/tests/models/test_global_admin_users.py @@ -0,0 +1,40 @@ +from sqlalchemy.exc import IntegrityError + +from redash.models import db +from redash_global.models import GlobalAdminUser +from tests import BaseTestCase + + +class GlobalAdminUserTest(BaseTestCase): + def _create_user(self, username="admin", password="secret"): + user = GlobalAdminUser(username=username) + user.hash_password(password) + db.session.add(user) + db.session.commit() + return user + + def test_hash_and_verify_password(self): + """Password is stored hashed and verifies against the original.""" + user = self._create_user(password="secret") + + self.assertNotEqual(user.password_hash, "secret") + self.assertTrue(user.verify_password("secret")) + self.assertFalse(user.verify_password("wrong")) + + def test_get_by_username(self): + """get_by_username returns the matching user, or None.""" + user = self._create_user(username="admin") + + self.assertEqual(GlobalAdminUser.get_by_username("admin").id, user.id) + self.assertIsNone(GlobalAdminUser.get_by_username("nobody")) + + def test_username_must_be_unique(self): + """Duplicate usernames raise an IntegrityError.""" + self._create_user(username="admin") + + dup = GlobalAdminUser(username="admin") + dup.hash_password("secret") + db.session.add(dup) + + with self.assertRaises(IntegrityError): + db.session.flush() From 2ecc17d695b3d106dfbb32adf673e03b59bb5918 Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Mon, 13 Jul 2026 19:33:41 +0200 Subject: [PATCH 2/8] Add redash_global/cli with two commands Registered via app.cli.add_command in create_global_app, so `FLASK_APP=redash_global.app:create_global_app flask create_global_admin` works. --- redash_global/app.py | 5 +++ redash_global/cli/__init__.py | 50 +++++++++++++++++++++ tests/test_global_cli.py | 81 +++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 redash_global/cli/__init__.py create mode 100644 tests/test_global_cli.py diff --git a/redash_global/app.py b/redash_global/app.py index 6043b5831c..0734f35ae2 100644 --- a/redash_global/app.py +++ b/redash_global/app.py @@ -22,4 +22,9 @@ def create_global_app(): app.register_blueprint(global_blueprint) + from redash_global.cli import create_global_admin, update_global_admin_password + + app.cli.add_command(create_global_admin) + app.cli.add_command(update_global_admin_password) + return app diff --git a/redash_global/cli/__init__.py b/redash_global/cli/__init__.py new file mode 100644 index 0000000000..799a98ea9e --- /dev/null +++ b/redash_global/cli/__init__.py @@ -0,0 +1,50 @@ +from sys import exit + +from click import argument, command, option, prompt + +from redash.models import db +from redash_global.models import GlobalAdminUser + + +@command(name="create_global_admin") +@argument("username") +@option( + "--password", + "password", + default=None, + help="Password for the admin (leave blank for prompt).", +) +def create_global_admin(username, password=None): + if GlobalAdminUser.get_by_username(username) is not None: + print(f"Global admin [{username}] already exists.") + exit(1) + + if not password: + password = prompt("Password", hide_input=True, confirmation_prompt=True) + + user = GlobalAdminUser(username=username) + user.hash_password(password) + + try: + db.session.add(user) + db.session.commit() + except Exception as e: + print(f"Failed creating global admin: {e}") + exit(1) + + print(f"Created global admin [{username}].") + + +@command(name="update_global_admin_password") +@argument("username") +@argument("password") +def update_global_admin_password(username, password): + user = GlobalAdminUser.get_by_username(username) + if user is None: + print(f"Global admin [{username}] not found.") + exit(1) + + user.hash_password(password) + db.session.add(user) + db.session.commit() + print(f"Updated password for global admin [{username}].") diff --git a/tests/test_global_cli.py b/tests/test_global_cli.py new file mode 100644 index 0000000000..7976b35453 --- /dev/null +++ b/tests/test_global_cli.py @@ -0,0 +1,81 @@ +from click.testing import CliRunner + +from redash_global.cli import create_global_admin, update_global_admin_password +from redash_global.models import GlobalAdminUser +from tests import BaseTestCase + + +class CreateGlobalAdminTest(BaseTestCase): + """BaseTestCase is used only for the DB session (app context + tables).""" + + def test_create_global_admin(self): + """Creating an admin via the CLI persists a verifiable user.""" + runner = CliRunner() + result = runner.invoke( + create_global_admin, + ["admin", "--password", "secret"], + ) + + self.assertFalse(result.exception) + self.assertEqual(result.exit_code, 0) + + user = GlobalAdminUser.get_by_username("admin") + self.assertIsNotNone(user) + self.assertTrue(user.verify_password("secret")) + + def test_create_global_admin_prompts_for_password(self): + """Password is prompted for when not passed as an option.""" + runner = CliRunner() + result = runner.invoke( + create_global_admin, + ["admin"], + input="secret\nsecret\n", + ) + + self.assertFalse(result.exception) + self.assertEqual(result.exit_code, 0) + self.assertTrue(GlobalAdminUser.get_by_username("admin").verify_password("secret")) + + def test_create_global_admin_rejects_duplicate_username(self): + """A duplicate username fails without creating a second user.""" + runner = CliRunner() + runner.invoke( + create_global_admin, + ["admin", "--password", "secret"], + ) + result = runner.invoke( + create_global_admin, + ["admin", "--password", "secret"], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("already exists", result.output) + self.assertEqual(GlobalAdminUser.query.count(), 1) + + +class UpdateGlobalAdminPasswordTest(BaseTestCase): + """BaseTestCase is used only for the DB session (app context + tables).""" + + def test_update_password(self): + """Updating the password replaces the stored hash.""" + runner = CliRunner() + runner.invoke( + create_global_admin, + ["admin", "--password", "secret"], + ) + + result = runner.invoke(update_global_admin_password, ["admin", "newsecret"]) + + self.assertFalse(result.exception) + self.assertEqual(result.exit_code, 0) + user = GlobalAdminUser.get_by_username("admin") + self.assertFalse(user.verify_password("secret")) + self.assertTrue(user.verify_password("newsecret")) + + def test_update_password_unknown_user(self): + """Updating a missing user fails cleanly.""" + runner = CliRunner() + result = runner.invoke(update_global_admin_password, ["nobody", "newsecret"]) + + self.assertEqual(result.exit_code, 1) + self.assertIn("not found", result.output) From ec68cc7c10cef0de6d28a83b82211139012eee92 Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 09:04:32 +0200 Subject: [PATCH 3/8] Add session-based login to Redash Global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the Redash Global admin SPA behind a login page so only authenticated global admins can reach it. - Add a /login page and POST handler that verifies GlobalAdminUser credentials and starts a flask_login session; wrap the SPA index in @login_required so unauthenticated visitors are redirected there. - Configure a dedicated GLOBAL_SECRET_KEY for signing the session cookie, and fail fast at startup if it's unset — this keeps global sessions cryptographically independent from the main Redash app so a leaked key in one app can't forge sessions in the other. - Use a separate session cookie name (global_admin_session) so the two apps' cookies don't collide when served from the same host. - Enable CSRF protection on the global app and include a csrf_token in the login form to protect the credential POST. - Register a Jinja ChoiceLoader that prefers Redash Global's own templates but falls back to Redash's, so login.html can extend the shared signed_out.html layout and reuse existing styling. - Add tests covering the redirect-when-unauthenticated, valid-login, and invalid-login flows. The shared test process forces us to mute the user_logged_in signal, since the main app's handler expects an org_id that GlobalAdminUser doesn't have. --- redash_global/app.py | 39 ++++++++++++++++ redash_global/routes.py | 2 + redash_global/security.py | 13 ++++++ redash_global/templates/login.html | 31 ++++++++++++ redash_global/views/auth.py | 28 +++++++++++ redash_global/views/index.py | 2 + tests/test_global_auth.py | 75 ++++++++++++++++++++++++++++++ 7 files changed, 190 insertions(+) create mode 100644 redash_global/security.py create mode 100644 redash_global/templates/login.html create mode 100644 redash_global/views/auth.py create mode 100644 tests/test_global_auth.py diff --git a/redash_global/app.py b/redash_global/app.py index 0734f35ae2..ddff4dcb0e 100644 --- a/redash_global/app.py +++ b/redash_global/app.py @@ -1,11 +1,33 @@ +import os + from flask import Flask +from jinja2 import ChoiceLoader, FileSystemLoader +import redash from redash import settings +from redash.handlers.webpack import configure_webpack from redash.models.base import db +from redash_global import security +from redash_global.views.auth import login_manager + +GLOBAL_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "templates") +REDASH_TEMPLATE_PATH = os.path.join(os.path.dirname(redash.__file__), "templates") + + +def _validate_required_config(): + """Fail fast at startup if security-critical config is missing.""" + if not os.environ.get("GLOBAL_SECRET_KEY"): + raise Exception( + "You must set the GLOBAL_SECRET_KEY environment variable to run " + "Redash Global. It signs the global-admin session cookie and must " + "differ from the main Redash app's secret." + ) def create_global_app(): """Create and configure the Redash Global Flask application.""" + _validate_required_config() + app = Flask( __name__, static_folder=settings.STATIC_ASSETS_PATH, @@ -16,7 +38,24 @@ def create_global_app(): # no second connection pool, full access to the existing models later on. app.config["SQLALCHEMY_DATABASE_URI"] = settings.SQLALCHEMY_DATABASE_URI app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + # Secret used to sign the session cookie. A dedicated secret keeps Redash + # Global sessions cryptographically independent from the main Redash app. + app.config["SECRET_KEY"] = os.environ["GLOBAL_SECRET_KEY"] + + # Load Redash Global's own templates first, falling back to Redash's + # templates so pages can extend shared layouts (e.g. signed_out.html). + app.jinja_loader = ChoiceLoader( + [ + FileSystemLoader(GLOBAL_TEMPLATE_PATH), + FileSystemLoader(REDASH_TEMPLATE_PATH), + ] + ) + db.init_app(app) + security.init_app(app) + login_manager.init_app(app) + configure_webpack(app) from redash_global.routes import global_blueprint diff --git a/redash_global/routes.py b/redash_global/routes.py index 970ce5a530..71787cfdbf 100644 --- a/redash_global/routes.py +++ b/redash_global/routes.py @@ -1,7 +1,9 @@ from flask import Blueprint +from redash_global.views.auth import login_page from redash_global.views.index import index_view global_blueprint = Blueprint("global", __name__) global_blueprint.add_url_rule("/", methods=["GET"], view_func=index_view, endpoint="index") +global_blueprint.add_url_rule("/login", methods=["GET", "POST"], view_func=login_page, endpoint="login") diff --git a/redash_global/security.py b/redash_global/security.py new file mode 100644 index 0000000000..f9c2e49948 --- /dev/null +++ b/redash_global/security.py @@ -0,0 +1,13 @@ +import os + +from flask_wtf.csrf import CSRFProtect + +from redash import settings + +csrf = CSRFProtect() + + +def init_app(app): + csrf.init_app(app) + app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT + app.config["SESSION_COOKIE_NAME"] = os.environ.get("GLOBAL_SESSION_COOKIE_NAME", "global_admin_session") diff --git a/redash_global/templates/login.html b/redash_global/templates/login.html new file mode 100644 index 0000000000..699ed06b0a --- /dev/null +++ b/redash_global/templates/login.html @@ -0,0 +1,31 @@ +{% extends "layouts/signed_out.html" %} + +{% block title %}Login to Redash Global{% endblock %} + +{% block content %} +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+ + +
+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/redash_global/views/auth.py b/redash_global/views/auth.py new file mode 100644 index 0000000000..f66dcefc7e --- /dev/null +++ b/redash_global/views/auth.py @@ -0,0 +1,28 @@ +from flask import flash, redirect, render_template, request, url_for +from flask_login import LoginManager, current_user, login_user + +from redash_global.models import GlobalAdminUser + +login_manager = LoginManager() +login_manager.login_view = "global.login" + + +@login_manager.user_loader +def load_user(user_id): + return GlobalAdminUser.query.get(user_id) + + +def login_page(): + if current_user.is_authenticated: + return redirect(url_for("global.index")) + + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + user = GlobalAdminUser.get_by_username(username) + if user and user.verify_password(password): + login_user(user) + return redirect(url_for("global.index")) + flash("Wrong username or password.") + + return render_template("login.html", username=request.form.get("username", "")) diff --git a/redash_global/views/index.py b/redash_global/views/index.py index 820d2447f0..57f6c0a32f 100644 --- a/redash_global/views/index.py +++ b/redash_global/views/index.py @@ -1,9 +1,11 @@ from flask import send_file +from flask_login import login_required from werkzeug.utils import safe_join from redash import settings +@login_required def index_view(): full_path = safe_join(settings.STATIC_ASSETS_PATH, "global.html") return send_file(full_path, max_age=0, conditional=True) diff --git a/tests/test_global_auth.py b/tests/test_global_auth.py new file mode 100644 index 0000000000..1d66e26d9f --- /dev/null +++ b/tests/test_global_auth.py @@ -0,0 +1,75 @@ +import os + +from flask_login import user_logged_in + +from redash.authentication import log_user_logged_in +from redash.models import db +from redash_global.app import create_global_app +from redash_global.models import GlobalAdminUser +from tests import BaseTestCase + + +class GlobalBaseTestCase(BaseTestCase): + """Base for Redash Global tests: reuses BaseTestCase's shared DB/session but + drives requests through the separate Redash Global app and its own client. + + It also mutes the ``user_logged_in`` signal. BaseTestCase builds the + main Redash app, which subscribes ``log_user_logged_in`` to flask_login's + process-global ``user_logged_in`` signal. That handler reads ``user.org_id``, + which ``GlobalAdminUser`` lacks, so it would crash whenever the global app + logs a user in. The two apps run in separate processes in production and + never share the signal; the clash exists only here, where both apps live in + one pytest process, so we disconnect the handler for the duration of the test + and restore it afterwards. + """ + + def setUp(self): + os.environ.setdefault("GLOBAL_SECRET_KEY", "test-global-secret") + super().setUp() + user_logged_in.disconnect(log_user_logged_in) + self.global_app = create_global_app() + self.global_app.config["TESTING"] = True # flips Flask into test mode + self.global_app.config["WTF_CSRF_ENABLED"] = False # turns off token validation to make tests easier + self.global_client = self.global_app.test_client() + + def tearDown(self): + user_logged_in.connect(log_user_logged_in) + super().tearDown() + + def _create_admin(self, username="admin", password="secret"): + user = GlobalAdminUser(username=username) + user.hash_password(password) + db.session.add(user) + db.session.commit() + return user + + +class GlobalAuthTest(GlobalBaseTestCase): + def test_unauthenticated_index_redirects_to_login(self): + """The SPA at / is gated: no session means a redirect to /login.""" + response = self.global_client.get("/") + + self.assertEqual(response.status_code, 302) + self.assertIn("/login", response.headers["Location"]) + + def test_login_with_valid_credentials_sets_session_and_redirects(self): + """Good credentials log the admin in and redirect to the SPA.""" + self._create_admin("admin", "secret") + + response = self.global_client.post("/login", data={"username": "admin", "password": "secret"}) + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers["Location"].endswith("/")) + + # The session persists, so / is now served instead of redirecting. + index = self.global_client.get("/") + self.assertEqual(index.status_code, 200) + + def test_login_with_invalid_credentials_shows_error(self): + """Wrong password re-renders the login page with an error.""" + self._create_admin("admin", "secret") + + response = self.global_client.post("/login", data={"username": "admin", "password": "wrong"}) + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Wrong username or password", response.data) From c17266fd4e74696fe583d1c95b1865d3a2a1acf4 Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 09:19:16 +0200 Subject: [PATCH 4/8] Add session-based logout to Redash Global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let an authenticated global admin end their session and return to the login page. - Add a POST-only /logout handler wrapped in @login_required that calls logout_user() to clear the flask_login session and redirects back to /login. - Keep it POST-only rather than GET so CSRF protection applies to it: a GET logout could be triggered by a prefetched link or embedded , letting a third-party page silently sign the admin out. - Add a test that logs in, POSTs /logout, and confirms / gates back to /login — proving the session was actually cleared, not just that the endpoint returns a redirect. --- redash_global/routes.py | 3 ++- redash_global/views/auth.py | 14 +++++++++++++- tests/test_global_auth.py | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/redash_global/routes.py b/redash_global/routes.py index 71787cfdbf..bec75b752e 100644 --- a/redash_global/routes.py +++ b/redash_global/routes.py @@ -1,9 +1,10 @@ from flask import Blueprint -from redash_global.views.auth import login_page +from redash_global.views.auth import login_page, logout_page from redash_global.views.index import index_view global_blueprint = Blueprint("global", __name__) global_blueprint.add_url_rule("/", methods=["GET"], view_func=index_view, endpoint="index") global_blueprint.add_url_rule("/login", methods=["GET", "POST"], view_func=login_page, endpoint="login") +global_blueprint.add_url_rule("/logout", methods=["POST"], view_func=logout_page, endpoint="logout") diff --git a/redash_global/views/auth.py b/redash_global/views/auth.py index f66dcefc7e..6afd4829cd 100644 --- a/redash_global/views/auth.py +++ b/redash_global/views/auth.py @@ -1,5 +1,11 @@ from flask import flash, redirect, render_template, request, url_for -from flask_login import LoginManager, current_user, login_user +from flask_login import ( + LoginManager, + current_user, + login_required, + login_user, + logout_user, +) from redash_global.models import GlobalAdminUser @@ -26,3 +32,9 @@ def login_page(): flash("Wrong username or password.") return render_template("login.html", username=request.form.get("username", "")) + + +@login_required +def logout_page(): + logout_user() + return redirect(url_for("global.login")) diff --git a/tests/test_global_auth.py b/tests/test_global_auth.py index 1d66e26d9f..f9225e8519 100644 --- a/tests/test_global_auth.py +++ b/tests/test_global_auth.py @@ -73,3 +73,18 @@ def test_login_with_invalid_credentials_shows_error(self): self.assertEqual(response.status_code, 200) self.assertIn(b"Wrong username or password", response.data) + + def test_logout_clears_session_and_redirects_to_login(self): + """Logout ends the session so / gates back to /login.""" + self._create_admin("admin", "secret") + self.global_client.post("/login", data={"username": "admin", "password": "secret"}) + + response = self.global_client.post("/logout") + + self.assertEqual(response.status_code, 302) + self.assertIn("/login", response.headers["Location"]) + + # The session is gone, so / redirects to /login again. + index = self.global_client.get("/") + self.assertEqual(index.status_code, 302) + self.assertIn("/login", index.headers["Location"]) From 5961dc0a598e60df907b58d8801e22d2b52aa5bc Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 12:11:53 +0200 Subject: [PATCH 5/8] Build Redash Global admin UI shell with working logout Wires the bare "Work in progress" global app into Redash's existing application chrome and fixes logout end to end. - Render ApplicationLayout + Router with a placeholder home route, reusing the main app's chrome instead of reinventing it. - Register GlobalDesktopNavbar as "ApplicationDesktopNavbar"; the default navbar needs an org-scoped currentUser that Redash Global doesn't have. - Add main.less + ant.less to the global_app webpack entry, without them the navbar's Ant Design Menu had no styles and the layout was unpositioned. - Pass baseHref + staticPath to global.html so the Router can resolve routes and favicons load. - Change /logout from POST to GET to match main Redash and the navbar's , which was returning Method Not Allowed. --- .../GlobalDesktopNavbar.jsx | 43 ++++++++++++++ .../GlobalApplicationArea/index.jsx | 57 ++++++++++++++++++- redash_global/client/global.html | 22 ++++++- redash_global/routes.py | 2 +- tests/test_global_auth.py | 2 +- webpack.config.js | 8 ++- 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 redash_global/client/components/GlobalApplicationArea/GlobalDesktopNavbar.jsx diff --git a/redash_global/client/components/GlobalApplicationArea/GlobalDesktopNavbar.jsx b/redash_global/client/components/GlobalApplicationArea/GlobalDesktopNavbar.jsx new file mode 100644 index 0000000000..f2d519fe47 --- /dev/null +++ b/redash_global/client/components/GlobalApplicationArea/GlobalDesktopNavbar.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import Menu from "antd/lib/menu"; + +import Link from "@/components/Link"; +import logoUrl from "@/assets/images/redash_icon_small.png"; + +import LogoutOutlinedIcon from "@ant-design/icons/LogoutOutlined"; + +import "@/components/ApplicationArea/ApplicationLayout/DesktopNavbar.less"; + +function NavbarSection({ children, ...props }) { + return ( + + {children} + + ); +} + +export default function GlobalDesktopNavbar() { + return ( + + ); +} diff --git a/redash_global/client/components/GlobalApplicationArea/index.jsx b/redash_global/client/components/GlobalApplicationArea/index.jsx index bee8ba8920..bc55cc4c04 100644 --- a/redash_global/client/components/GlobalApplicationArea/index.jsx +++ b/redash_global/client/components/GlobalApplicationArea/index.jsx @@ -1,5 +1,58 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; + +import Router from "@/components/ApplicationArea/Router"; +import ApplicationLayout from "@/components/ApplicationArea/ApplicationLayout"; +import ErrorMessage from "@/components/ApplicationArea/ErrorMessage"; +import handleNavigationIntent from "@/components/ApplicationArea/handleNavigationIntent"; +import { registerComponent } from "@/components/DynamicComponent"; + +import GlobalDesktopNavbar from "./GlobalDesktopNavbar"; + +// Reuse Redash's application chrome, but swap the navbar for one that doesn't +// depend on an org-scoped currentUser (which doesn't exist in Redash Global). +registerComponent("ApplicationDesktopNavbar", GlobalDesktopNavbar); + +const routes = [ + { + id: "Home", + path: "/", + title: "Global Admin", + render: () =>
Work in progress
, + }, +]; export default function GlobalApplicationArea() { - return
Work in progress
; + const [currentRoute, setCurrentRoute] = useState(null); + const [unhandledError, setUnhandledError] = useState(null); + + useEffect(() => { + if (currentRoute && currentRoute.title) { + document.title = currentRoute.title; + } + }, [currentRoute]); + + useEffect(() => { + function globalErrorHandler(event) { + event.preventDefault(); + setUnhandledError(event.error); + } + + document.body.addEventListener("click", handleNavigationIntent, false); + window.addEventListener("error", globalErrorHandler, false); + + return () => { + document.body.removeEventListener("click", handleNavigationIntent, false); + window.removeEventListener("error", globalErrorHandler, false); + }; + }, []); + + if (unhandledError) { + return ; + } + + return ( + + + + ); } diff --git a/redash_global/client/global.html b/redash_global/client/global.html index 9caa8bc8ff..dcc92c9caf 100644 --- a/redash_global/client/global.html +++ b/redash_global/client/global.html @@ -1,9 +1,29 @@ - + + <%= htmlWebpackPlugin.options.title %> + + + +
diff --git a/redash_global/routes.py b/redash_global/routes.py index bec75b752e..d9d2f0cd39 100644 --- a/redash_global/routes.py +++ b/redash_global/routes.py @@ -7,4 +7,4 @@ global_blueprint.add_url_rule("/", methods=["GET"], view_func=index_view, endpoint="index") global_blueprint.add_url_rule("/login", methods=["GET", "POST"], view_func=login_page, endpoint="login") -global_blueprint.add_url_rule("/logout", methods=["POST"], view_func=logout_page, endpoint="logout") +global_blueprint.add_url_rule("/logout", methods=["GET"], view_func=logout_page, endpoint="logout") diff --git a/tests/test_global_auth.py b/tests/test_global_auth.py index f9225e8519..91237f1191 100644 --- a/tests/test_global_auth.py +++ b/tests/test_global_auth.py @@ -79,7 +79,7 @@ def test_logout_clears_session_and_redirects_to_login(self): self._create_admin("admin", "secret") self.global_client.post("/login", data={"username": "admin", "password": "secret"}) - response = self.global_client.post("/logout") + response = self.global_client.get("/logout") self.assertEqual(response.status_code, 302) self.assertIn("/login", response.headers["Location"]) diff --git a/webpack.config.js b/webpack.config.js index 6de271dbb7..86267d26c7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -68,7 +68,11 @@ const config = { "./client/app/assets/less/main.less", "./client/app/assets/less/ant.less" ], - global_app: ["./redash_global/client/index.js"], + global_app: [ + "./redash_global/client/index.js", + "./client/app/assets/less/main.less", + "./client/app/assets/less/ant.less" + ], server: ["./client/app/assets/less/server.less"] }, output: { @@ -105,6 +109,8 @@ const config = { template: "./redash_global/client/global.html", filename: "global.html", chunks: ["global_app"], + staticPath, + baseHref: "/", title: "Global Admin" }), new HtmlWebpackPlugin({ From 1082a7bec43ac011b88f85bb1d9c0388159582e2 Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 12:53:15 +0200 Subject: [PATCH 6/8] Suppress Flask-Login's default login prompt message Accessing a protected page while logged out redirected to the login page but flashed "Please log in to access this page." as an error. For an admin app the redirect alone is enough signal, and the message read as an error the user had done something wrong rather than a routine auth gate. --- redash_global/views/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/redash_global/views/auth.py b/redash_global/views/auth.py index 6afd4829cd..97bed92278 100644 --- a/redash_global/views/auth.py +++ b/redash_global/views/auth.py @@ -11,6 +11,7 @@ login_manager = LoginManager() login_manager.login_view = "global.login" +login_manager.login_message = None @login_manager.user_loader From 636f28d5e9dd8d0c7a0a5cb9ccba6083dbb1a96f Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 17:07:22 +0200 Subject: [PATCH 7/8] Document Redash Global app setup and CLI usage --- redash_global/README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 redash_global/README.md diff --git a/redash_global/README.md b/redash_global/README.md new file mode 100644 index 0000000000..bec457e705 --- /dev/null +++ b/redash_global/README.md @@ -0,0 +1,36 @@ +# Redash Global + +A small, standalone Flask app for cross-organization ("global") administration, +running alongside the main Redash app. It shares Redash's PostgreSQL database and +models but is a **separate application** with its own session cookie, signed by a +dedicated `GLOBAL_SECRET_KEY`. + +## Configuration + +`GLOBAL_SECRET_KEY` is **required** — the app refuses to start without it. It must +differ from the main Redash app's secret. The database connection is inherited +from Redash's settings (`SQLALCHEMY_DATABASE_URI`), so no separate DB config is needed. + +## Running CLI commands + +CLI commands (`create_global_admin`, `update_global_admin_password`) are registered +on the global app, so the `flask` CLI must be told to load it via `FLASK_APP`. + +### Locally + +```bash +env FLASK_APP=redash_global.wsgi_global flask create_global_admin --password +``` + +Omit `--password` to be prompted for it interactively. + + +### In staging (Kubernetes) + +`GLOBAL_SECRET_KEY` is already in the pod's environment, so only prepend `FLASK_APP`: + +```bash +kube staging exec -it -- \ + env FLASK_APP=redash_global.wsgi_global \ + flask create_global_admin --password +``` From 2638df0553583d4e02a3af72d7d1d079cb3858be Mon Sep 17 00:00:00 2001 From: Imen Attatra Date: Tue, 14 Jul 2026 18:04:07 +0200 Subject: [PATCH 8/8] Throttle Redash Global admin login attempts --- redash_global/security.py | 11 +++++++++++ redash_global/views/auth.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/redash_global/security.py b/redash_global/security.py index f9c2e49948..1b6f9cc45d 100644 --- a/redash_global/security.py +++ b/redash_global/security.py @@ -1,13 +1,24 @@ import os +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from flask_wtf.csrf import CSRFProtect from redash import settings csrf = CSRFProtect() +GLOBAL_THROTTLE_LOGIN_PATTERN = os.environ.get("GLOBAL_THROTTLE_LOGIN_PATTERN", "10/hour") + +# Dedicated Limiter instance rather than reusing redash.limiter, to keep the +# two apps decoupled, but pointed at the same Redis storage so limits hold +# across processes/pods. +limiter = Limiter(key_func=get_remote_address, storage_uri=settings.LIMITER_STORAGE) + def init_app(app): csrf.init_app(app) app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT app.config["SESSION_COOKIE_NAME"] = os.environ.get("GLOBAL_SESSION_COOKIE_NAME", "global_admin_session") + app.config["RATELIMIT_ENABLED"] = settings.RATELIMIT_ENABLED + limiter.init_app(app) diff --git a/redash_global/views/auth.py b/redash_global/views/auth.py index 97bed92278..a4746eed26 100644 --- a/redash_global/views/auth.py +++ b/redash_global/views/auth.py @@ -8,6 +8,7 @@ ) from redash_global.models import GlobalAdminUser +from redash_global.security import GLOBAL_THROTTLE_LOGIN_PATTERN, limiter login_manager = LoginManager() login_manager.login_view = "global.login" @@ -19,6 +20,7 @@ def load_user(user_id): return GlobalAdminUser.query.get(user_id) +@limiter.limit(GLOBAL_THROTTLE_LOGIN_PATTERN, methods=["POST"]) def login_page(): if current_user.is_authenticated: return redirect(url_for("global.index"))