Knoxis
O voto secreto de uma igreja precisava ser provado olhando o schema, além de uma promessa numa política.
- django
- postgresql
- react
- typescript
- drf
O problema e as restrições
O Knoxis existe para conduzir um ritual específico e de alto risco: uma igreja elegendo presbíteros e diáconos por voto secreto, do jeito que igrejas presbiterianas fazem há gerações, com cédula única e múltiplos escrutínios de contagem até que toda vaga seja preenchida por maioria de verdade. O cliente queria esse ritual num computador, e a exigência que moldou tudo o resto foi que “secreto” precisava significar algo mais forte que “prometemos não olhar”.
Essa é uma restrição mais afiada do que parece. Um formulário web típico pode logar toda requisição, guardar toda linha do banco para sempre, e ninguém fora do time nunca percebe. O custo desse descuido normalmente é uma query lenta ou uma tabela um pouco inchada. Aqui, o mesmo descuido (guardar quem votou ao lado do que foi votado, mesmo que brevemente, mesmo que “só para debugar”) é a única falha que tornaria o sistema inteiro inútil para uma igreja que existe justamente porque não confia em processo informal e sem documentação para lidar com algo tão sensível.
A segunda restrição era a forma da própria governança eclesiástica, que não se encaixa numa lista plana de “usuários” intercambiáveis. Um presbitério tem múltiplos cargos em eleição ao mesmo tempo, cada um com seu próprio número de vagas; a regra para vencer é maioria simples dos votos, o que rotineiramente exige mais de um escrutínio quando ninguém passa de 50%; e o último escrutínio pode virar “quem tiver mais votos vence”, especificamente para garantir que o processo de fato termine. Nada disso é exótico, mas tudo precisava ser representado fielmente: um cálculo de “maioria” errado por uma vaga, ou um sistema incapaz de levar de forma limpa os mesmos candidatos remanescentes para um segundo escrutínio, minaria a mesma confiança que o desenho de sigilo estava protegendo.
A terceira restrição foi disciplina de escopo contra pressão real para adicionar coisa. O cliente estava aberto a infraestrutura como Redis se o sistema precisasse (uma notificação push em tempo real, uma fila de job em background, um cache distribuído), e a resposta honesta, conferida caso a caso, foi que na escala esperada de uma eleição de uma congregação só, nenhuma dessas necessidades era real ainda. Adicionar Redis “por garantia” teria significado uma quarta peça móvel em produção, mais superfície de ataque, e mais coisa para raciocinar num sistema cuja promessa central é confiabilidade pela simplicidade; throughput bruto nunca foi o ponto.
A quarta restrição era que um CPF (o identificador nacional usado para conferir um eleitor contra o rol de membros da igreja) é em si dado pessoal sensível pela lei brasileira, e a única necessidade real do sistema para ele é uma checagem de sim-ou-não na entrada. Qualquer coisa além desse mínimo, inclusive a tentação de simplesmente guardá-lo “para o caso de ser útil depois”, era um passivo sem funcionalidade correspondente para justificá-lo.
A última restrição foi multi-tenancy desde o dia zero: uma plataforma onde qualquer número de congregações conduz suas próprias eleições, completamente isoladas umas das outras, sem cada uma precisar do próprio banco, do próprio deploy, ou do próprio histórico de migration para manter sincronizado.
Arquitetura
Um eleitor abre o fluxo de votação e se identifica com um CPF. O backend normaliza, valida os dígitos verificadores, calcula um hash HMAC com chave e busca contra o rol de eleitores da igreja para aquela eleição específica: uma única comparação indexada, na mesma velocidade de comparar texto puro, exceto que o CPF em claro nunca é o que está sentado no banco. Uma correspondência emite um ballot_session: um token de curta duração, devolvido num cookie httpOnly, que representa “essa pessoa específica agora pode votar nessa cédula específica” sem ainda dizer nada sobre o que ela vai escolher.
Enviar a cédula é onde a promessa real da arquitetura é cumprida. Numa única transação de banco, o backend trava a linha da sessão de cédula, confirma que ela ainda não foi usada e não expirou, marca como usada, e então escreve em duas tabelas separadas: voter_attendance registra que esse eleitor participou desse escrutínio (tem um ID de eleitor, porque saber o comparecimento e impedir voto duplo exigem isso), e votes registra as escolhas de verdade, para cada cargo aberto, sem ID de eleitor, sem CPF, sem nenhuma referência de volta à sessão de cédula. Não existe chave estrangeira que uma consulta possa seguir de uma tabela para a outra. O timestamp do voto em si é truncado ao minuto, o que silenciosamente reduz o valor de tentar correlacionar “quem estava ativo às 19h42m03s” com “o que foi registrado às 19h42m03s”, é uma defesa imperfeita numa eleição minúscula com intervalos longos entre eleitores, mas real na escala em que esse sistema de fato roda.
Toda tabela dessas, e toda tabela do sistema, carrega um organization_id: a fronteira que mantém a eleição de uma igreja completamente invisível para outra. Essa fronteira é imposta por um mixin que todo viewset de API do sistema é obrigado a herdar, que filtra toda leitura pela organização logada e injeta o tenant em silêncio em toda escrita, ignorando qualquer coisa que um cliente tente mandar em vez disso, sem banco separado nem política de row-level security do Postgres envolvidos. Como essa garantia vive no código da aplicação em vez de no próprio motor de banco, ela é reforçada por uma segunda garantia no nível de teste: um único teste de regressão percorre todo viewset registrado no projeto e derruba a build se algum deles tiver sido escrito sem o mixin.
Enquanto um escrutínio está aberto, o organizador acompanha uma contagem ao vivo numa tela separada, só para admin (nunca visível para eleitores), atualizada consultando o backend a cada três segundos em vez de qualquer mecanismo de push. Um ETag construído a partir do ID do escrutínio, seu status e a contagem corrente de votos faz uma contagem sem mudança custar quase nada ao servidor responder: um 304 Not Modified e nenhuma consulta. Quando o escrutínio fecha, a mesma consulta agregada determina quem passou de maioria real dos votos, e quem ficar sem vaga segue automaticamente direto para o próximo escrutínio, com os mesmos candidatos e as mesmas vagas abertas, até toda posição ser preenchida, no último escrutínio, por quem recebeu mais votos, exatamente como as próprias regras impressas da igreja exigem.

