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/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 +``` diff --git a/redash_global/app.py b/redash_global/app.py index 6043b5831c..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,10 +38,32 @@ 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 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/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/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/redash_global/routes.py b/redash_global/routes.py index 970ce5a530..d9d2f0cd39 100644 --- a/redash_global/routes.py +++ b/redash_global/routes.py @@ -1,7 +1,10 @@ from flask import Blueprint +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=["GET"], view_func=logout_page, endpoint="logout") diff --git a/redash_global/security.py b/redash_global/security.py new file mode 100644 index 0000000000..1b6f9cc45d --- /dev/null +++ b/redash_global/security.py @@ -0,0 +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/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..a4746eed26 --- /dev/null +++ b/redash_global/views/auth.py @@ -0,0 +1,43 @@ +from flask import flash, redirect, render_template, request, url_for +from flask_login import ( + LoginManager, + current_user, + login_required, + login_user, + logout_user, +) + +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" +login_manager.login_message = None + + +@login_manager.user_loader +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")) + + 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", "")) + + +@login_required +def logout_page(): + logout_user() + return redirect(url_for("global.login")) 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/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() diff --git a/tests/test_global_auth.py b/tests/test_global_auth.py new file mode 100644 index 0000000000..91237f1191 --- /dev/null +++ b/tests/test_global_auth.py @@ -0,0 +1,90 @@ +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) + + 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.get("/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"]) 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) 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({