Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS

Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.

Ver commit.md para descricao completa por sessao.

# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15

# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)

# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql

Total acumulado: 18 migrations (Sessoes 1-10).

# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 22:00:06 -03:00
parent 7c20b518d4
commit d6eb992f71
18 changed files with 1699 additions and 313 deletions
+52
View File
@@ -48,6 +48,20 @@ function buildStoragePath(tenantId, patientId, fileName) {
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
}
// V#51: SHA-256 hex do conteúdo. Calculado no client antes do upload.
async function computeSha256Hex(file) {
if (!file || !window?.crypto?.subtle) return null;
try {
const buf = await file.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hashBuf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
} catch {
return null; // hash é best-effort; falha não bloqueia upload
}
}
// ── Upload ──────────────────────────────────────────────────
/**
@@ -65,6 +79,9 @@ export async function uploadDocument(file, patientId, meta = {}) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
// V#51: hash SHA-256 do conteúdo ANTES do upload (integridade)
const contentSha256 = await computeSha256Hex(file);
// Upload ao Storage
const path = buildStoragePath(tenantId, patientId, file.name);
const { error: upErr } = await supabase.storage
@@ -83,6 +100,7 @@ export async function uploadDocument(file, patientId, meta = {}) {
nome_original: file.name,
mime_type: file.type || null,
tamanho_bytes: file.size || null,
content_sha256: contentSha256,
tipo_documento: meta.tipo_documento || 'outro',
categoria: meta.categoria || null,
descricao: meta.descricao || null,
@@ -311,3 +329,37 @@ export async function getUsedTags() {
}
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
}
// ── V#51: verificação de integridade ──────────────────────────
/**
* Baixa o documento e recalcula o SHA-256, compara com o registrado.
* @returns {{ ok: boolean, expected: string|null, actual: string|null }}
*/
export async function verifyDocumentIntegrity(docId) {
const { data: doc, error } = await supabase
.from('documents')
.select('id, bucket_path, storage_bucket, content_sha256')
.eq('id', docId)
.single();
if (error) throw error;
if (!doc?.content_sha256) {
return { ok: false, expected: null, actual: null, reason: 'sem_hash_registrado' };
}
const { data: blob, error: dlErr } = await supabase.storage
.from(doc.storage_bucket || BUCKET)
.download(doc.bucket_path);
if (dlErr) throw dlErr;
const buf = await blob.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const actual = Array.from(new Uint8Array(hashBuf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return {
ok: actual === doc.content_sha256,
expected: doc.content_sha256,
actual
};
}