fix(superset): corrige embedding de dashboards via guest token
- superset_config.py: adiciona FLASK_APP_MUTATOR que seta g.user a partir do Bearer JWT quando o usuário está como anônimo (necessário porque o papel Public com can_read on Dashboard faz protect() pular verificação JWT) - superset_config.py: TALISMAN_ENABLED=False, X_FRAME_OPTIONS=ALLOWALL para permitir embedding cross-origin via iframe - routes.ts: adiciona session cookie nas chamadas de busca do dashboard além de incluir X-CSRFToken + Cookie no guest token request - SupersetDashboard.tsx: usa bi.onboardbi.com.br como supersetDomain, tela de unavailable com UI adequada (sem iframe de fallback) - BiWorkspace.tsx: link "Abrir em Nova Aba" aponta para bi.onboardbi.com.br - docker-compose.prod.yml: labels HTTP→HTTPS redirect para Superset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1ba3b4b02
commit
4214a2561c
|
|
@ -69,7 +69,7 @@ export function SupersetDashboard({
|
|||
|
||||
sdkRef.current = await embedDashboard({
|
||||
id: embeddedId, // UUID do embedded config (não o slug)
|
||||
supersetDomain: window.location.origin + "/superset",
|
||||
supersetDomain: "https://bi.onboardbi.com.br",
|
||||
mountPoint: containerRef.current,
|
||||
fetchGuestToken: async () => {
|
||||
const { token } = await fetchTokenData();
|
||||
|
|
@ -84,15 +84,10 @@ export function SupersetDashboard({
|
|||
|
||||
setStatus("ready");
|
||||
} catch (err: any) {
|
||||
console.error("[SupersetDashboard] Erro:", err.message);
|
||||
// Se SDK não estiver instalado, usa iframe como fallback
|
||||
if (err.message?.includes("Cannot find module") || err.message?.includes("embedDashboard")) {
|
||||
setStatus("unavailable");
|
||||
} else {
|
||||
setErrorMsg(err.message);
|
||||
console.error("[SupersetDashboard] Erro:", err.message, err);
|
||||
setErrorMsg(err.message || "Erro desconhecido");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
}, [dashboardId, fetchTokenData]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -106,13 +101,16 @@ export function SupersetDashboard({
|
|||
|
||||
if (status === "unavailable") {
|
||||
return (
|
||||
<div className={`rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm ${className}`} style={{ height: heightStyle }}>
|
||||
<iframe
|
||||
src="/superset/login"
|
||||
className="w-full h-full border-0"
|
||||
title="Arcádia Insights — Apache Superset"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
<div className={`flex flex-col items-center justify-center gap-4 rounded-xl border border-yellow-100 bg-yellow-50 ${className}`} style={{ height: heightStyle }}>
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-yellow-800 text-sm">Arcádia Insights indisponível</p>
|
||||
<p className="text-yellow-600 text-xs mt-1">O serviço de BI está iniciando. Tente novamente em alguns segundos.</p>
|
||||
</div>
|
||||
<button onClick={loadDashboard} className="flex items-center gap-2 px-4 py-2 bg-yellow-600 text-white rounded-lg text-sm hover:bg-yellow-700 transition-colors">
|
||||
<RefreshCw className="w-4 h-4" /> Tentar novamente
|
||||
</button>
|
||||
<a href="https://bi.onboardbi.com.br" target="_blank" rel="noopener noreferrer" className="text-xs text-yellow-700 underline">Abrir em nova aba</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -148,7 +146,7 @@ export function SupersetDashboard({
|
|||
|
||||
{showOpenLink && status === "ready" && (
|
||||
<a
|
||||
href="/superset"
|
||||
href="https://bi.onboardbi.com.br"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
|
||||
|
|
|
|||
|
|
@ -2924,7 +2924,7 @@ export default function BiWorkspace() {
|
|||
<p className="text-sm text-gray-500">SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos</p>
|
||||
</div>
|
||||
<a
|
||||
href="/superset"
|
||||
href="https://bi.onboardbi.com.br"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||||
|
|
|
|||
|
|
@ -380,6 +380,12 @@ services:
|
|||
- "traefik.http.routers.superset.tls=true"
|
||||
- "traefik.http.routers.superset.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.superset.loadbalancer.server.port=8088"
|
||||
- "traefik.http.routers.superset-http.entrypoints=http"
|
||||
- "traefik.http.routers.superset-http.rule=Host(`${SUPERSET_DOMAIN:-bi.onboardbi.com.br}`)"
|
||||
- "traefik.http.routers.superset-http.middlewares=superset-https-redirect"
|
||||
- "traefik.http.routers.superset-http.service=superset"
|
||||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
|
||||
profiles: [bi]
|
||||
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -55,8 +55,42 @@ SESSION_COOKIE_SECURE = False # True em prod com HTTPS
|
|||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log"]
|
||||
|
||||
# ── Embedding cross-origin ────────────────────────────────────────────────────
|
||||
# Desabilita Flask-Talisman para permitir embedding via iframe cross-origin
|
||||
# (HTTPS é gerenciado pelo Traefik upstream)
|
||||
TALISMAN_ENABLED = False
|
||||
HTTP_HEADERS = {}
|
||||
X_FRAME_OPTIONS = "ALLOWALL"
|
||||
|
||||
# ── Guest Token (para embedding) ──────────────────────────────────────────────
|
||||
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutos — frontend renova automaticamente
|
||||
GUEST_ROLE_NAME = "Public"
|
||||
GUEST_TOKEN_JWT_ALGO = "HS256"
|
||||
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
|
||||
|
||||
# ── Fix: g.user a partir do Bearer JWT ────────────────────────────────────────
|
||||
# Quando o papel Public tem can_read on Dashboard, o decorator protect() do FAB
|
||||
# trata o endpoint como público e pula a verificação JWT, deixando g.user como
|
||||
# AnonymousUser. Este hook corrige isso: se há um Bearer JWT válido e g.user é
|
||||
# anônimo, carrega o usuário do JWT antes do handler ser invocado.
|
||||
def FLASK_APP_MUTATOR(app): # noqa
|
||||
@app.before_request
|
||||
def _set_g_user_from_bearer_jwt():
|
||||
from flask import g, request # noqa
|
||||
user = getattr(g, "user", None)
|
||||
if user is not None and not getattr(user, "is_anonymous", True):
|
||||
return # já autenticado (ex: guest token via request_loader)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return
|
||||
try:
|
||||
from flask_jwt_extended import decode_token # noqa
|
||||
decoded = decode_token(auth[7:])
|
||||
user_id = decoded.get("sub")
|
||||
if user_id is not None:
|
||||
from superset.extensions import security_manager as sm # noqa
|
||||
jwt_user = sm.get_user_by_id(int(user_id))
|
||||
if jwt_user and jwt_user.is_active:
|
||||
g.user = jwt_user
|
||||
except Exception: # noqa
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
|
|||
|
||||
// Cache do service token (válido por 50 min)
|
||||
let cachedToken: string | null = null;
|
||||
let cachedCsrf: string | null = null;
|
||||
let cachedSession: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function getServiceToken(): Promise<string> {
|
||||
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
|
||||
async function getServiceToken(): Promise<{ token: string; csrf: string; session: string }> {
|
||||
if (cachedToken && cachedCsrf && cachedSession && Date.now() < tokenExpiry) {
|
||||
return { token: cachedToken, csrf: cachedCsrf, session: cachedSession };
|
||||
}
|
||||
|
||||
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
||||
method: "POST",
|
||||
|
|
@ -22,8 +26,20 @@ async function getServiceToken(): Promise<string> {
|
|||
if (!resp.ok) throw new Error(`Falha ao autenticar no Superset (${resp.status})`);
|
||||
const data = await resp.json();
|
||||
cachedToken = data.access_token;
|
||||
|
||||
const csrfResp = await fetch(`${SUPERSET_URL}/api/v1/security/csrf_token/`, {
|
||||
headers: { "Authorization": `Bearer ${cachedToken}` },
|
||||
});
|
||||
if (!csrfResp.ok) throw new Error(`Falha ao obter CSRF token (${csrfResp.status})`);
|
||||
const csrfData = await csrfResp.json();
|
||||
cachedCsrf = csrfData.result;
|
||||
|
||||
// Guardar o cookie de sessão retornado pelo Superset (necessário junto com X-CSRFToken)
|
||||
const setCookie = csrfResp.headers.get("set-cookie") || "";
|
||||
cachedSession = setCookie.split(";")[0]; // ex: "session=xxx"
|
||||
|
||||
tokenExpiry = Date.now() + 50 * 60 * 1000;
|
||||
return cachedToken!;
|
||||
return { token: cachedToken!, csrf: cachedCsrf!, session: cachedSession };
|
||||
}
|
||||
|
||||
export function registerSupersetRoutes(app: Express): void {
|
||||
|
|
@ -36,12 +52,14 @@ export function registerSupersetRoutes(app: Express): void {
|
|||
const { dashboardId } = req.body;
|
||||
if (!dashboardId) return res.status(400).json({ error: "dashboardId é obrigatório" });
|
||||
|
||||
const token = await getServiceToken();
|
||||
const { token, csrf, session } = await getServiceToken();
|
||||
const user = req.user as any;
|
||||
|
||||
// Resolve slug → dashboard ID → embedded UUID
|
||||
// Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique
|
||||
// o admin via user_loader (necessário quando o papel Public tem can_read on Dashboard)
|
||||
const dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
|
||||
});
|
||||
if (!dashResp.ok) {
|
||||
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
|
||||
|
|
@ -50,7 +68,7 @@ export function registerSupersetRoutes(app: Express): void {
|
|||
const dbNumericId = dashData.result?.id;
|
||||
|
||||
const embeddedResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dbNumericId}/embedded`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
|
||||
});
|
||||
if (!embeddedResp.ok) {
|
||||
return res.status(404).json({ error: `Dashboard '${dashboardId}' não tem embedding habilitado` });
|
||||
|
|
@ -67,6 +85,8 @@ export function registerSupersetRoutes(app: Express): void {
|
|||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"X-CSRFToken": csrf,
|
||||
"Cookie": session,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: {
|
||||
|
|
@ -97,7 +117,7 @@ export function registerSupersetRoutes(app: Express): void {
|
|||
try {
|
||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
const token = await getServiceToken();
|
||||
const { token } = await getServiceToken();
|
||||
const resp = await fetch(
|
||||
`${SUPERSET_URL}/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)`,
|
||||
{ headers: { "Authorization": `Bearer ${token}` } }
|
||||
|
|
|
|||
Loading…
Reference in New Issue