Arquitetura
Decisões
DECISION 01/04 · Voto secreto
Uma promessa de não olhar não é uma garantia. Quebra com um admin curioso, um bug ou uma intimação. Separar as tabelas torna a garantia visível no próprio schema: não existe join que recupere quem votou em quem
Custo aceitoCorrelação temporal residual ainda é possível num escrutínio muito pequeno com intervalo longo entre votos, mitigada truncando o timestamp do voto ao minuto, mas não eliminada. Documentada como limitação aceita desse modelo de ameaça, em vez de resolvida com criptografia mais pesada que o escopo não pede
DECISION 02/04 · Guardar um CPF
A única necessidade real de um CPF depois da importação é bater o que o eleitor digita contra o rol: um hash com chave faz isso numa única consulta indexada sem nunca guardar o valor em claro
Custo aceitoO CPF original se perde para sempre depois de hasheado. Não existe caminho de recuperação, e trocar a chave do hash invalida o matching de toda lista existente, exigindo reimportação. Aceito porque nenhuma função do sistema jamais precisa do CPF de volta em claro
DECISION 03/04 · Isolamento multi-tenant
Um banco só e um caminho de migration é operacionalmente simples para a escala esperada (centenas de igrejas-tenant, em vez de milhares), com backup centralizado e sem multiplicar migration por tenant
Custo aceitoVazamento cross-tenant vira bug de aplicação: o próprio banco não tem nenhuma trava estrutural contra isso. O mixin precisa ser herdado corretamente toda vez, por isso um teste de regressão consolidado percorre todo viewset registrado e falha se algum não herdar dele
DECISION 04/04 · Contagem ao vivo
Na escala esperada (algumas centenas de votos, poucas dezenas de organizadores simultâneos por igreja), polling de três segundos é imperceptível para um humano, e mantém o backend inteiro num processo WSGI simples, sem peça extra (sem Channels, sem worker assíncrono, sem broker de mensagem)
Custo aceitoNão existe push de verdade: o tempo mais rápido que um organizador descobre que um escrutínio fechou é o que sobrar do ciclo de três segundos corrente. Aceito porque nada nesse domínio precisa de atualização abaixo de um segundo
Invariantes
A tabela votes nunca tem uma coluna que identifique o eleitor
Garantido por
o modelo Vote não declara campo voter nem ballot_session, e um teste dedicado de introspecção de schema percorre information_schema.columns e falha em qualquer coluna fora do conjunto permitidoUm eleitor nunca vota duas vezes no mesmo escrutínio
Garantido por
uma constraint única em voter_attendance(round_id, voter_id)Uma requisição pelos dados de eleição de outra igreja retorna 404, nunca 200
Garantido por
um mixin de viewset obrigatório filtra toda consulta pela organização logada, mais um teste de regressão consolidado que percorre todo viewset registrado e confirma que herda do mixinUm CPF nunca é guardado nem logado em texto claro
Garantido por
o único valor persistido é um hash HMAC-SHA256 com chave do lado do servidor; um helper de mascaramento obrigatório é a única forma sancionada de um CPF aparecer perto de uma linha de logUma migration roda duas vezes seguidas sem mudar nada na segunda
Garantido por
verificado em CI: aplica todas as migrations, aplica de novo, confere que a segunda rodada não relata nada pendente
O que quebrou
- Sintoma
- Uma revisão interna de segurança encontrou que o cookie ballot_session (o token de curta duração que liga a identificação do eleitor à cédula ainda não enviada) não estava marcado como Secure em produção, significando que o navegador estaria disposto a mandá-lo por uma conexão sem criptografia, se uma existisse
- Causa raiz
- O cookie era configurado com as mesmas flags em todo ambiente. O desenvolvimento local roda necessariamente sobre HTTP puro, então a flag Secure tinha ficado de fora por completo em vez de condicional, e essa mesma configuração incondicional seguiu direto para a configuração de produção sem ser tocada
- Correção
- A flag Secure passou a ser condicional ao ambiente de deploy (ativa sempre que DEBUG está desligado), na mesma rodada de revisão que também removeu um endpoint não documentado que expunha busca de eleição por ID em bruto, e adicionou uma checagem de validação que faltava no fluxo de desempate
- Prevenção
- As três correções saíram de uma única rodada de auditoria deliberada, em vez de três incidentes separados. O checklist de revisão que encontrou os três agora é item obrigatório antes de qualquer release que toque autenticação ou sessão
Resultados
Stack completa
Backend
- Django 5 + Django REST Framework
- Python 3.12
- Argon2id (hash de senha)
Frontend
- React 18 + Vite
- TypeScript
Dados
- PostgreSQL 16
Infra
- Docker Compose (Nginx + Gunicorn + Postgres)
Tem interesse em um projeto como esse? Entre em contato.
Entrar em contato