Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+119 -121
View File
@@ -28,11 +28,14 @@ import { resetAjuda } from '@/composables/useAjuda';
import { useMenuStore } from '@/stores/menuStore';
import { getMenuByRole } from '@/navigation';
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session';
import { sessionUser, sessionReady, sessionRefreshing, initSession, onSessionEvent } from '@/app/session';
// ✅ separa RBAC (papel) vs Plano (upgrade)
import { denyByRole, denyByPlan } from '@/router/accessRedirects'; // (denyByPlan pode ficar, mesmo que não use aqui)
// ✅ única fonte de verdade pra normalizar role
import { normalizeRole } from '@/utils/roleNormalizer';
// cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null;
@@ -40,9 +43,18 @@ let sessionUidCache = null;
let saasAdminCacheUid = null;
let saasAdminCacheIsAdmin = null;
// cache de globalRole por uid (evita query ao banco em cada navegação)
// V#6 — cache de globalRole por uid com TTL.
// Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role
// mudasse durante a sessão. TTL de 5min força re-fetch periódico.
const GLOBAL_ROLE_TTL_MS = 5 * 60 * 1000;
let globalRoleCacheUid = null;
let globalRoleCache = null;
let globalRoleCacheAt = 0;
// Flags module-level para garantir single-bind (ES modules são singletons,
// então basta uma variável aqui — não precisa poluir window).
let guardsBound = false;
let authListenerBound = false;
// -----------------------------------------
// Pending invite (Modelo B) — retomada pós-login
@@ -63,38 +75,12 @@ function clearPendingInviteToken() {
} catch (_) { }
}
function isUuid(v) {
// Exportadas pra permitir teste unitário sem montar o router inteiro.
export function isUuid(v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}
/**
* ✅ Normaliza roles (aliases) para RBAC.
*
* tenant_admin / admin + kind = 'therapist' → 'therapist'
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
* qualquer outro role → pass-through
*/
function normalizeRole(role, kind) {
const r = String(role || '').trim();
if (!r) return '';
const isAdmin = r === 'tenant_admin' || r === 'admin';
if (isAdmin) {
const k = String(kind || '').trim();
if (k === 'therapist' || k === 'saas') return 'therapist';
if (k === 'supervisor') return 'supervisor';
return 'clinic_admin';
}
if (r === 'clinic_admin') return 'clinic_admin';
// demais
return r;
}
function roleToPath(role) {
export function roleToPath(role) {
// ✅ clínica: aceita nomes canônicos e legado
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin';
@@ -150,7 +136,7 @@ async function isSaasAdmin(uid) {
}
// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
function shouldLoadEntitlements(ent, tenantId) {
export function shouldLoadEntitlements(ent, tenantId) {
if (!tenantId) return false;
const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false;
@@ -193,7 +179,7 @@ async function fetchTenantFeaturesSafe(tf, tenantId) {
}
// util: roles guard (plural) com aliases
function matchesRoles(roles, activeRole) {
export function matchesRoles(roles, activeRole) {
if (!Array.isArray(roles) || !roles.length) return true;
const ar = normalizeRole(activeRole);
@@ -207,10 +193,12 @@ function matchesRoles(roles, activeRole) {
// - O AppMenu lê menuStore.model e não recalcula.
// ======================================================
// V#9 router — skip-fast: evita useMenuStore() + comparações quando o último
// key processado é o mesmo. Reset em SIGNED_OUT/SIGNED_IN garante invalidação.
let lastEnsureKey = null;
async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
try {
const menuStore = useMenuStore();
const isSaas = globalRole === 'saas_admin';
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole);
@@ -222,6 +210,11 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
const safeGlobal = globalRole || 'no-global';
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`;
// V#9 — short-circuit: mesmo key da última chamada → menu já construído nessa nav
if (lastEnsureKey === key) return;
const menuStore = useMenuStore();
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
// Antes, o check era feito antes de garantir que tenant.activeRole
// já tinha sido resolvido corretamente nessa navegação.
@@ -254,6 +247,7 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas);
if (menuMatchesRole) {
lastEnsureKey = key;
return; // cache válido e menu correto
}
@@ -292,14 +286,20 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
const model = getMenuByRole(roleForMenu, ctx) || [];
menuStore.setMenu(key, model);
lastEnsureKey = key;
} catch (e) {
logGuard('[guards] ensureMenuBuilt failed', { error: e?.message });
}
}
// V#9 — invalida o short-circuit do ensureMenuBuilt (chamado em SIGNED_IN/OUT)
function resetEnsureMenuKey() {
lastEnsureKey = null;
}
export function applyGuards(router) {
if (window.__guardsBound) return;
window.__guardsBound = true;
if (guardsBound) return;
guardsBound = true;
router.beforeEach(async (to) => {
const tlabel = `[guard] ${to.fullPath}`;
@@ -353,7 +353,10 @@ export function applyGuards(router) {
// ======================================
let globalRole = null;
if (globalRoleCacheUid === uid && globalRoleCache) {
const cacheAge = Date.now() - globalRoleCacheAt;
const cacheValid = globalRoleCacheUid === uid && globalRoleCache && cacheAge < GLOBAL_ROLE_TTL_MS;
if (cacheValid) {
globalRole = globalRoleCache;
logGuard('profiles.role (cache) =', globalRole);
} else {
@@ -363,6 +366,7 @@ export function applyGuards(router) {
if (globalRole) {
globalRoleCacheUid = uid;
globalRoleCache = globalRole;
globalRoleCacheAt = Date.now();
}
logGuard('profiles.role (db) =', globalRole);
}
@@ -479,6 +483,7 @@ export function applyGuards(router) {
saasAdminCacheIsAdmin = null;
globalRoleCacheUid = null;
globalRoleCache = null;
globalRoleCacheAt = 0;
const ent0 = useEntitlementsStore();
if (typeof ent0.invalidate === 'function') ent0.invalidate();
@@ -556,10 +561,15 @@ export function applyGuards(router) {
const isSaas = globalRole === 'saas_admin';
if (isSaas) {
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/');
// V#10 — usa meta.area como fonte primária; path.startsWith vira fallback
// pra rotas legacy que ainda não declaram meta.area. matchedRouteHasArea()
// checa a cadeia inteira de matched (lida com routes aninhadas).
const matchedHasSaasArea = (to.matched || []).some((r) => r.meta?.area === 'saas' || r.meta?.saasAdmin === true);
const matchedHasDemoArea = (to.matched || []).some((r) => r.meta?.area === 'demo');
const isSaasArea = matchedHasSaasArea || to.path === '/saas' || to.path.startsWith('/saas/');
// Rotas do Tema Demo (no seu caso ficam em /demo/*)
const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/'));
// Rotas do Tema Demo (em DEV)
const isDemoArea = import.meta.env.DEV && (matchedHasDemoArea || to.path === '/demo' || to.path.startsWith('/demo/'));
// Se for demo em DEV, libera
if (isDemoArea) {
@@ -842,93 +852,81 @@ export function applyGuards(router) {
}
});
// auth listener (reset caches) — ✅ agora com filtro de evento
if (!window.__supabaseAuthListenerBound) {
window.__supabaseAuthListenerBound = true;
// V#2 — listener consolidado: session.js é o único registrante de
// supabase.auth.onAuthStateChange. Aqui só nos inscrevemos via onSessionEvent.
if (!authListenerBound) {
authListenerBound = true;
supabase.auth.onAuthStateChange((event, sess) => {
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
const uid = sess?.user?.id || null;
// SIGNED_OUT: zera caches e localStorage tenant
onSessionEvent('SIGNED_OUT', () => {
sessionUidCache = null;
saasAdminCacheUid = null;
saasAdminCacheIsAdmin = null;
globalRoleCacheUid = null;
globalRoleCache = null;
globalRoleCacheAt = 0;
resetEnsureMenuKey();
// ✅ SIGNED_OUT: aqui sim zera tudo
if (event === 'SIGNED_OUT') {
sessionUidCache = null;
saasAdminCacheUid = null;
saasAdminCacheIsAdmin = null;
globalRoleCacheUid = null;
globalRoleCache = null;
try { resetAjuda(); } catch (_) { }
try { resetAjuda(); } catch (_) { }
// limpa localStorage de tenant — sem isso, próximo login restaura
// o tenant do usuário anterior (mesma máquina)
try {
localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant');
localStorage.removeItem('currentTenantId');
} catch (_) { }
// ✅ FIX: limpa o localStorage de tenant na saída
// Sem isso, o próximo login restaura o tenant do usuário anterior.
try {
localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant');
localStorage.removeItem('currentTenantId');
} catch (_) { }
try {
const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate();
} catch { }
try {
const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate();
} catch { }
try {
const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate();
} catch { }
try {
const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate();
} catch { }
try {
const tenant = useTenantStore();
if (typeof tenant.reset === 'function') tenant.reset();
} catch { }
try {
const tenant = useTenantStore();
if (typeof tenant.reset === 'function') tenant.reset();
} catch { }
try {
const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset();
} catch { }
return;
}
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
if (event === 'TOKEN_REFRESHED') return;
// ✅ SIGNED_IN / USER_UPDATED:
// só invalida se o usuário mudou de verdade
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
if (uid && sessionUidCache && sessionUidCache === uid) {
// mesmo usuário -> não derruba caches
return;
}
// user mudou (ou cache vazio) -> invalida dependências
sessionUidCache = uid || null;
saasAdminCacheUid = null;
saasAdminCacheIsAdmin = null;
globalRoleCacheUid = null;
globalRoleCache = null;
try {
const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate();
} catch { }
try {
const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate();
} catch { }
try {
const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset();
} catch { }
// tenantStore carrega de novo no fluxo do guard quando precisar
return;
}
// default: não faz nada
try {
const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset();
} catch { }
});
// SIGNED_IN: só invalida se o usuário mudou de verdade
onSessionEvent('SIGNED_IN', (sess) => {
const uid = sess?.user?.id || null;
if (uid && sessionUidCache && sessionUidCache === uid) return; // mesmo user
sessionUidCache = uid || null;
saasAdminCacheUid = null;
saasAdminCacheIsAdmin = null;
globalRoleCacheUid = null;
globalRoleCache = null;
globalRoleCacheAt = 0;
resetEnsureMenuKey();
try {
const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate();
} catch { }
try {
const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate();
} catch { }
try {
const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset();
} catch { }
});
// TOKEN_REFRESHED: não invalida nada (caso clássico de trocar de aba)
}
}