prompt inicial do projeto completo

This commit is contained in:
2026-07-22 18:45:09 -03:00
parent 6fb920d333
commit b9ede753c4
12 changed files with 64 additions and 18 deletions

View File

@@ -202,7 +202,7 @@ Três identidades distintas, três mecanismos:
| **Morador / operador** | OIDC (Spring Security) + refresh token no keystore do dispositivo | Access 15 min, refresh 30 dias |
| **Admin** | OIDC + MFA obrigatório | Sessão de 8h |
**Busca cega de unidade:** o endpoint de destino recebe bloco + unidade e **nunca** confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida; se não existir, a chamada simplesmente não é atendida. Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em `06-LGPD-E-SEGURANCA.md`.
**Busca cega de unidade:** o endpoint de destino recebe bloco + unidade e **nunca** confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida**inclusive na linha do tempo de estados**: a visita-sombra percorre os mesmos estados nos mesmos tempos sem notificar ninguém (`03-FLUXOS-E-CONTRATOS.md` §2). Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em `06-LGPD-E-SEGURANCA.md`.
## 7. Multi-tenant preparado, não ativado

View File

@@ -56,7 +56,9 @@ CREATE TABLE condominiums (
name text NOT NULL,
address text NOT NULL,
timezone text NOT NULL DEFAULT 'America/Sao_Paulo',
quiet_hours int4range, -- janela de silêncio, ex.: [22,7)
-- janela de silêncio ("quiet_hours" nos demais docs); start > end cruza a meia-noite (22 → 7)
quiet_start smallint CHECK (quiet_start BETWEEN 0 AND 23),
quiet_end smallint CHECK (quiet_end BETWEEN 0 AND 23),
created_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0
);
@@ -82,7 +84,7 @@ CREATE TABLE units (
);
```
`quiet_hours` alimenta a defesa contra "tocar em todos os apartamentos de madrugada": dentro da janela, visitas não-pré-autorizadas vão direto para a fila de operador em vez de acordar moradores.
`quiet_start`/`quiet_end` alimentam a defesa contra "tocar em todos os apartamentos de madrugada": dentro da janela, visitas não-pré-autorizadas vão direto para a fila de operador em vez de acordar moradores. Duas colunas em vez de `int4range` de propósito: a janela típica cruza a meia-noite (22 → 7), e `int4range(22, 7)` é um range inválido no Postgres. A verificação "está na janela?" vive em `shared/validation`, testada nos dois sentidos.
## 3. Pessoas e dispositivos
@@ -258,7 +260,7 @@ CREATE TABLE access_grants (
visit_id uuid NOT NULL REFERENCES visits(id),
gate_id uuid NOT NULL REFERENCES gates(id),
granted_by uuid NOT NULL REFERENCES persons(id),
pin text, -- 6 dígitos, conferência humana
pin_hash text, -- hash do PIN de 6 dígitos; o PIN em claro só existe na tela e na notificação
valid_until timestamptz NOT NULL,
used_at timestamptz,
device_result text, -- resultado do AccessControlDevice (v2)
@@ -361,6 +363,8 @@ CREATE INDEX idx_audit_entity ON audit_log (tenant_id, entity_type, entity_id, c
**Append-only.** Nenhum `UPDATE` ou `DELETE`, garantido por permissão do usuário de aplicação no banco. É a defesa jurídica em caso de autorização indevida.
**Papéis de banco separados — sem isso o revoke é decorativo.** Dono de tabela ignora `REVOKE` e RLS (salvo `FORCE`). Portanto: o Flyway conecta como `portaria` (dono do schema, roda migrations); a aplicação conecta como **`portaria_app`**, papel sem ownership, criado no init do Postgres (ver `05-INFRA-DOCKER.md` §2). Se o backend conectar como dono, nem o append-only do `audit_log` nem as policies de RLS valem nada — e nenhum teste funcional percebe.
## 9. Row Level Security — preparada, desativada
```sql

View File

