Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions migrations/versions/b8b7fd6b4ffd_add_globaladminuser.py
Original file line number Diff line number Diff line change
@@ -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 ###
36 changes: 36 additions & 0 deletions redash_global/README.md
Original file line number Diff line number Diff line change
@@ -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 <username> --password <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 <global-pod> -- \
env FLASK_APP=redash_global.wsgi_global \
flask create_global_admin <username> --password <password>
```
44 changes: 44 additions & 0 deletions redash_global/app.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
imenattatra marked this conversation as resolved.


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,
Expand All @@ -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
50 changes: 50 additions & 0 deletions redash_global/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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}].")
Original file line number Diff line number Diff line change
@@ -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 (
<Menu selectable={false} mode="vertical" theme="dark" {...props}>
{children}
</Menu>
);
}

export default function GlobalDesktopNavbar() {
return (
<nav className="desktop-navbar">
<NavbarSection className="desktop-navbar-logo">
<div role="menuitem">
<Link href="./">
<img src={logoUrl} alt="Redash" />
</Link>
</div>
</NavbarSection>

<NavbarSection className="desktop-navbar-spacer" />

<NavbarSection>
<Menu.Item key="logout">
{/* Session logout is handled by the server, so bypass the client-side router. */}
<a href="/logout" data-skip-router>
<LogoutOutlinedIcon />
<span className="desktop-navbar-label">Log out</span>
</a>
</Menu.Item>
</NavbarSection>
</nav>
);
}
57 changes: 55 additions & 2 deletions redash_global/client/components/GlobalApplicationArea/index.jsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Work in progress</div>,
},
];

export default function GlobalApplicationArea() {
return <div>Work in progress</div>;
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 <ErrorMessage error={unhandledError} />;
}

return (
<ApplicationLayout>
<Router routes={routes} onRouteChange={setCurrentRoute} />
</ApplicationLayout>
);
}
22 changes: 21 additions & 1 deletion redash_global/client/global.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="<%= htmlWebpackPlugin.options.baseHref %>" />
<title><%= htmlWebpackPlugin.options.title %></title>

<link
rel="icon"
type="image/png"
sizes="32x32"
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-16x16.png"
/>
</head>
<body>
<div id="application-root"></div>
Expand Down
28 changes: 28 additions & 0 deletions redash_global/models.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions redash_global/routes.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading