# BeryX — Segurança e conformidade

> Documento público. É a resposta-padrão de segurança para RFPs e a fonte da secção
> «Segurança e conformidade» da página `/datavault/rfp` do site. Cada secção diz o que
> garantimos, onde está no código e que teste o prova. Não afirmamos nada que não esteja aqui.

## Acesso read-only mecânico

O BeryX lê metadados, agregados e amostras limitadas — nunca escreve, move nem altera dados
nas fontes do cliente. Não é só convenção nem só permissões pedidas ao cliente: a garantia é
imposta no código.

- **Conectores HTTP** (Jira, Confluence, Databricks, SharePoint, Google Drive, …) só saem para a
  rede pelo `ReadOnlyHttpClient` (`app/infra/connectors/http_guard.py`): GET/HEAD/OPTIONS passam;
  POST/PUT/PATCH/DELETE são recusados **antes** de tocar na rede.
- **Nenhum conector pode expor um método de escrita**: o decorator `enforce_read_only` recusa na
  definição da classe qualquer método com nome de escrita, e um teste percorre todos os conectores
  registados.
- **PostgreSQL**: as sessões de profiling abrem com `default_transaction_read_only=on` e
  `statement_timeout` — uma escrita, mesmo escondida numa função ou view, falha no servidor.
- **Snowflake (varredura de engenharia)**: `assert_catalog_statement` recusa tudo o que não seja
  um único `SHOW`/`SELECT`/`DESCRIBE` antes de chegar ao driver.
- Recomendamos sempre um utilizador de serviço com permissões só de leitura — os passos por fonte
  estão no [guia de conectores](CONECTORES.md).

Testes: `backend/tests/test_http_guard.py`, `backend/tests/test_connectors_registry.py`.

## Isolamento entre clientes (multi-tenant)

Cada cliente é um workspace, e o workspace é a fronteira dos dados. Um pedido autenticado resolve
sempre o workspace da sessão ou do token de API (`get_current_workspace`, `app/api/deps.py`), e
cada rota só lê e altera recursos desse workspace. A equipa da BeryxTech só lê outro workspace com um
acesso de suporte que um administrador desse cliente aprovou. Esse acesso tem prazo, é só de
leitura e fica no registo de auditoria.

**Isolamento provado por teste sobre todas as rotas.** A garantia não depende de alguém se lembrar
de testar cada endpoint novo. O teste percorre as rotas registadas da API, com dois clientes de
teste, A e B, semeados com um recurso de cada espécie:

- **Entre clientes.** Cada rota com um id no caminho recebe os ids de A e é chamada pelo
  administrador de B, o papel mais permissivo, e pelo token de API de B. Nunca pode responder 2xx
  nem 5xx. A recusa tem de ser igual à de um id que não existe, para que B nem saiba se o id de A
  existe. As rotas com o id no corpo ou na query passam pelo mesmo teste. Nas listagens,
  agregados e exports, nenhuma resposta a B pode conter um id ou email de A.
- **Controlo positivo.** O mesmo pedido feito por A chega aos seus recursos. Sem isto, o 404 de B
  não provava nada. O teste imprime a cobertura em cada execução do CI e falha abaixo de 90 % das
  rotas com id de cliente.
- **Sem sessão.** Todas as rotas respondem 401, exceto uma lista explícita e comentada de rotas
  públicas: saúde, login, SSO, webhooks assinados e links públicos por token. Essa lista tem de
  ser igual à tabela de decisão de acesso.
- **Papéis.** O perfil só de leitura recebe 403 em todas as alterações. Ficam de fora só o
  self-service da conta, o pedido de ajuda, o assistente, o feedback e a decisão de confiança em
  lote, que é uma leitura. O token de API só escreve nas operações previstas.
- **Rotas novas.** Uma rota nova com um parâmetro que o teste não sabe preencher faz o teste
  falhar. Só volta a passar quando a rota entra na matriz ou numa lista justificada.
- **Rotas entre workspaces por desenho.** A consola da equipa da BeryxTech, a organização e a pesquisa
  federada são provadas à parte, cada uma na sua fronteira. Por exemplo, um acesso de suporte sem
  aprovação recebe 403 em todas as leituras.
- **O teste também morde.** Um router de exemplo sem filtro de workspace é apanhado pelo mesmo
  varrimento.

Testes: `backend/tests/test_tenant_isolation_matrix.py`. As permissões por domínio dentro de um
workspace estão em `backend/tests/test_access_matrix.py`.

## Amostras: o que sai da fonte e o que fica

O que um scan lê das tuas fontes, onde é processado e o que fica guardado:

- **Perfil nas fontes SQL: agregados.** Nas bases e warehouses SQL, o perfil (contagens, nulos,
  distintos, mínimos e máximos) é calculado por SQL na própria fonte. Só voltam os números e uma
  amostra de até 1 000 linhas das colunas perfiladas (`VALUE_SAMPLE_ROWS`,
  `app/infra/connectors/sql.py`), lida em memória no worker para tirar até 20 valores de exemplo
  por coluna, que alimentam os tipos semânticos e a deteção de PII; só esses valores ficam
  gravados. Até à versão anterior eram 20 valores por coluna, numa query por coluna. Nas
  fontes sem SQL (ficheiros, APIs de SaaS, streaming), o conector lê os registos pela API oficial
  e o perfil é calculado no worker; o [guia de conectores](CONECTORES.md) diz o que lê cada uma.
- **Regras de linha e duplicados: uma amostra de até 5 000 linhas por tabela.** Em cada scan, as
  regras que precisam de linhas e a deteção de duplicados leem no máximo 5 000 linhas por tabela
  (`ROW_FETCH_LIMIT`, `app/domain/scans/service.py`), escolhidas pela amostragem da própria
  fonte (`TABLESAMPLE`/`SAMPLE`), pelo hash da chave primária ou, sem nenhum dos dois, pelas
  primeiras linhas ([método](METODOS.md#amostragem)). É um teto, que vale também para a amostra
  do perfil: o DBA pode baixá-lo por fonte, até um mínimo de 100 linhas (`max_sample_rows`,
  «Boa vizinhança com o DBA»). As linhas são processadas em memória no worker, na UE (Google
  Cloud europe-west1); dessa leitura só ficam gravados os exemplos limitados descritos abaixo.
- **Leituras maiores só quando as pedes.** A qualidade de rótulos (nos datasets em que a
  configuras), o data-diff e a correspondência entre datasets leem até 50 000 linhas por tabela,
  também em memória no worker.
- **O que fica guardado, na UE:** perfis, agregados, resultados de regras e scores, e exemplos
  limitados — por exemplo, até 20 valores por coluna no perfil, até 5 linhas por grupo de
  duplicados e até 5 valores truncados por regra violada. A tabela nunca é copiada para o
  BeryX.
- **Com IA ativa, os exemplos enviados ao modelo seguem a política de IA do workspace.** Por
  omissão vão à Anthropic, que os processa nos EUA com cláusulas contratuais-tipo. Para ficarem na
  UE, desliga a IA ou usa o Claude na tua conta cloud, no Bedrock ou no Vertex (plano Enterprise)
  — ver «Os teus modelos» e «Anonimização antes do LLM», abaixo.
- **Execução no teu Databricks (ADR 0005, degrau B): ainda não corre nos scans.** O pacote
  `beryx-profiler`, que lê a tabela completa na tua conta e devolve só métricas, e o cliente
  da Jobs API já existem no código, mas nenhum scan o submete ainda. Até lá, também no Databricks
  as regras de linha e os duplicados usam a amostra acima.
- **Não temos hoje um modo em que nenhuma linha saia da tua rede** (on-prem ou air-gapped). Um
  runner no teu perímetro (degrau C do mesmo ADR) depende de procura real.

Testes: `backend/tests/test_seguranca_amostras.py` (os números e o estado do Databricks acima são
comparados com o código).

## Enriquecimento proposto: o que sai para cada fornecedor

O [enriquecimento proposto](CAPACIDADES.md#enriquecimento) pergunta a serviços públicos de
referência, ou a uma instância Nominatim do próprio cliente, o valor que falta numa coluna. Cada
fornecedor recebe só o campo a enriquecer, um valor de cada vez, e nunca a amostra completa nem as
outras colunas da linha:

| Fornecedor (chave) | Host | O que recebe | O que devolve |
|---|---|---|---|
| GeoAPI.pt (`geoapi_pt`) | `json.geoapi.pt` | O código postal CP7 | Localidade, concelho e distrito |
| VIES, da Comissão Europeia (`vies`) | `ec.europa.eu` | Só o NIF de pessoa coletiva (1.º dígito 5, 6 ou 9). O NIF de uma pessoa singular não sai | Nome e morada registados |
| Nominatim público, da OpenStreetMap Foundation (`nominatim`) | `nominatim.openstreetmap.org` | Só o código postal espanhol | Localidade e comunidade autónoma |
| Instância Nominatim do cliente (`nominatim_self_hosted`) | O do URL que o administrador indica | A morada, na geocodificação. É o único fornecedor que recebe dados pessoais | Latitude e longitude |

Os países e as moedas comparam-se com as listas ISO guardadas no BeryX, e nada sai.

- **Opt-in por workspace e fail-closed.** Por omissão, nenhum fornecedor recebe nada. Um
  administrador autoriza cada um em «Regras → Fornecedores de enriquecimento»
  (`PUT /api/v1/workspace/enrichment`), e a autorização guarda quem consentiu e quando. Sem
  autorização, a proposta é recusada antes de ler a amostra da fonte. Esta autorização é separada
  da da regra `accuracy`: autorizar o VIES numa não o autoriza na outra.
- **Moradas só para a instância do cliente.** A geocodificação usa só a instância Nominatim do
  próprio cliente. A
  [política de uso do Nominatim público](https://operations.osmfoundation.org/policies/nominatim/)
  pede que não se lhe enviem dados pessoais. Por isso o Nominatim público recebe só o código
  postal espanhol e autoriza-se sem a confirmação de dados pessoais. A mesma política limita o uso
  a 1 pedido por segundo. A OpenStreetMap Foundation guarda os dados dos pedidos no Reino Unido e
  nos Países Baixos ([política de privacidade da OSMF](https://osmfoundation.org/wiki/Privacy_Policy)).
- **Um consentimento por destino.** Para autorizar a instância, o administrador indica o URL
  (`nominatim_url`) e confirma que as moradas podem ser dados pessoais (`confirm_personal_data`).
  Mudar o URL é um destino novo e pede outra vez a confirmação, que regista quem a deu e quando.
  Retirar a autorização apaga o URL. Sem instância configurada e autorizada, nenhuma morada sai.
- **O URL da instância valida-se ao gravar.** Tem de ser https (http só para hosts em
  `DATAVAULT_CONNECTOR_ALLOWED_HOSTS`), sem utilizador nem palavra-passe, sem query nem fragmento
  e com até 500 caracteres. Os hosts do `openstreetmap.org` são recusados, porque são o serviço
  público. A guarda anti-SSRF (`SsrfGuard`, em `backend/app/infra/connectors/http_guard.py`)
  recusa os hosts que resolvem para redes internas (loopback, redes privadas, metadata da cloud),
  salvo se o operador os autorizar em `DATAVAULT_CONNECTOR_ALLOWED_HOSTS`. O mesmo código
  normaliza o URL ao gravar e ao consultar (`backend/app/infra/enrichment/providers.py`), e cada
  consulta passa outra vez pela guarda, com o pinning de DNS.
- **O pedido à instância.** O BeryX chama `<url>/search` com `q` (a morada), `format=jsonv2`
  e `limit=1`, só por GET e com um User-Agent que o identifica. O pedido não leva credencial nem
  chave: uma instância atrás de autenticação não está suportada.
- **Fora da lista de subprocessadores.** Os três serviços públicos não estão na lista pública de
  subprocessadores: só recebem valores de um workspace depois de um administrador desse workspace
  os autorizar, e cada um trata os pedidos segundo os seus próprios termos. A instância Nominatim
  é do próprio cliente, que escolhe o destino.
- **Auditoria.** Cada mudança nos fornecedores autorizados fica no registo de auditoria
  (`enrichment_providers_updated`), com a lista de antes e de depois, se houve confirmação de
  dados pessoais e, quando muda, o host da instância Nominatim.
- **Como sai.** Só pedidos GET, pelo cliente HTTP só-leitura dos conectores, com a guarda
  anti-SSRF e o pinning de DNS. Os serviços públicos têm um intervalo mínimo entre pedidos em
  cada processo da API (0,5 s no VIES e 1 s na GeoAPI.pt e no Nominatim público) e cada proposta
  tem um teto de consultas. As respostas ficam numa cache em memória, separada por workspace e
  com a chave em hash (SHA-256). Os valores nunca vão para os logs.
- **O que fica guardado.** A sugestão guarda os valores propostos com a proveniência (fonte, data
  da consulta e confiança). Na geocodificação, a sugestão guarda só a proveniência e a cobertura:
  nem a morada nem as coordenadas, que a localizariam. As coordenadas propostas só aparecem na
  resposta ao pedido. O SQL da sugestão leva os códigos postais e os NIF de empresas que a
  fonte confirmou, com o valor de referência (no VIES, o nome e a morada registados da empresa).
  Na geocodificação, o SQL não leva nenhuma morada.
- **O que a fonte não confirmou não fica.** Um NIF de pessoa singular, um código postal que a
  fonte não conhece ou um texto livre na coluna não ficam na sugestão. Na resposta ao pedido,
  aparecem mascarados, só com os 3 últimos caracteres. O pull request leva a cobertura e a
  fonte, não a lista de valores.

O BeryX não escreve o valor proposto na fonte: o cliente aplica-o por pull request ou SQL,
depois de aprovar a sugestão ([o que não fazemos](#o-que-não-fazemos-e-porquê)).

Testes: `backend/tests/test_enrichment.py`.

## Credenciais cifradas e rotação de chaves

- As credenciais das fontes e os tokens de integração são cifrados com **Fernet**
  (`app/domain/catalog/crypto.py`) antes de serem gravados. Nunca são devolvidos pela API nem
  escritos em logs; as mensagens de erro dos vendors passam por um filtro que retira segredos.
- **Rotação de chaves**: `DATAVAULT_ENCRYPTION_KEYS` aceita várias chaves (`MultiFernet`): a
  primeira cifra, todas decifram. `backend/scripts/rotate_fernet.py` re-cifra todos os segredos
  com a chave nova em lotes idempotentes; tokens que nenhuma chave abre são reportados só pelo
  `id`, sem nunca serem decifrados para logs (`app/domain/catalog/key_rotation.py`).

Testes: `backend/tests/test_fernet_rotation.py`.

## Proteção anti-SSRF com pinning de DNS

Os URLs das fontes vêm de configuração do cliente. Para que não possam apontar para dentro da
nossa infraestrutura (metadados da cloud, `localhost`, redes privadas):

- `SsrfGuard` (`app/infra/connectors/http_guard.py`) só aceita `https` e recusa qualquer host que
  resolva para um endereço interno. É fail-closed.
- **Pinning de DNS**: o host é resolvido uma única vez, todos os IPs devolvidos são validados e a
  ligação é feita ao IP fixado, com o hostname original no `Host` e no SNI (o certificado TLS
  continua a ser validado contra o nome). Fecha a janela de *DNS rebinding*.
- Cada salto de redirect repete resolução, validação e pinning. Num salto para outra origem
  (esquema, host ou porta), só seguem os headers neutros, como o `Accept` e o `User-Agent`
  (`NEUTRAL_HEADERS`). O `Authorization`, os cookies, os query params e qualquer header de
  credencial próprio, como um `X-API-Key` ou o `X-Tableau-Auth`, ficam para trás.
- **Teto da resposta e prazo total** (cartões #571 e #605). O transporte dos conectores HTTP
  (`pinned_httpx_transport`) lê o corpo em streaming e para no teto do conector: 20 MB no REST e
  nos documentos do SharePoint e do Google Drive, 30 MB no OData e no Dynamics 365 e 32 MB quando
  o chamador não define o seu. Um `Content-Length` acima do teto é recusado antes do corpo. Só pede `gzip` e
  `deflate`, descomprime-os com a saída limitada ao mesmo teto e recusa `br` e `zstd`. O timeout
  conta para o pedido inteiro, da ligação ao último byte, e não por leitura.
- Aplica-se também às integrações de saída que recebem URLs do cliente (catálogo externo,
  OpenLineage, Git self-hosted): validação ao gravar **e** a cada uso.
- **Fontes de dados (bases de dados, warehouses, object storage).** Cada host e URL do config
  volta à guarda (`netguard.check_host`) antes de abrir a ligação, não só ao gravar a conexão
  (`BaseConnector.assert_destination_allowed`, cartão #339). O Postgres liga só aos IPs validados
  (`hostaddr`), pela ordem do resolver, e o MySQL abre o socket para eles, com o certificado
  verificado contra o nome. O pymongo resolve cada nome pela guarda
  (`netguard.guarded_getaddrinfo`, cartão #564) e liga aos endereços validados nessa mesma
  resolução, a monitorização do driver incluída, e nunca abre um socket Unix local, nem para um
  membro que o replica set anuncie (cartão #602). Os outros dialetos revalidam e o driver resolve o
  nome outra vez. Em produção, um nome que não resolve é recusado na criação da conexão e em cada
  revalidação, e os drivers com IP fixado recusam também um socket Unix local e uma lista de
  hosts. O quadro por dialeto está em [CONECTORES.md](CONECTORES.md).
- **Webhooks e tickets.** Os webhooks dos canais de alertas (Slack, Teams e webhook genérico),
  os relatórios agendados nesses canais e os tickets (Jira, Linear, ServiceNow, Azure DevOps,
  GitHub e GitLab, cartão #376) saem todos por `app/infra/network/outbound.py`. O URL é validado ao gravar, na app e na API de IaC, e de novo
  em cada envio, com o IP fixado. No webhook genérico, cada tentativa do retry volta a validar.
  Os redirecionamentos não são seguidos: um `3xx` conta como falha. Um destino recusado não se
  repete e aparece em «Última entrega» como «destino recusado (SSRF)».
- **O URL de um webhook é um segredo**: quem o tiver publica no canal. Não aparece na resposta
  do «Testar canal», em `last_delivery_error` nem nos logs, que guardam só o código HTTP ou o
  tipo do erro. O logger do `httpx`, que em INFO escreveria o URL de cada pedido, fica em
  WARNING. Os excertos das respostas dos fornecedores de tickets saem sem as credenciais.
- `DATAVAULT_CONNECTOR_ALLOWED_HOSTS` isenta também os webhooks e os tickets (por exemplo, um
  recetor na rede do cliente numa instância dedicada). Nos canais, o `https` continua
  obrigatório.

Testes: `backend/tests/test_http_guard_pinning.py`, `backend/tests/test_dynamics_ssrf.py`,
`backend/tests/test_outbound_ssrf.py`, `backend/tests/test_connectors_sql.py`,
`backend/tests/test_security_gates.py`, `backend/tests/test_dns_rebinding_guard.py`,
`backend/tests/test_http_guard_response_limits.py`.

## Perímetro: WAF, rate limiting e DDoS

**Estado:** implementado como código e coberto por testes automáticos, mas **ainda não aplicado
em produção**. O load balancer global (`deploy/lb_apply.sh`) e o Cloud Armor por cima dele
(`deploy/armor_apply.sh`) esperam por uma operação da equipa. Até lá:

- a API e a app são servidas diretamente pelo Cloud Run, e o endereço `*.run.app` de cada
  serviço também responde;
- a contenção de abuso é o rate limit da própria API, em memória e por instância, mais o teto de
  instâncias do Cloud Run;
- não há WAF à frente do serviço.

A ativação é faseada. Primeiro entra o load balancer. Depois o Cloud Armor fica uma semana em modo
de observação, em que nada é bloqueado e se medem os falsos positivos. Só então passa a bloquear.
Esta secção só dirá «ativo» depois do bloqueio em produção.

Com o edge aplicado, o perímetro fica assim:

- **Load balancer**: todo o tráfego público (site, app, API e MCP) entra por um Application Load
  Balancer global da Google, com TLS ≥ 1.2. Os serviços passam a recusar pedidos que não venham
  dele. O LB só encaminha pedidos HTTP(S) bem formados, por isso os ataques volumétricos L3/L4
  ficam na rede da Google. O **Cloud Armor** fica à frente dos backend services do LB.
- **WAF** com as regras OWASP CRS 3.3 geridas pela Google: SQLi, XSS, LFI, RFI, RCE, ataques ao
  protocolo e deteção de scanners, na sensibilidade 1, a mais conservadora.
  - Os endpoints que recebem SQL, YAML ou XML legítimos ficam fora do WAF: import de regras,
    conexões, manifest dbt, OpenLineage, SAML, webhooks assinados, entre outros. São autenticados
    e validados na aplicação.
  - O WAF inspeciona os primeiros 8 KB do corpo de cada pedido.
- **Rate limiting por IP na borda**, com contadores centrais que não dependem do número de
  instâncias:
  - 600 pedidos/min por IP na API;
  - 20 pedidos/min por IP no login, registo, recuperação de password, verificação de email e
    código MFA, com bloqueio de 10 min a quem passar. A força bruta distribuída fica contida
    antes de chegar à aplicação.
- **Adaptive Protection**: deteção de DDoS na camada 7 por machine learning, com alertas.
- **Scanners e métodos**: `TRACE`, `CONNECT` e pedidos a `/.env`, `/.git` e `/wp-*` recebem 403.
- **Isenções**: o rate limit só é saltado com um cabeçalho cujo valor está no Secret Manager
  (a validação de produção e os uptime checks), nunca por IP. O cabeçalho não salta o WAF.
- **Monitorização**: os bloqueios contam-se numa métrica sem amostragem. Acima de 200 em 5 min
  há alerta para a equipa.
- **Defesa em profundidade**: a API mantém o seu rate limit por IP, por token e por endpoint caro.
  O MCP (`mcp.<domínio>`) passa pelo mesmo LB sem Cloud Armor, porque os agentes saem por IPs
  partilhados do fornecedor. É autenticado por token e sujeito ao rate limit da API.

Testes: `backend/tests/test_edge_armor.py` (regras, isenções, idempotência, segredo nunca
impresso). Depois do bloqueio em produção, `deploy/check_edge.sh` prova três casos em cada deploy:
SQLi num query param → 403, path de scanner → 403, rajada ao login a partir de um IP → 429 em
menos de 10 s. Enquanto o Cloud Armor não bloquear, estes três casos aparecem no check só como
informação, sem passar nem falhar.

## Cabeçalhos HTTP e teto de corpo da API

A API responde no seu próprio URL, sem passar pelo nginx da app, e é esse URL que o SDK, o MCP e
um scanner testam. Por isso envia os cabeçalhos de segurança ela própria
(`backend/app/api/security_headers.py`), em todas as respostas, incluindo os erros e os 429:

- `Strict-Transport-Security` (um ano, com subdomínios), `X-Content-Type-Options: nosniff`,
  `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY` e uma `Permissions-Policy` que desliga
  câmara, microfone e localização;
- `Cross-Origin-Resource-Policy: same-site`, exceto nas rotas `/api/v1/public/`, que levam
  `cross-origin` porque um badge entra como imagem em páginas de terceiros;
- nas respostas JSON, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`;
- num pedido com `Authorization`, `Cache-Control: no-store`.

Um cabeçalho que a rota já definiu fica como está: o badge público mantém o seu `Cache-Control` e a
sua CSP, e os SSE o seu `no-cache`.

O corpo de um pedido tem um teto de 4 MB (`api_max_body_bytes`, em
`backend/app/api/body_limit.py`). Um `Content-Length` acima dele recebe 413 sem que a API leia o
corpo. Um corpo sem `Content-Length` é contado à medida que chega e a leitura para no teto. O 413
sai antes da autenticação e traz `detail` e `max_bytes`. As rotas que já tinham teto próprio
mantêm-no (`ROUTE_BODY_LIMITS`): manifest dbt, resultados dbt, OpenLineage, import de regras e
gate RAG. A validação de registos (`POST /datasets/{id}/validate`) tem 2 MB, por padrão de caminho
(`ROUTE_BODY_LIMIT_PATTERNS`).

A app envia os seus pelo nginx, com uma CSP própria da SPA (`frontend/nginx/security-headers.conf`).
A validação de produção (`deploy/validate_prod.sh` §17) verifica HSTS e `nosniff` no `/health` da
API, os seis cabeçalhos da app e, no site, HSTS, `nosniff` e a CSP. A CSP do site fica em
Report-Only até passar uma semana sem violações: o browser não bloqueia nada e envia cada violação
para `/api/csp-report`, que lê no máximo 16 KB por relatório e regista até 20 relatórios por IP em
cada 10 minutos. Testes: `backend/tests/test_http_hardening.py`.

## RGPD: exportação e eliminação self-service

Sem pedido ao suporte:

- **Exportar os dados do workspace** — `GET /api/v1/workspace/export` (administradores).
- **Apagar o workspace** — `DELETE /api/v1/workspace`: eliminação em cascata de conexões,
  datasets, perfis, scans, relatórios, integrações e credenciais
  (`delete_workspace_cascade`, `app/domain/catalog/erasure.py`).
- **Apagar a própria conta** — `DELETE /api/v1/auth/me`: anonimiza a autoria nos registos que
  têm de sobreviver (ex.: trilho de auditoria) e apaga o resto (`erase_user`).
- **Certificado de eliminação** — apagar o workspace emite um certificado assinado (Ed25519, com a
  data de emissão dentro do que é assinado). Conta as linhas de cada categoria de dados antes e
  depois da cascata, na mesma transação, e diz até quando os backups guardam os dados apagados.
  Fica no trilho da plataforma (`staff_events`, sem emails) e segue por email para os admins, em
  JSON e PDF (`app/domain/compliance/deletion_certificate.py`). Verifica-se com
  `backend/scripts/verify_deletion_certificate.py`.
- **Retenção por workspace** — `GET`/`PUT /api/v1/workspace/retention` (administradores): prazos
  para o histórico de perfis (nunca acima da janela do plano), os relatórios IA e as amostras de
  linhas. A purga diária do worker aplica-os e deixa `retention_purged` na Auditoria, com os
  cortes e as contagens (`app/domain/billing/retention_policy.py`,
  `app/domain/billing/retention.py`).

A política de privacidade e os termos estão publicados no site (`/privacidade`, `/termos`).

## Analytics de produto: opt-in e sem dados pessoais

A app só mede que páginas e funcionalidades se usam quando um admin do workspace liga a partilha.
Vem desligada (`analytics_enabled`), e o admin responde uma vez depois do registo. Pode mudar a
escolha em Equipa & Acessos, onde está a lista exata do que se recolhe (`GET`/`PUT
/api/v1/workspace/analytics`). Cada mudança fica na Auditoria (`analytics_consent_changed`).

- **O que se grava** — o workspace, o plano, o nome do evento, o modelo da página
  (`/datasets/:datasetId`, nunca o URL com ids) e a hora (`product_events`). Não se grava o
  utilizador, o email, o IP, o user-agent nem texto escrito. Os eventos, as rotas e as propriedades
  vêm de listas fechadas (`app/domain/analytics/events.py`). O que não está nelas é descartado.
- **Receção** — `POST /api/v1/events/product`, em lotes de até 50 eventos, com limite próprio de
  pedidos (`product_events`, 60 por minuto). Responde sempre 204: sem a partilha ligada não grava.
- **Prazo** — 90 dias, pela purga diária (`app/domain/billing/retention.py`). Desligar a partilha
  apaga logo os eventos do workspace (`app/domain/analytics/service.py`). Os eventos saem no export
  do workspace e na eliminação em cascata.
- **O que a BeryxTech vê** — na Consola, só percentagens por plano e contagens por página dos últimos
  30 dias. Uma célula com menos de 5 workspaces aparece sem números, e os workspaces da BeryxTech.
  ficam de fora (`app/domain/analytics/adoption.py`).

## Rotulagem de conteúdo de IA (art. 50.º do AI Act)

Todo o conteúdo produzido com um modelo de linguagem — relatórios, sugestões, diagnósticos,
respostas do assistente — leva um rodapé que identifica que foi gerado com apoio de IA, **qual
modelo e qual fornecedor responderam de facto**, e que está sujeito a revisão humana, nos termos
do art. 50.º do Regulamento (UE) 2024/1689 (`ai_generated_footer`,
`app/domain/insights/templates.py`). O modo offline (sem IA) leva um rodapé próprio que o diz.

**Marcação legível por máquina (art. 50.º, n.º 2).** O texto gerado leva também três campos,
calculados num só sítio (`app/domain/insights/ai_marking.py`): `ai_generated`, `ai_model` e
`generated_at`. Saem:

- na API, nos relatórios e diagnósticos RCA, nas sugestões e no README do dataset (rascunho e
  versão publicada);
- no evento `done` do assistente, que também nomeia o modelo que respondeu;
- no export HTML e na partilha pública, como `<meta name="ai-generated">`, `ai-model` e
  `ai-generated-at`, com o rótulo visível «Gerado por IA (modelo, data)»;
- nos metadados do PDF (`aigenerated`, `aimodel`, `aigeneratedat`), no front matter do
  `README.md` exportado e nos cartões Slack e Teams dos relatórios agendados (rótulo visível).

O texto do modo offline sai com `ai_generated=false`. O nome do modelo é limpo antes de sair: um
ARN ou um caminho de projeto da conta cloud do cliente fica reduzido à família do modelo ou a
`undisclosed`. No assistente, o painel diz desde a primeira interação que se fala com um sistema
de IA (art. 50.º, n.º 1). Testes: `backend/tests/test_ai_marking.py` e
`frontend/src/pages/Assistente.dom.test.tsx`.

Para clientes com sistemas de IA de risco elevado, o BeryX exporta também o **dossiê de
evidência do art. 10.º** (governação de dados de treino, validação e teste), com as lacunas
explícitas (`app/domain/compliance/ai_act.py`, teste `backend/tests/test_ai_act_art10.py`).

## Ações da IA

O assistente do chat pode propor ações no workspace: criar uma regra, registar, atribuir ou adiar
um issue, pedir a revisão da classificação de PII, abrir um PR de remediação, entre outras.
**Propor nunca executa.** O utilizador vê um cartão com o que vai acontecer e só a aprovação o
executa, num endpoint que volta a verificar a assinatura do cartão (HMAC, válida 24 h), o papel, o
plano e a política, e grava o evento de auditoria `assistant_action_executed`.

Cada workspace tem uma **política das ações da IA** (Equipa → Ações da IA, só administradores). Por
tipo de ação, decide:

- **se está ligado.** Desligado, sai das ferramentas do assistente e a execução é recusada;
- **quem propõe e quem aprova** (membro ou administrador). Quem aprova nunca fica abaixo do papel
  que a API exige para a mesma operação. Quem pode propor mas não aprovar envia um **pedido**, que
  um administrador aprova ou recusa. Os pedidos valem 7 dias;
- **a auto-aprovação**, só para os tipos marcados como seguros (adiar um issue, atribuir um issue a
  si próprio) e desligada por omissão. Mesmo sem clique, a execução fica no trilho, marcada
  `auto_approved`. A auto-aprovação também respeita os papéis por domínio (ADR 0011): só corre
  quando quem pede pode alterar o dataset do issue, a mesma verificação do «Aprovar». Num domínio
  restrito onde é leitor, ou que não vê, o cartão fica à espera do «Aprovar», que o recusa;
- **o limite diário** no workspace (dia UTC). No limite, o assistente explica-o no chat e a
  aprovação responde 429 com o motivo;
- **os destinos permitidos** (por exemplo, os repositórios git), **os campos obrigatórios** e **o
  modelo** do corpo.

Leitores nunca recebem propostas e um token de API nunca aprova. Sem alterações, valem as
permissões da API, como antes da política existir. Cada alteração fica no registo de auditoria
(`assistant_policy_changed`, com o antes e o depois), tal como cada pedido
(`assistant_action_requested`) e cada recusa (`assistant_action_request_rejected`). O mesmo ecrã
decide o que o turno diário do steward pode propor e aplicar sozinho.

Implementação: `app/domain/assistant/policy.py`. Testes:
`backend/tests/test_assistant_action_policy.py`.

## Endpoint público da app Slack

A app Slack do BeryX (cartão #193) é criada pelo cliente no workspace Slack dele, a partir de
um manifesto com três scopes de bot: `chat:write`, `app_mentions:read` e `im:history`. Recebe as
menções e as mensagens diretas ao bot em `POST /api/v1/webhooks/slack/events/{integration_id}`,
um endpoint público, sem sessão nem token de API. A autenticação é a assinatura do Slack:

- **Assinatura sobre o corpo cru.** O endpoint verifica `X-Slack-Signature` (`v0=` + HMAC-SHA256
  com o signing secret da integração sobre `v0:{ts}:{raw_body}`) em tempo constante, antes de
  interpretar o JSON (`app/infra/collaboration/slack.py`). Uma assinatura inválida recebe 401, e
  o desafio `url_verification` só é respondido depois desta verificação.
- **Janela de replay de 5 minutos.** Um `X-Slack-Request-Timestamp` a mais de 5 minutos do
  relógio do servidor recebe 401, mesmo com a assinatura certa.
- **Equipa, canal e dedupe.** O `team_id` do evento tem de ser o que o Slack devolveu ao gravar a
  integração. Só passam menções e mensagens na DM com o bot, nunca de bots, e só nos canais que
  um administrador autorizou. Um `event_id` repetido não é processado duas vezes.
- **Tenant suspenso.** Com o workspace suspenso pela BeryxTech, o endpoint não grava nem enfileira
  as perguntas, o worker fecha as que já estavam na fila sem chamar o modelo nem o Slack, e as
  publicações que esperavam envio ficam por enviar. É a mesma regra das sessões e dos tokens de API.
- **Ack imediato.** O endpoint grava a pergunta cifrada com Fernet, publica-a numa fila e responde
  200. O assistente corre no worker, só de leitura e com as permissões de quem ligou a conta Slack
  à conta BeryX. A pergunta é apagada quando o pedido fecha.
- **Saída limitada.** O cliente Slack só chama `chat.postMessage`, `chat.postEphemeral` e
  `auth.test`, e recusa outro método antes de ir à rede. As publicações proativas levam só ids,
  contagens, nomes do catálogo, severidade e links, por uma lista fechada de chaves. Nas respostas
  do bot, a regra de não copiar valores nem amostras é uma instrução ao modelo. O filtro mecânico
  é o guardrail de saída da IA, que mascara emails, telefones, NIF/IBAN, cartões, IPs e segredos,
  e o escape do mrkdwn, para que nenhuma resposta notifique um canal inteiro. Outros valores de
  colunas só ficam de fora pela instrução.

Implementação: `app/domain/collaboration/inbound.py`. Guia de instalação:
[SLACK_APP.md](SLACK_APP.md). Testes: `backend/tests/test_slack_app.py`.

## Os teus modelos: chave própria, Amazon Bedrock e Vertex AI

- **Chave Anthropic própria** por workspace (cifrada como qualquer credencial).
- **Política de IA** por workspace: «IA desligada» (só heurísticas e templates offline) ou
  restrição de modelos por tarefa.
- **Claude na conta cloud do cliente** (plano Enterprise): Amazon Bedrock, só regiões `eu-*`, via
  `sts:AssumeRole` com `ExternalId` por workspace — [onboarding Bedrock](BYOK_BEDROCK.md); ou
  Google Cloud Vertex AI, só regiões `europe-*`, por impersonação de uma service account do
  cliente, sem chaves JSON — [onboarding Vertex](BYOK_VERTEX.md). Os dados e a fatura dos tokens
  ficam na conta do cliente; a BeryxTech não guarda segredos dessa rota.
- Se a rota do cliente falhar, a IA degrada para os modos offline — nunca cai para a conta da
  BeryxTech contra a escolha do cliente.

## Anonimização antes do LLM

- **Por omissão, só a Anthropic recebe dados de clientes.** Um fornecedor alternativo nunca
  recebe amostras em bruto: só metadados (nomes de colunas, tipos, contagens) ou, se o workspace o
  ativar expressamente, amostras já anonimizadas.
- As amostras enviadas a um modelo são limitadas em tamanho e podem ser anonimizadas antes de
  sair: o motor (Microsoft Presidio, com reconhecedores portugueses para NIF, NISS, IBAN/NIB,
  telefone e Cartão de Cidadão) substitui os valores por marcadores reversíveis só dentro da
  sessão (`app/domain/llm/anonymize.py`).
- O encaminhamento recusa qualquer degrau não-Anthropic que não receba a amostra já anonimizada
  (`app/domain/llm/routing.py`).

Testes: `backend/tests/test_llm_anonymize.py`.

## Limites de tentativas no início de sessão

O login por password tem três limites, em `backend/app/api/ratelimit.py`:

- **Por IP e email:** 5 tentativas por minuto.
- **Por IP:** 30 tentativas em 5 minutos, somadas entre o login, o registo e o pedido de recuperação
  de password. Trava o *password spraying*, em que uma password fraca é tentada em muitos emails a
  partir do mesmo IP.
- **Por conta:** 20 tentativas em 15 minutos, de qualquer IP. Trava a força bruta distribuída por
  muitos IPs contra a mesma conta. A chave é o sha256 do email e vale igual para emails sem conta. O
  pedido de recuperação de password tem um limite por conta à parte, com os mesmos números.

Os três verificam-se antes de a tentativa contar, por isso um 429 num deles não gasta os outros. Uma
entrada com a password certa não gasta nenhum. Acima do limite, a resposta é 429 com `Retry-After` e
a mesma mensagem, exista a conta ou não, e sai antes de a API procurar a conta. Quando o limite por
conta dispara, o registo de auditoria do workspace da conta recebe `login_throttled`, com o hash do
email e o IP, nunca a password, uma vez por conta em cada janela.

Quem sabe o email de uma conta consegue travar-lhe o login por password durante 15 minutos. O SSO e o
pedido de recuperação de password continuam a funcionar nesse tempo.

Pela aplicação web, o limite por IP conta o IP do browser, que o servidor web atesta à API com uma
chave partilhada. Em produção, a API não arranca sem essa chave. Fora de produção e sem ela, a API vê
o mesmo IP em todos os pedidos da aplicação. Para que as
falhas de uns utilizadores não travem o login aos outros, um pedido que se apresente como vindo do
servidor web fica então fora do limite por IP. Os limites por IP e email e por conta aplicam-se na
mesma. Em produção, a chave está ativa desde 11/10/2026.

Nos limites por IP da API, um endereço IPv6 conta pelo seu bloco /64 e um IPv4 pelo endereço
completo. Quem tem um bloco IPv6 escolhe a parte final do endereço, e mudar de endereço dentro do
bloco não abre um limite novo.

Os contadores ficam em memória em cada instância da API: com 3 instâncias, cada limite pode valer até
o triplo. A contenção central, por IP real, é a do Cloud Armor (ver «Perímetro»), ainda por aplicar
em produção.

Testes: `backend/tests/test_security_gates.py` e `backend/tests/test_ipv6_rate_limit.py`.

## Sessões

Cada início de sessão fica registado com o dispositivo, o IP truncado (/24 em IPv4, /48 em IPv6),
a data de criação e o último uso; o token nunca é guardado (`backend/app/domain/auth/sessions.py`).
Em «Segurança da conta → Sessões ativas», cada pessoa vê onde tem sessão aberta, termina uma sessão
ou todas as outras. «Terminar sessão» acaba a sessão também no servidor.

- **Inatividade.** Uma sessão sem pedidos durante 12 horas termina. O administrador do workspace pode
  exigir menos, até 15 minutos, em «Equipa → Sessões». A mudança vale para as sessões já abertas. Um
  pedido feito sem interação há mais de um minuto não conta como atividade: é o caso das páginas com
  atualização ao vivo e da ligação do sino, que volta a abrir sozinha a cada 4 minutos.
- **Logout forçado.** O administrador termina todas as sessões de um membro em «Equipa». A pessoa
  pode voltar a entrar com as credenciais dela.
- **Password e MFA.** Redefinir a password, ativar ou desligar o MFA e regenerar os códigos de
  recuperação terminam todas as sessões da conta.
- Cada revogação, logout forçado, troca de password e mudança do timeout fica no registo de auditoria.

## Tokens de API

Um token `dvk_*` só fica guardado como SHA-256, e o valor só aparece uma vez, ao criar ou ao rodar
o token (`backend/app/domain/access/token_lifecycle.py`).

- **Prazo.** Cada token novo expira: 90 dias por omissão, ou entre 1 e 365 dias, à escolha do
  administrador. Um token expirado responde 401 em qualquer rota, com a mesma mensagem de um token
  inválido. A instalação pode aceitar tokens sem prazo (`DATAVAULT_API_TOKEN_ALLOW_NO_EXPIRY`); por
  omissão, não aceita. Os tokens criados antes desta regra não têm prazo e continuam a funcionar até
  o administrador os rodar.
- **Rotação.** «Rodar», em «Equipa → Tokens de API», cria um token novo com o mesmo nome e o mesmo
  scope. O antigo continua a funcionar durante o período de graça (24 horas por omissão), para se
  trocar o valor na pipeline sem a partir. «Revogar» corta-o logo. A rotação fica no registo de
  auditoria (`token_rotated`).
- **Último uso.** A lista de tokens mostra o último uso e o último IP de cada token, atualizados no
  máximo uma vez por minuto. O IP fica na base de dados e só o administrador o vê: não entra nos
  logs nem no registo de auditoria.
- **Avisos.** Um token ativo há mais de 180 dias gera um alerta para os administradores. O resumo
  semanal dos canais de notificação diz quantos tokens expiram nos 14 dias seguintes.

Testes: `backend/tests/test_api_token_lifecycle.py`.

## Restrições de acesso da organização

O administrador da organização define, em «Equipa → Organização → Restrições de acesso», regras que
valem para todos os workspaces da organização (`backend/app/domain/organizations/access_policy.py`):

- **Allowlist de IPs.** Uma lista de IPs e blocos CIDR, IPv4 e IPv6. Com a lista preenchida, um IP de
  fora é recusado no início de sessão (password, links de email, segundo passo do MFA e SSO), antes de
  a sessão ser emitida, e em cada pedido autenticado: sessões abertas, tokens de API e o Copilot do
  Microsoft 365, que chega da rede da Microsoft. O IP é o que o rate limit usa, por isso um
  `X-Forwarded-For` forjado não passa. Quem é recusado recebe uma mensagem com o IP visto e o que fazer;
  uma password errada continua a dar o erro de sempre, sem revelar a política. A sessão não termina:
  de volta a uma rede autorizada, continua.
- **Tokens de API isentos pelo nome.** Um token com o nome indicado, no workspace indicado, entra de
  qualquer rede, por exemplo o CI com IPs dinâmicos. Cada isenção leva o motivo escrito pelo
  administrador. Rodar o token, ou seja criar outro com o mesmo nome e revogar o antigo, mantém a
  isenção. Um token com o mesmo nome noutro workspace não fica isento.
- **Duração máxima das sessões.** Entre 1 hora e a validade da instalação (12 horas por omissão),
  contada desde o início de sessão, com ou sem atividade. Baixar o valor termina já as sessões que o
  passaram; o ecrã de login diz porquê.
- **MFA obrigatória em toda a organização.** Quem entra com password tem de configurar o MFA no
  próximo início de sessão. Quem entra por SSO usa a MFA do fornecedor de identidade.
- **Anti-bloqueio.** Guardar uma lista que não inclui o IP de quem guarda é recusado. Se mesmo assim
  todos ficarem de fora, por exemplo porque o IP do escritório mudou, o operador da BeryxTech desliga a
  lista com `backend/scripts/clear_ip_allowlist.py`, depois de confirmar o pedido com o cliente.
- Cada mudança fica no registo de auditoria de todos os workspaces da organização. Cada recusa por IP
  fica no do workspace da conta ou do token, e a secção mostra as últimas 20 recusas da organização.
  Num pedido de uma sessão ou de um token já autenticados, fica uma recusa por conta, IP e origem em
  cada 15 minutos.

O acesso de suporte da BeryxTech continua a seguir o consentimento do cliente (ver
[Isolamento entre clientes](#isolamento-entre-clientes-multi-tenant)) e não passa pela allowlist da
organização do cliente.

## Registo de auditoria

As ações de administração (papéis, planos, conexões, credenciais, tokens, sessões, política de IA, política
das ações da IA, acessos de suporte) e as ações do assistente aprovadas, pedidas ou recusadas ficam
num registo de auditoria gravado na mesma transação da mudança, consultável e exportável em CSV
pelos administradores do workspace. Cada evento fica 400 dias: uma purga diária do worker apaga os
mais antigos e deixa `audit_log_purged` no registo, com o corte e a contagem
(`app/domain/audit/retention.py`).

## Subprocessadores e fornecedores

A lista de subprocessadores é pública em `/subprocessadores` (pt/en): finalidade, região, base
legal, mecanismo de transferência e DPA de cada um. Um fornecedor novo, uma saída ou uma mudança de
região é anunciada com 30 dias de antecedência. Os administradores podem receber o aviso por email
(Conformidade → Aviso de subprocessadores). Durante o pré-aviso, um fornecedor de IA novo não recebe
dados: o router só envia para subprocessadores ativos da lista. A avaliação de risco de cada
fornecedor (certificações declaradas, dados que recebe, plano de saída) está em
`docs/compliance/vendors/`.

## Certificações: ISO 27001 e SOC 2

**Não temos certificação ISO 27001 nem relatório SOC 2.** Estão em programa: os controlos
descritos neste documento são a base desse trabalho. Até lá, respondemos às linhas de RFP com a
evidência técnica acima, e não com selos — e não usamos «conforme» nem «alinhado com» como
substituto de uma certificação que não temos.

O estado público de cada certificação (planeada, em preparação, auditoria agendada, obtida — com
datas) vive num único ficheiro, `docs/compliance/trust-status.json`, que o site lê no build para a
página `/datavault/seguranca` e para o `/.well-known/security.txt`. O `backend/tests/test_trust_status.py`
falha se esse estado divergir desta secção ou do ADR 0010.

Um dos controlos já operacionais é a **gestão de vulnerabilidades**
([compliance/vulnerability-management.md](compliance/vulnerability-management.md)). Inclui:

- SCA das dependências Python e npm e scan das imagens com o Trivy em cada PR;
- falha do CI em critical/high sem exceção registada;
- SLA de 7, 30 e 90 dias por severidade;
- SBOM CycloneDX por deploy;
- um relatório mensal em `docs/compliance/evidence/`;
- DAST semanal (OWASP ZAP baseline, só passivo) ao site, à app e à API, com relatório semanal e
  o mesmo registo de exceções.

Os achados de todas as fontes (scanners, DAST, pen test externo e divulgação responsável, pelo
`/.well-known/security.txt`) seguem um processo com SLA
([compliance/security-findings.md](compliance/security-findings.md)). **Ainda não fizemos um pen
test externo.** O âmbito, as contas de teste e as regras estão preparados
([compliance/pentest.md](compliance/pentest.md)); falta contratar o fornecedor.

## O que não fazemos, e porquê

- **Remediação destrutiva nas fontes.** Propomos a correção (SQL/PySpark, ou um pull request no
  repositório do cliente); quem aplica é o cliente. Pedir credenciais de escrita seria a objeção
  n.º 1 de qualquer equipa de segurança — é uma decisão de produto, não uma limitação.
- **MDM / golden record com escrita.** Detetamos duplicados e propomos a consolidação, mas não
  mantemos um registo-mestre nem escrevemos a versão «vencedora» nas fontes.
- **Análise multimodal de imagens.** Os documentos são lidos pela camada de texto; imagens não são
  processadas, e um PDF digitalizado sem texto é assinalado como a precisar de OCR
  (`app/domain/quality/documents.py`) em vez de ser enviado a um modelo de visão.

## English

### BeryX — Security and compliance

> Public document. It is the standard security answer for RFPs and the source of the
> "Security and compliance" section of the site's `/datavault/rfp` page. Each section says what
> we guarantee, where it is in the code and which test proves it. We claim nothing that is not here.

#### Mechanical read-only access

BeryX reads metadata, aggregates and limited samples — it never writes, moves or changes data
in the customer's sources. It is not just a convention, nor just permissions asked of the customer:
the guarantee is enforced in the code.

- **HTTP connectors** (Jira, Confluence, Databricks, SharePoint, Google Drive, …) only reach the
  network through the `ReadOnlyHttpClient` (`app/infra/connectors/http_guard.py`): GET/HEAD/OPTIONS
  pass; POST/PUT/PATCH/DELETE are refused **before** touching the network.
- **No connector can expose a write method**: the `enforce_read_only` decorator refuses, at class
  definition, any method with a write name, and a test goes through every registered connector.
- **PostgreSQL**: profiling sessions open with `default_transaction_read_only=on` and
  `statement_timeout` — a write, even one hidden in a function or view, fails on the server.
- **Snowflake (engineering sweep)**: `assert_catalog_statement` refuses anything that is not a
  single `SHOW`/`SELECT`/`DESCRIBE` before it reaches the driver.
- We always recommend a service user with read-only permissions — the steps for each source are in
  the [connectors guide](CONECTORES.md).

Tests: `backend/tests/test_http_guard.py`, `backend/tests/test_connectors_registry.py`.

#### Isolation between customers (multi-tenant)

Each customer is a workspace, and the workspace is the data boundary. An authenticated request
always resolves the workspace of the session or of the API token (`get_current_workspace`,
`app/api/deps.py`), and each route only reads and changes resources of that workspace. The BeryxTech.
team only reads another workspace with support access that an administrator of that customer has
approved. That access has a time limit, is read-only and is recorded in the audit log.

**Isolation proved by a test over every route.** The guarantee does not depend on someone
remembering to test each new endpoint. The test goes through the API's registered routes, with two
test customers, A and B, seeded with one resource of each kind:

- **Between customers.** Each route with an id in the path receives A's ids and is called by B's
  administrator, the most permissive role, and by B's API token. It can never respond 2xx or 5xx.
  The refusal must be the same as for an id that does not exist, so that B does not even learn
  whether A's id exists. Routes with the id in the body or in the query go through the same test.
  In listings, aggregates and exports, no response to B may contain an id or email of A.
- **Positive control.** The same request made by A reaches its resources. Without this, B's 404
  would prove nothing. The test prints the coverage on every CI run and fails below 90% of the
  routes with a customer id.
- **No session.** Every route responds 401, except an explicit, commented list of public routes:
  health, login, SSO, signed webhooks and public links by token. That list must match the access
  decision table.
- **Roles.** The read-only role gets 403 on every change. The only exceptions are account
  self-service, the help request, the assistant, feedback and the bulk trust decision, which is a
  read. The API token only writes in the intended operations.
- **New routes.** A new route with a parameter the test does not know how to fill makes the test
  fail. It only passes again when the route enters the matrix or a justified list.
- **Cross-workspace routes by design.** The BeryxTech team's console, the organisation and federated
  search are proved separately, each at its own boundary. For example, support access without
  approval gets 403 on every read.
- **The test also bites.** An example router without a workspace filter is caught by the same
  sweep.

Tests: `backend/tests/test_tenant_isolation_matrix.py`. Per-domain permissions within a workspace
are in `backend/tests/test_access_matrix.py`.

#### Samples: what leaves the source and what stays

What a scan reads from your sources, where it is processed and what is stored:

- **Profile on SQL sources: aggregates.** On SQL databases and warehouses, the profile (counts,
  nulls, distinct values, minimums and maximums) is calculated by SQL on the source itself. Only
  the numbers come back, plus a sample of up to 1,000 rows of the profiled columns
  (`VALUE_SAMPLE_ROWS`, `app/infra/connectors/sql.py`), read in memory on the worker to take
  up to 20 example values per column, which feed the semantic types and PII detection; only those
  values are stored. Up to the previous version it was 20 values per column, in one query per
  column. On sources without SQL (files, SaaS APIs, streaming), the connector reads the records
  through the official API and the profile is calculated on the worker; the
  [connectors guide](CONECTORES.md) says what each one reads.
- **Row rules and duplicates: a sample of up to 5,000 rows per table.** On each scan, the rules
  that need rows and duplicate detection read at most 5,000 rows per table
  (`ROW_FETCH_LIMIT`, `app/domain/scans/service.py`), chosen by the source's own sampling
  (`TABLESAMPLE`/`SAMPLE`), by the primary key hash or, with neither, by the first rows
  ([method](METODOS.md#amostragem)). It is a ceiling, which also applies to the profile sample:
  the DBA can lower it per source, down to a minimum of 100 rows (`max_sample_rows`,
  "Good neighbour to the DBA"). The rows are processed in memory on the worker, in the EU (Google
  Cloud europe-west1); from that read, only the limited examples described below are stored.
- **Larger reads only when you ask for them.** Label quality (on the datasets where you configure
  it), data-diff and matching between datasets read up to 50,000 rows per table, also in memory
  on the worker.
- **What is stored, in the EU:** profiles, aggregates, rule results and scores, and limited
  examples — for example, up to 20 values per column in the profile, up to 5 rows per duplicate
  group and up to 5 truncated values per failed rule. The table is never copied to BeryX.
- **With AI on, the examples sent to the model follow the workspace's AI policy.** By default they
  go to Anthropic, which processes them in the US under standard contractual clauses. To keep them
  in the EU, turn AI off or use Claude in your cloud account, on Bedrock or on Vertex (Enterprise
  plan) — see "Your models" and "Anonymisation before the LLM", below.
- **Running in your Databricks (ADR 0005, step B): it does not run in scans yet.** The
  `beryx-profiler` package, which reads the full table in your account and returns only
  metrics, and the Jobs API client already exist in the code, but no scan submits it yet. Until
  then, on Databricks too, row rules and duplicates use the sample above.
- **Today we have no mode in which no row leaves your network** (on-prem or air-gapped). A runner
  inside your perimeter (step C of the same ADR) depends on real demand.

Tests: `backend/tests/test_seguranca_amostras.py` (the numbers and the Databricks status above are
compared with the code).

#### Proposed enrichment: what leaves for each provider

[Proposed enrichment](CAPACIDADES.md#enriquecimento) asks public reference services, or a
Nominatim instance of the customer's own, for the value missing in a column. Each provider receives
only the field to enrich, one value at a time, and never the full sample or the other columns of
the row:

| Provider (key) | Host | What it receives | What it returns |
|---|---|---|---|
| GeoAPI.pt (`geoapi_pt`) | `json.geoapi.pt` | The CP7 postcode (full Portuguese postcode) | Locality, municipality and district |
| VIES, from the European Commission (`vies`) | `ec.europa.eu` | Only the NIF (Portuguese tax number) of a legal person (first digit 5, 6 or 9). The NIF of a natural person does not leave | Registered name and address |
| Public Nominatim, from the OpenStreetMap Foundation (`nominatim`) | `nominatim.openstreetmap.org` | Only the Spanish postcode | Locality and autonomous community |
| The customer's Nominatim instance (`nominatim_self_hosted`) | The one in the URL the administrator gives | The address, for geocoding. It is the only provider that receives personal data | Latitude and longitude |

Countries and currencies are compared with the ISO lists stored in BeryX, and nothing leaves.

- **Opt-in per workspace and fail-closed.** By default, no provider receives anything. An
  administrator allows each one under "Rules → Enrichment providers"
  (`PUT /api/v1/workspace/enrichment`), and the authorisation records who consented and when.
  Without authorisation, the proposal is refused before the source sample is read. This
  authorisation is separate from that of the `accuracy` rule: allowing VIES in one does not allow
  it in the other.
- **Addresses only to the customer's instance.** Geocoding uses only the customer's own Nominatim
  instance. The
  [usage policy of the public Nominatim](https://operations.osmfoundation.org/policies/nominatim/)
  asks that no personal data be sent to it. So the public Nominatim receives only the Spanish
  postcode and is allowed without the personal data confirmation. The same policy limits use to 1
  request per second. The OpenStreetMap Foundation stores request data in the United Kingdom and
  the Netherlands ([OSMF privacy policy](https://osmfoundation.org/wiki/Privacy_Policy)).
- **One consent per destination.** To allow the instance, the administrator gives the URL
  (`nominatim_url`) and confirms that the addresses may be personal data (`confirm_personal_data`).
  Changing the URL is a new destination and asks for the confirmation again, which records who gave
  it and when. Withdrawing the authorisation deletes the URL. Without a configured and allowed
  instance, no address leaves.
- **The instance URL is validated when saved.** It must be https (http only for hosts in
  `DATAVAULT_CONNECTOR_ALLOWED_HOSTS`), with no user or password, no query or fragment, and up to
  500 characters. Hosts under `openstreetmap.org` are refused, because they are the public service.
  The anti-SSRF guard (`SsrfGuard`, in `backend/app/infra/connectors/http_guard.py`) refuses hosts
  that resolve to internal networks (loopback, private networks, cloud metadata), unless the
  operator allows them in `DATAVAULT_CONNECTOR_ALLOWED_HOSTS`. The same code normalises the URL
  when saving and when querying (`backend/app/infra/enrichment/providers.py`), and every lookup goes
  through the guard again, with DNS pinning.
- **The request to the instance.** BeryX calls `<url>/search` with `q` (the address),
  `format=jsonv2` and `limit=1`, by GET only and with a User-Agent that identifies it. The request
  carries no credential or key: an instance behind authentication is not supported.
- **Outside the subprocessor list.** The three public services are not on the public subprocessor
  list: they only receive values from a workspace after an administrator of that workspace allows
  them, and each one handles the requests under its own terms. The Nominatim instance is the
  customer's own, and the customer chooses the destination.
- **Audit.** Every change to the allowed providers goes into the audit log
  (`enrichment_providers_updated`), with the list before and after, whether personal data was
  confirmed and, when it changes, the host of the Nominatim instance.
- **How it leaves.** GET requests only, through the connectors' read-only HTTP client, with the
  anti-SSRF guard and DNS pinning. The public services have a minimum interval between requests
  in each API process (0.5 s for VIES and 1 s for GeoAPI.pt and the public Nominatim) and each
  proposal has a lookup cap. Answers are kept in an in-memory cache, per workspace and with the
  key hashed (SHA-256). Values never go to the logs.
- **What is stored.** The suggestion stores the proposed values with their provenance (source,
  lookup date and confidence). For geocoding, the suggestion stores only the provenance and the
  coverage: neither the address nor the coordinates, which would locate it. The proposed
  coordinates only appear in the response to the request. The suggestion's SQL carries the
  postcodes and company NIFs
  the source confirmed, with the reference value (for VIES, the company's registered name and
  address). For geocoding, the SQL carries no address at all.
- **What the source did not confirm is not kept.** A natural person's NIF, a postcode the source
  does not know or free text in the column is not kept in the suggestion. In the response to the
  request, it appears masked, with only the last 3 characters. The pull request carries the
  coverage and the source, not the list of values.

BeryX does not write the proposed value to the source: the customer applies it by pull request
or SQL, after approving the suggestion ([what we do not do](#o-que-não-fazemos-e-porquê)).

Tests: `backend/tests/test_enrichment.py`.

#### Encrypted credentials and key rotation

- Source credentials and integration tokens are encrypted with **Fernet**
  (`app/domain/catalog/crypto.py`) before being stored. They are never returned by the API nor
  written to logs; vendor error messages go through a filter that removes secrets.
- **Key rotation**: `DATAVAULT_ENCRYPTION_KEYS` accepts several keys (`MultiFernet`): the first
  encrypts, all decrypt. `backend/scripts/rotate_fernet.py` re-encrypts every secret with the new
  key in idempotent batches; tokens that no key opens are reported only by `id`, without ever
  being decrypted for logs (`app/domain/catalog/key_rotation.py`).

Tests: `backend/tests/test_fernet_rotation.py`.

#### Anti-SSRF protection with DNS pinning

Source URLs come from customer configuration. So that they cannot point inside our infrastructure
(cloud metadata, `localhost`, private networks):

- `SsrfGuard` (`app/infra/connectors/http_guard.py`) only accepts `https` and refuses any host that
  resolves to an internal address. It is fail-closed.
- **DNS pinning**: the host is resolved only once, every IP returned is validated and the
  connection is made to the pinned IP, with the original hostname in `Host` and in SNI (the TLS
  certificate is still validated against the name). This closes the *DNS rebinding* window.
- Each redirect hop repeats resolution, validation and pinning. On a hop to another origin
  (scheme, host or port), only the neutral headers go through, such as `Accept` and `User-Agent`
  (`NEUTRAL_HEADERS`). The `Authorization` header, cookies, query params and any custom
  credential header, such as an `X-API-Key` or the `X-Tableau-Auth`, are dropped.
- **Response cap and total deadline** (cards #571 and #605). The HTTP connectors' transport
  (`pinned_httpx_transport`) reads the body as a stream and stops at the connector's cap: 20 MB for
  REST and for SharePoint and Google Drive documents, 30 MB for OData and Dynamics 365 and 32 MB
  when the caller sets none. A `Content-Length` above the cap is refused before the body. It only asks for `gzip`
  and `deflate`, decompresses them with the output limited to the same cap and refuses `br` and
  `zstd`. The timeout counts for the whole request, from the connection to the last byte, not per
  read.
- It also applies to outbound integrations that receive customer URLs (external catalogue,
  OpenLineage, self-hosted Git): validation on save **and** on each use.
- **Data sources (databases, warehouses, object storage).** Every host and URL in the config goes
  back through the guard (`netguard.check_host`) before the connection is opened, not only when
  the connection is saved (`BaseConnector.assert_destination_allowed`, card #339). Postgres
  connects only to the validated IPs (`hostaddr`), in the resolver's order, and MySQL opens the
  socket to them, with the certificate verified against the name. pymongo resolves each name
  through the guard (`netguard.guarded_getaddrinfo`, card #564) and connects to the addresses
  validated in that same resolution, the driver's monitoring included, and it never opens a local
  Unix socket, not even for a member the replica set announces (card #602). The other dialects
  revalidate and the driver resolves the name again. In production, a name that does not resolve
  is refused when the connection is created and on each revalidation, and the drivers with a
  pinned IP also refuse a local Unix socket and a list of hosts. The per-dialect table is in
  [CONECTORES.md](CONECTORES.md).
- **Webhooks and tickets.** Alert channel webhooks (Slack, Teams and generic webhook), scheduled
  reports on those channels and tickets (Jira, Linear, ServiceNow, Azure DevOps, GitHub and
  GitLab, card #376) all go out through `app/infra/network/outbound.py`. The URL is validated on save, in the app and in the IaC API,
  and again on each send, with the pinned IP. On the generic webhook, each retry attempt validates
  again. Redirects are not followed: a `3xx` counts as a failure. A refused destination is not
  retried and shows in "Last delivery" as "destination refused (SSRF)".
- **A webhook URL is a secret**: whoever has it can post to the channel. It does not appear in the
  "Test channel" response, in `last_delivery_error` or in the logs, which keep only the HTTP code
  or the error type. The `httpx` logger, which at INFO would write the URL of each request, is set
  to WARNING. Excerpts of ticket provider responses go out without the credentials.
- `DATAVAULT_CONNECTOR_ALLOWED_HOSTS` also exempts webhooks and tickets (for example, a receiver on
  the customer's network in a dedicated instance). On channels, `https` is still mandatory.

Tests: `backend/tests/test_http_guard_pinning.py`, `backend/tests/test_dynamics_ssrf.py`,
`backend/tests/test_outbound_ssrf.py`, `backend/tests/test_connectors_sql.py`,
`backend/tests/test_security_gates.py`, `backend/tests/test_dns_rebinding_guard.py`,
`backend/tests/test_http_guard_response_limits.py`.

#### Perimeter: WAF, rate limiting and DDoS

**Status:** implemented as code and covered by automated tests, but **not yet applied in production**.
The global load balancer (`deploy/lb_apply.sh`) and Cloud Armor on top of it
(`deploy/armor_apply.sh`) are waiting for an operation by the team. Until then:

- the API and the app are served directly by Cloud Run, and each service's `*.run.app` address
  also responds;
- abuse containment is the API's own rate limit, in memory and per instance, plus the Cloud Run
  instance ceiling;
- there is no WAF in front of the service.

Activation is phased. First the load balancer goes in. Then Cloud Armor spends a week in
observation mode, in which nothing is blocked and false positives are measured. Only then does it
start blocking. This section will only say "active" after blocking is on in production.

With the edge applied, the perimeter looks like this:

- **Load balancer**: all public traffic (site, app, API and MCP) enters through a global Google
  Application Load Balancer, with TLS ≥ 1.2. The services then refuse requests that do not come
  from it. The LB only forwards well-formed HTTP(S) requests, so L3/L4 volumetric attacks stay on
  Google's network. **Cloud Armor** sits in front of the LB's backend services.
- **WAF** with the OWASP CRS 3.3 rules managed by Google: SQLi, XSS, LFI, RFI, RCE, protocol
  attacks and scanner detection, at sensitivity 1, the most conservative.
  - Endpoints that receive legitimate SQL, YAML or XML are left outside the WAF: rule import,
    connections, dbt manifest, OpenLineage, SAML, signed webhooks, among others. They are
    authenticated and validated in the application.
  - The WAF inspects the first 8 KB of the body of each request.
- **Per-IP rate limiting at the edge**, with central counters that do not depend on the number of
  instances:
  - 600 requests/min per IP on the API;
  - 20 requests/min per IP on login, sign-up, password recovery, email verification and MFA code,
    with a 10 min block for anyone who goes over. Distributed brute force is contained before it
    reaches the application.
- **Adaptive Protection**: layer 7 DDoS detection by machine learning, with alerts.
- **Scanners and methods**: `TRACE`, `CONNECT` and requests to `/.env`, `/.git` and `/wp-*` get
  403.
- **Exemptions**: the rate limit is only bypassed with a header whose value is in Secret Manager
  (the production validation and the uptime checks), never by IP. The header does not bypass the
  WAF.
- **Monitoring**: blocks are counted in a metric without sampling. Above 200 in 5 min, the team
  gets an alert.
- **Defence in depth**: the API keeps its own rate limit per IP, per token and per expensive
  endpoint. MCP (`mcp.<domínio>`) goes through the same LB without Cloud Armor, because agents come
  from the provider's shared IPs. It is authenticated by token and subject to the API's rate limit.

Tests: `backend/tests/test_edge_armor.py` (rules, exemptions, idempotency, secret never printed).
After blocking is on in production, `deploy/check_edge.sh` proves three cases on each deploy:
SQLi in a query param → 403, scanner path → 403, a burst at login from one IP → 429 in under
10 s. While Cloud Armor is not blocking, these three cases appear in the check only as
information, neither passing nor failing.

#### API HTTP headers and body cap

The API answers on its own URL, without going through the app's nginx, and that URL is what the SDK,
the MCP and a scanner test. So it sends the security headers itself
(`backend/app/api/security_headers.py`), on every response, errors and 429s included:

- `Strict-Transport-Security` (one year, with subdomains), `X-Content-Type-Options: nosniff`,
  `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY` and a `Permissions-Policy` that turns off
  camera, microphone and geolocation;
- `Cross-Origin-Resource-Policy: same-site`, except on the `/api/v1/public/` routes, which get
  `cross-origin` because a badge is embedded as an image in third-party pages;
- on JSON responses, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`;
- on a request with `Authorization`, `Cache-Control: no-store`.

A header the route has already set is left as it is: the public badge keeps its own `Cache-Control`
and CSP, and the SSE streams their `no-cache`.

A request body is capped at 4 MB (`api_max_body_bytes`, in `backend/app/api/body_limit.py`). A
`Content-Length` above it gets a 413 without the API reading the body. A body without
`Content-Length` is counted as it arrives and reading stops at the cap. The 413 is sent before
authentication and carries `detail` and `max_bytes`. Routes that already had their own cap keep it
(`ROUTE_BODY_LIMITS`): dbt manifest, dbt results, OpenLineage, rule import and the RAG gate. Record
validation (`POST /datasets/{id}/validate`) has 2 MB, set by path pattern
(`ROUTE_BODY_LIMIT_PATTERNS`).

The app sends its own through nginx, with a CSP of its own for the SPA
(`frontend/nginx/security-headers.conf`). The production validation (`deploy/validate_prod.sh` §17)
checks HSTS and `nosniff` on the API's `/health`, the app's six headers and, on the site, HSTS,
`nosniff` and the CSP. The site's CSP stays in Report-Only until a week goes by without violations:
the browser blocks nothing and sends each violation to `/api/csp-report`, which reads at most 16 KB
per report and logs up to 20 reports per IP every 10 minutes. Tests:
`backend/tests/test_http_hardening.py`.

#### GDPR: self-service export and deletion

Without a request to support:

- **Export the workspace data** — `GET /api/v1/workspace/export` (administrators).
- **Delete the workspace** — `DELETE /api/v1/workspace`: cascading deletion of connections,
  datasets, profiles, scans, reports, integrations and credentials
  (`delete_workspace_cascade`, `app/domain/catalog/erasure.py`).
- **Delete your own account** — `DELETE /api/v1/auth/me`: anonymises authorship in the records
  that must survive (e.g. audit trail) and deletes the rest (`erase_user`).
- **Deletion certificate** — deleting the workspace issues a signed certificate (Ed25519, with the
  issue date inside what is signed). It counts the rows in each data category before and after
  the cascade, in the same transaction, and states until when the backups hold the deleted data.
  It is kept in the platform trail (`staff_events`, no email addresses) and emailed to the admins
  as JSON and PDF (`app/domain/compliance/deletion_certificate.py`). Verify it with
  `backend/scripts/verify_deletion_certificate.py`.
- **Retention per workspace** — `GET`/`PUT /api/v1/workspace/retention` (administrators): limits
  for the profile history (never above the plan window), AI reports and row samples. The worker's
  daily purge applies them and writes `retention_purged` to the Audit log, with the cut-offs and
  the counts (`app/domain/billing/retention_policy.py`, `app/domain/billing/retention.py`).

The privacy policy and the terms are published on the site (`/privacidade`, `/termos`).

#### Product analytics: opt-in and no personal data

The app only measures which pages and features are used when a workspace admin turns sharing on.
It ships switched off (`analytics_enabled`), and the admin answers once after sign-up. The choice
can be changed in Team & Access, which shows the exact list of what is collected (`GET`/`PUT
/api/v1/workspace/analytics`). Each change goes to the Audit log (`analytics_consent_changed`).

- **What is stored** — the workspace, the plan, the event name, the page pattern
  (`/datasets/:datasetId`, never the URL with ids) and the time (`product_events`). The user, the
  email, the IP, the user agent and typed text are not stored. Events, routes and properties come
  from closed lists (`app/domain/analytics/events.py`). Anything else is dropped.
- **Intake** — `POST /api/v1/events/product`, in batches of up to 50 events, with its own request
  limit (`product_events`, 60 per minute). It always answers 204: with sharing off nothing is stored.
- **Retention** — 90 days, through the daily purge (`app/domain/billing/retention.py`). Switching
  sharing off deletes the workspace's events at once (`app/domain/analytics/service.py`). The events
  are in the workspace export and in the cascading deletion.
- **What BeryxTech sees** — in the Console, only percentages per plan and counts per page over the last
  30 days. A cell with fewer than 5 workspaces shows no numbers, and BeryxTech's own workspaces are left
  out (`app/domain/analytics/adoption.py`).

#### Labelling of AI content (Article 50 of the AI Act)

All content produced with a language model — reports, suggestions, diagnoses, assistant answers —
carries a footer stating that it was generated with AI support, **which model and which provider
actually responded**, and that it is subject to human review, under Article 50 of
Regulation (EU) 2024/1689 (`ai_generated_footer`, `app/domain/insights/templates.py`). Offline
mode (no AI) carries its own footer that says so.

**Machine-readable marking (Article 50(2)).** Generated text also carries three fields, computed
in a single place (`app/domain/insights/ai_marking.py`): `ai_generated`, `ai_model` and
`generated_at`. They are output:

- in the API, in reports and RCA diagnoses, in suggestions and in the dataset README (draft and
  published version);
- in the assistant's `done` event, which also names the model that responded;
- in the HTML export and the public share, as `<meta name="ai-generated">`, `ai-model` and
  `ai-generated-at`, with the visible label "AI-generated (model, date)";
- in the PDF metadata (`aigenerated`, `aimodel`, `aigeneratedat`), in the front matter of the
  exported `README.md` and in the Slack and Teams cards of scheduled reports (visible label).

Offline-mode text goes out with `ai_generated=false`. The model name is cleaned before it goes out:
an ARN or a project path from the customer's cloud account is reduced to the model family or to
`undisclosed`. In the assistant, the panel says from the first interaction that you are talking
to an AI system (Article 50(1)). Tests: `backend/tests/test_ai_marking.py` and
`frontend/src/pages/Assistente.dom.test.tsx`.

For customers with high-risk AI systems, BeryX also exports the **Article 10 evidence
dossier** (governance of training, validation and test data), with the gaps made explicit
(`app/domain/compliance/ai_act.py`, test `backend/tests/test_ai_act_art10.py`).

#### AI actions

The chat assistant can propose actions in the workspace: create a rule, log, assign or snooze an
issue, request a review of the PII classification, open a remediation PR, among others.
**Proposing never executes.** The user sees a card with what is going to happen and only approval
executes it, on an endpoint that re-checks the card's signature (HMAC, valid for 24 h), the role,
the plan and the policy, and records the audit event `assistant_action_executed`.

Each workspace has an **AI actions policy** (Team → AI actions, administrators only). For each
action type, it decides:

- **whether it is on.** When off, it is removed from the assistant's tools and execution is
  refused;
- **who proposes and who approves** (member or administrator). The approver is never below the
  role the API requires for the same operation. Whoever can propose but not approve sends a
  **request**, which an administrator approves or declines. Requests are valid for 7 days;
- **auto-approval**, only for the types marked as safe (snoozing an issue, assigning an issue to
  oneself) and off by default. Even without a click, the execution is kept in the trail, marked
  `auto_approved`. Auto-approval also respects per-domain roles (ADR 0011): it only runs when the
  requester can change the issue's dataset, the same check as "Approve". In a restricted domain
  where they are a viewer, or that they cannot see, the card waits for "Approve", which refuses it;
- **the daily limit** in the workspace (UTC day). At the limit, the assistant explains it in the
  chat and approval responds 429 with the reason;
- **the allowed destinations** (for example, the git repositories), **the required fields** and
  **the template** for the body.

Viewers never receive proposals and an API token never approves. Without changes, the API
permissions apply, as before the policy existed. Each change is recorded in the audit log
(`assistant_policy_changed`, with the before and after), as is each request
(`assistant_action_requested`) and each refusal (`assistant_action_request_rejected`). The same
screen decides what the steward's daily round can propose and apply on its own.

Implementation: `app/domain/assistant/policy.py`. Tests:
`backend/tests/test_assistant_action_policy.py`.

#### Public endpoint of the Slack app

BeryX's Slack app (card #193) is created by the customer in their own Slack workspace, from a
manifest with three bot scopes: `chat:write`, `app_mentions:read` and `im:history`. It receives
mentions and direct messages to the bot at `POST /api/v1/webhooks/slack/events/{integration_id}`,
a public endpoint with no session and no API token. Authentication is Slack's signature:

- **Signature over the raw body.** The endpoint checks `X-Slack-Signature` (`v0=` + HMAC-SHA256
  with the integration's signing secret over `v0:{ts}:{raw_body}`) in constant time, before
  parsing the JSON (`app/infra/collaboration/slack.py`). An invalid signature gets 401, and the
  `url_verification` challenge is only answered after this check.
- **5-minute replay window.** An `X-Slack-Request-Timestamp` more than 5 minutes away from the
  server clock gets 401, even with the right signature.
- **Team, channel and dedupe.** The event's `team_id` must be the one Slack returned when the
  integration was saved. Only mentions and messages in the DM with the bot get through, never from
  bots, and only in the channels an administrator has allowed. A repeated `event_id` is not
  processed twice.
- **Suspended tenant.** With the workspace suspended by BeryxTech, the endpoint neither stores nor
  queues questions, the worker closes the ones already queued without calling the model or Slack,
  and posts waiting to go out are not sent. It is the same rule as for sessions and API tokens.
- **Immediate ack.** The endpoint stores the question encrypted with Fernet, publishes it to a
  queue and responds 200. The assistant runs on the worker, read-only and with the permissions of
  whoever linked the Slack account to the BeryX account. The question is deleted when the
  request closes.
- **Limited output.** The Slack client only calls `chat.postMessage`, `chat.postEphemeral` and
  `auth.test`, and refuses any other method before reaching the network. Proactive posts carry
  only ids, counts, catalogue names, severity and links, through a closed list of keys. In the
  bot's answers, the rule against copying values or samples is an instruction to the model. The
  mechanical filter is the AI output guardrail, which masks emails, phone numbers, NIF/IBAN, cards,
  IPs and secrets, plus mrkdwn escaping, so no answer can notify a whole channel. Other column
  values are kept out only by the instruction.

Implementation: `app/domain/collaboration/inbound.py`. Installation guide:
[SLACK_APP.md](SLACK_APP.md). Tests: `backend/tests/test_slack_app.py`.

#### Your models: your own key, Amazon Bedrock and Vertex AI

- **Your own Anthropic key** per workspace (encrypted like any credential).
- **AI policy** per workspace: "AI off" (only offline heuristics and templates) or a restriction
  of models per task.
- **Claude in the customer's cloud account** (Enterprise plan): Amazon Bedrock, `eu-*` regions
  only, via `sts:AssumeRole` with a per-workspace `ExternalId` —
  [Bedrock onboarding](BYOK_BEDROCK.md); or Google Cloud Vertex AI, `europe-*` regions only, by
  impersonating a service account of the customer, with no JSON keys —
  [Vertex onboarding](BYOK_VERTEX.md). The data and the token bill stay in the customer's account;
  BeryxTech keeps no secrets for that route.
- If the customer's route fails, AI degrades to the offline modes — it never falls back to
  BeryxTech's account against the customer's choice.

#### Anonymisation before the LLM

- **By default, only Anthropic receives customer data.** An alternative provider never receives
  raw samples: only metadata (column names, types, counts) or, if the workspace explicitly turns
  it on, already anonymised samples.
- Samples sent to a model are limited in size and can be anonymised before they leave: the engine
  (Microsoft Presidio, with Portuguese recognisers for NIF (tax number), NISS (social security
  number), IBAN/NIB, phone number and Cartão de Cidadão (identity card)) replaces the values with
  placeholders that are reversible only within the session (`app/domain/llm/anonymize.py`).
- Routing refuses any non-Anthropic step that does not receive the sample already anonymised
  (`app/domain/llm/routing.py`).

Tests: `backend/tests/test_llm_anonymize.py`.

#### Sign-in attempt limits

Password sign-in has three limits, in `backend/app/api/ratelimit.py`:

- **Per IP and email:** 5 attempts per minute.
- **Per IP:** 30 attempts in 5 minutes, added up across sign-in, sign-up and password recovery
  requests. It stops *password spraying*, where one weak password is tried against many emails from
  the same IP.
- **Per account:** 20 attempts in 15 minutes, from any IP. It stops brute force spread across many
  IPs against the same account. The key is the sha256 of the email and applies the same way to
  emails without an account. Password recovery requests have a separate per-account limit, with the
  same numbers.

All three are checked before the attempt counts, so a 429 from one does not use up the others. A
sign-in with the right password uses up none of them. Over the limit, the response is 429 with
`Retry-After` and the same message whether or not the account exists, and it leaves before the API
looks the account up. When the per-account limit trips, the audit log of the account's workspace
gets `login_throttled`, with the email hash and the IP, never the password, once per account in each
window.

Anyone who knows an account's email can block its password sign-in for 15 minutes. SSO and password
recovery requests keep working during that time.

Through the web app, the per-IP limit counts the browser's IP, which the web server vouches for to
the API with a shared key. In production, the API does not start without that key. Outside
production and without it, the API sees the same IP on every request from the app.
So that some users' failures do not block sign-in for the others, a request that presents itself as
coming from the web server then stays out of the per-IP limit. The per-IP-and-email and per-account
limits still apply. In production, the key has been active since 11/10/2026.

In the API's per-IP limits, an IPv6 address counts by its /64 block and an IPv4 address by the full
address. Whoever holds an IPv6 block picks the end of the address, and switching address inside the
block does not open a new limit.

The counters live in memory in each API instance: with 3 instances, each limit can be worth up to
three times as much. Central containment by real IP is Cloud Armor's (see «Perimeter»), not yet
applied in production.

Tests: `backend/tests/test_security_gates.py` and `backend/tests/test_ipv6_rate_limit.py`.

#### Sessions

Every sign-in is recorded with the device, the truncated IP (/24 for IPv4, /48 for IPv6), the
creation date and the last use; the token is never stored (`backend/app/domain/auth/sessions.py`).
In “Account security → Active sessions”, each person sees where they are signed in and ends one
session or all the others. “Sign out” also ends the session on the server.

- **Inactivity.** A session with no requests for 12 hours ends. The workspace administrator can
  require less, down to 15 minutes, in “Team → Sessions”. The change applies to sessions that are
  already open. A request made with no interaction for more than a minute does not count as activity:
  this covers pages with live updates and the notification bell's connection, which reopens on its
  own every 4 minutes.
- **Forced sign-out.** The administrator ends every session of a member in “Team”. The person can
  sign in again with their credentials.
- **Password and MFA.** Resetting the password, turning MFA on or off and regenerating the recovery
  codes end every session of the account.
- Every revocation, forced sign-out, password change and timeout change goes to the audit log.

#### API tokens

A `dvk_*` token is stored only as a SHA-256 hash, and its value is shown once, when the token is
created or rotated (`backend/app/domain/access/token_lifecycle.py`).

- **Expiry.** Every new token expires: 90 days by default, or between 1 and 365 days, as the
  administrator chooses. An expired token gets 401 on every route, with the same message as an
  invalid token. An installation can accept tokens with no expiry
  (`DATAVAULT_API_TOKEN_ALLOW_NO_EXPIRY`); by default, it does not. Tokens created before this rule
  have no expiry and keep working until the administrator rotates them.
- **Rotation.** “Rotate”, in “Team → API tokens”, creates a new token with the same name and the
  same scope. The old one keeps working during the grace period (24 hours by default), so the value
  can be swapped in the pipeline without breaking it. “Revoke” cuts it off at once. The rotation goes
  to the audit log (`token_rotated`).
- **Last use.** The token list shows each token's last use and last IP, updated at most once a
  minute. The IP stays in the database and only the administrator sees it: it does not go into the
  logs or the audit log.
- **Warnings.** A token active for more than 180 days raises an alert for the administrators. The
  weekly summary of the notification channels says how many tokens expire in the next 14 days.

Tests: `backend/tests/test_api_token_lifecycle.py`.

#### Organisation access restrictions

The organisation administrator sets, in “Team → Organisation → Access restrictions”, rules that
apply to every workspace in the organisation (`backend/app/domain/organizations/access_policy.py`):

- **IP allowlist.** A list of IPs and CIDR blocks, IPv4 and IPv6. With the list filled in, an IP
  from outside is refused at sign-in (password, email links, the second MFA step and SSO), before the
  session is issued, and on every authenticated request: open sessions, API tokens and the Microsoft
  365 Copilot, which comes from Microsoft's network. The IP is the one the rate limit uses, so a
  forged `X-Forwarded-For` does not get through. A refused person gets a message with the IP seen and
  what to do; a wrong password still returns the usual error, without revealing the policy. The
  session does not end: back on an allowed network, it carries on.
- **API tokens exempt by name.** A token with the given name, in the given workspace, gets in from
  any network, for example CI with dynamic IPs. Each exemption carries the reason written by the
  administrator. Rotating the token, that is creating another with the same name and revoking the
  old one, keeps the exemption. A token with the same name in another workspace is not exempt.
- **Maximum session length.** Between 1 hour and the installation's validity (12 hours by default),
  counted from sign-in, with or without activity. Lowering it ends at once the sessions already past
  it; the sign-in screen says why.
- **MFA mandatory across the organisation.** People who sign in with a password must set up MFA at
  their next sign-in. People who sign in with SSO use their identity provider's MFA.
- **Lock-out protection.** Saving a list that does not include the IP of whoever saves it is refused.
  If everyone is still locked out, for example because the office IP changed, the BeryxTech operator
  turns the list off with `backend/scripts/clear_ip_allowlist.py`, after confirming the request with
  the customer.
- Every change goes to the audit log of every workspace in the organisation. Every refusal by IP goes
  to the audit log of the account's or token's workspace, and the section shows the organisation's
  latest 20 refusals. For a request from a session or token that is already authenticated, one
  refusal is kept per account, IP and origin every 15 minutes.

BeryxTech support access still follows the customer's consent (see
[Isolation between customers](#isolation-between-customers-multi-tenant)) and does not go through the
customer organisation's allowlist.

#### Audit log

Administrative actions (roles, plans, connections, credentials, tokens, sessions, AI policy, AI actions
policy, support access) and assistant actions that are approved, requested or declined are kept
in an audit log written in the same transaction as the change, which workspace administrators can
query and export as CSV. Each event is kept for 400 days: a daily worker purge deletes older ones
and writes `audit_log_purged` to the log, with the cut-off and the count
(`app/domain/audit/retention.py`).

#### Subprocessors and providers

The list of subprocessors is public at `/subprocessadores` (pt/en): purpose, region, legal basis,
transfer mechanism and DPA of each one. A new provider, a removal or a change of region is
announced 30 days in advance. Administrators can receive the notice by email
(Compliance → Subprocessor notices). During the notice period, a new AI provider receives no data:
the router only sends to active subprocessors on the list. The risk assessment of each provider
(declared certifications, data it receives, exit plan) is in `docs/compliance/vendors/`.

#### Certifications: ISO 27001 and SOC 2

**We do not have ISO 27001 certification or a SOC 2 report.** They are part of a programme: the
controls described in this document are the basis of that work. Until then, we answer RFP lines
with the technical evidence above, not with badges — and we do not use "compliant" or "aligned
with" as a substitute for a certification we do not have.

The public status of each certification (planned, in preparation, audit scheduled, obtained —
with dates) lives in a single file, `docs/compliance/trust-status.json`, which the site reads at
build time for the `/datavault/seguranca` page and for `/.well-known/security.txt`.
`backend/tests/test_trust_status.py` fails if that status diverges from this section or from
ADR 0010.

One of the controls already in operation is **vulnerability management**
([compliance/vulnerability-management.md](compliance/vulnerability-management.md)). It includes:

- SCA of the Python and npm dependencies and scanning of the images with Trivy on each PR;
- CI failure on critical/high without a recorded exception;
- an SLA of 7, 30 and 90 days by severity;
- a CycloneDX SBOM per deploy;
- a monthly report in `docs/compliance/evidence/`;
- weekly DAST (OWASP ZAP baseline, passive only) on the site, the app and the API, with a weekly
  report and the same exception register.

Findings from all sources (scanners, DAST, external pen test and responsible disclosure, through
`/.well-known/security.txt`) follow a process with an SLA
([compliance/security-findings.md](compliance/security-findings.md)).
**We have not done an external pen test yet.** The scope, the test accounts and the rules are ready
([compliance/pentest.md](compliance/pentest.md)); we have yet to hire the provider.

#### What we do not do, and why

- **Destructive remediation on the sources.** We propose the fix (SQL/PySpark, or a pull request
  in the customer's repository); the customer applies it. Asking for write credentials would be
  objection no. 1 for any security team — it is a product decision, not a limitation.
- **MDM / golden record with writes.** We detect duplicates and propose consolidation, but we do
  not maintain a master record nor write the "winning" version to the sources.
- **Multimodal image analysis.** Documents are read through their text layer; images are not
  processed, and a scanned PDF with no text is flagged as needing OCR
  (`app/domain/quality/documents.py`) instead of being sent to a vision model.