@@ -42,7 +42,7 @@ Implementada em `shared/state/VisitStateMachine.kt` — a mesma classe roda no a
| Job | Dispara | Ação |
|---|---|---|
| `VISIT_RING_TIMEOUT` | 20s após `TOCANDO` | → `ESCALONADA`, toca para os demais `unit_members` por `ring_order` |
| `VISIT_RING_TIMEOUT` | 20s após `TOCANDO` (kind `VISITA`) | → `ESCALONADA`, toca para os demais `unit_members` por `ring_order` |
| `VISIT_ESCALATE` | 15s após `ESCALONADA` | → `FILA_OPERADOR` se `operator_queue` ativo, senão → `RECADO_EM_VIDEO` |
| `VISIT_QUEUE_TIMEOUT` | 120s em `FILA_OPERADOR` | → `RECADO_EM_VIDEO` |
| `VISIT_EXPIRE` | `expires_at` (10 min) | → `EXPIRADA` |
@@ -66,7 +66,7 @@ VISITANTE BACKEND MORADOR
│ {kind, nome, bloco, │ BUSCA CEGA: │
│ unidade, foto} │ resolve unit_id ou NULL │
├───────────────────────────►│ resposta idêntica sempre │
│◄─── {visitId, roomToken} ──┤ │
│◄──────── {visitId} ────────┤ │
│ │ ── outbox: VisitaCriada ──┤
│ │ │
│ │ push FCM / VoIP+CallKit │
@@ -74,6 +74,8 @@ VISITANTE BACKEND MORADOR
│ │ │
│ 4. WS /ws/visitor/{id} │ │ 5. atende
│◄══ estado em tempo real ══►│◄═══════ WS /ws/app ═══════►│
│ │ sala criada em EM_CHAMADA │
│◄── ROOM_READY + token ─────┼──── ROOM_READY + token ───►│
│ │ │
│◄────── LiveKit: sala compartilhada ────────────────────►│
│ │ │
@@ -83,7 +85,23 @@ VISITANTE BACKEND MORADOR
│ ├── notifica responsável ────►
```
**Passo 3 é o ponto crítico de segurança.** A resposta é byte-a-byte idêntica para unidade existente e inexistente, com o mesmo tempo de resposta (comparação em tempo constante e delay artificial se necessário). Se a unidade não existe, a visita entra em `TOCANDO`, ninguém é notificado, e ela expira normalmente. Quem tem o QR não consegue mapear o prédio.
**Passo 3 é o ponto crítico de segurança.** A resposta é byte-a-byte idêntica para unidade existente e inexistente, com o mesmo tempo de resposta (comparação em tempo constante e delay artificial se necessário). Quem tem o QR não consegue mapear o prédio.
### Visita-sombra — a busca cega vale também para a linha do tempo
Não basta a resposta HTTP ser idêntica: se a visita de unidade inexistente ficasse parada em `TOCANDO` até expirar enquanto a real anda `TOCANDO → ESCALONADA → FILA_OPERADOR`, o atacante distinguiria os dois casos **observando a sequência de estados no WebSocket**. A busca cega tem que valer para a linha do tempo inteira.
Por isso, quando `unit_id` é `NULL` — ou quando a unidade existe mas não tem nenhum dispositivo ativo — a visita percorre **exatamente os mesmos estados, nos mesmos tempos**, sem notificar ninguém:
```
visita-sombra: TOCANDO (20s, sem push) → ESCALONADA (15s, sem push)
├─ operator_queue ativo → FILA_OPERADOR (de verdade,
│ marcada para o operador como "unidade não cadastrada")
└─ sem módulo → RECADO_EM_VIDEO
(listado no admin como "recado sem unidade")
```
Com o módulo de operador, a visita-sombra entra na fila **de verdade**: o operador vê a marcação privada "unidade não cadastrada" e trata como um porteiro humano trataria quem errou o número — pergunta, corrige a unidade e redireciona. Efeito colateral desejável: erro de digitação legítimo ("101A" em vez de "101-A") é **recuperado** em vez de perdido, e a indistinguibilidade fica perfeita — nos dois casos alguém atende.
## 3. Fluxo B — Entrega (funil rápido)
@@ -161,7 +179,7 @@ Na v1 a abertura é humana: quem abre confere o PIN na tela do visitante contra
| `GET` | `/visits/{id}` | Estado atual (fallback de polling se o WS cair) |
| `POST` | `/visits/{id}/message` | Envia recado em vídeo |
| `POST` | `/visits/{id}/cancel` | Visitante desiste |
| `GET` | `/visits/{id}/room-token` | Token LiveKit com escopo da sala |
| `GET` | `/visits/{id}/room-token` | Token LiveKit com escopo da sala — disponível a partir de `EM_CHAMADA`; antes disso, `409` |
### Morador e operador — `/api/v1/app`

View File

@@ -43,8 +43,9 @@ services:
image: postgis/postgis:17-3.5
environment:
POSTGRES_DB: portaria
POSTGRES_USER: portaria
POSTGRES_USER: portaria # dono do schema — usado só pelo Flyway
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD} # lido por init/02-roles.sh → cria portaria_app
volumes:
- pgdata:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
@@ -60,9 +61,13 @@ services:
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/portaria
SPRING_DATASOURCE_USERNAME: portaria
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
LIVEKIT_URL: ws://livekit:7880
# aplicação NUNCA conecta como dona do schema — senão REVOKE e RLS são inócuos (02 §8)
SPRING_DATASOURCE_USERNAME: portaria_app
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_APP_PASSWORD}
SPRING_FLYWAY_USER: portaria
SPRING_FLYWAY_PASSWORD: ${POSTGRES_PASSWORD}
# livekit está em network_mode:host — o nome de serviço não resolve; alcançado via gateway do host
LIVEKIT_URL: ws://host.docker.internal:7880
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
STORAGE_ENDPOINT: http://minio:9000
@@ -73,6 +78,8 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
volumes:
- ./secrets:/secrets:ro
extra_hosts:
- host.docker.internal:host-gateway
depends_on:
postgres: { condition: service_healthy }
minio: { condition: service_started }
@@ -110,6 +117,8 @@ services:
redis: # exclusivo do LiveKit e do rate limit
image: redis:7-alpine # NUNCA para dados que não podem se perder
command: redis-server --save "" --appendonly no
ports:
- 127.0.0.1:6379:6379 # livekit está em host network e alcança via localhost; nunca exponha fora do host
restart: unless-stopped
minio:
@@ -185,6 +194,8 @@ room:
**Redis não é opcional** com múltiplas instâncias do LiveKit: sem ele, o estado das salas se divide silenciosamente entre nós e dois participantes da mesma sala não se enxergam.
**Capacidade de relay do coturn:** a faixa 4916049200 são 40 portas ≈ **20 chamadas simultâneas via TURN**. Suficiente para o piloto (TURN só entra quando o UDP direto falha, tipicamente 1020% das chamadas), mas amplie a faixa em produção junto com o firewall.
## 4. Dimensionamento de banda
Uma chamada de portaria tem 2 participantes, vídeo em 480p a ~600kbps mais áudio. O SFU recebe e reenvia:
@@ -218,6 +229,7 @@ GH_OWNER=sua-org
TAG=latest
POSTGRES_PASSWORD=
POSTGRES_APP_PASSWORD=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
TURN_SECRET=

View File

@@ -93,6 +93,7 @@ Prazos são configuráveis por tenant, com **teto** definido pela plataforma. Um
- Visita criada mesmo com `unit_id` nulo, preservando `unit_input`
- Resposta idêntica em corpo, código e headers
- **Tempo de resposta constante** (delay artificial iguala os caminhos — sem isso o ataque vira timing attack)
- **Linha do tempo de estados idêntica** — a visita-sombra percorre os mesmos estados nos mesmos tempos, sem notificar ninguém (`03-FLUXOS-E-CONTRATOS.md` §2). Sem isso, o atacante distingue os casos observando a sequência no WebSocket, e o tempo constante da resposta HTTP não protege nada
- Nome do morador nunca aparece antes do atendimento
- Rate limit por IP e por dispositivo, com bloqueio progressivo
@@ -128,9 +129,11 @@ REVOKE UPDATE, DELETE ON audit_log FROM portaria_app;
Nem a aplicação comprometida altera o log. Somado ao `sha256` da mídia, isso é o que dá valor probatório ao registro.
**Só funciona se a aplicação não for dona do schema** — dono ignora `REVOKE` e RLS. A aplicação conecta como `portaria_app`; o Flyway, como `portaria` (dono). Ver `02-MODELO-DE-DADOS.md` §8.
### A.6 — Roubo de token
**Mitigação:** JWT de visitante com 15 min, escopo de uma visita, atado ao `gateId` e ao IP de origem. Refresh do morador no Keystore/Keychain com biometria de dispositivo. Rotação de refresh token e revogação por dispositivo no admin.
**Mitigação:** JWT de visitante com 15 min, escopo de uma visita, atado ao `gateId` e à sessão — **não ao IP de origem**: IP de celular muda no meio da sessão (troca de torre, CGNAT) e a amarração derrubaria visitante legítimo no meio do fluxo. A defesa é a posse do token somada à validade curta e ao escopo mínimo. Refresh do morador no Keystore/Keychain com biometria de dispositivo. Rotação de refresh token e revogação por dispositivo no admin.
### A.7 — QR vazado ou clonado

View File

@@ -22,6 +22,7 @@ Um condomínio só entra em produção com todos os itens verificados **em campo
- [ ] **Wi-Fi aberto de visitante** com cobertura medida em cada portaria
- [ ] SSID óbvio: `Portaria-<NomeDoCondominio>`
- [ ] **Captive portal** que abre direto a página do visitante
- [ ] **Portal instrui a abrir no navegador padrão** — o mini-navegador do captive portal (CNA do iOS, e vários Androids) **bloqueia câmera e geolocalização**, as duas permissões de que o fluxo depende. O portal exibe botão/instrução "abrir no Safari/Chrome", e o teste de ativação percorre o fluxo completo **a partir do portal**, não de um navegador já aberto
- [ ] Rede **isolada** da rede administrativa (VLAN separada, sem acesso à LAN interna)
- [ ] Banda mínima 5 Mbps simétricos reservados para o visitante
- [ ] Teste de sinal ≥ -70 dBm no ponto exato onde o visitante fica
@@ -47,6 +48,8 @@ A rede de visitante isolada não é detalhe: uma rede aberta com acesso à LAN d
O botão físico é o que atende quem não tem celular, está sem bateria ou não consegue usar o QR. **Sem ele, o produto exclui uma parcela real de visitantes** — e cria a situação em que alguém legítimo simplesmente não consegue entrar.
**Na v1, o botão é deliberadamente um interfone direto (GSM/4G ou SIP) para o responsável/central — fora da plataforma.** É proposital: ele precisa continuar funcionando exatamente quando a plataforma cai, então não pode depender dela. Integrá-lo como dispositivo da plataforma (disparando o fluxo de visita com operador) é v2, atrás de `AccessControlDevice`.
### 2.4 Aviso legal — obrigatório
- [ ] **Placa física** com o aviso de tratamento de dados, visível antes da coleta