DataVault — Guia de conectores
Como obter credenciais e configurar cada fonte de dados no wizard de conexões. Contrato técnico em
docs/ARQUITETURA.md§3.1; implementação embackend/app/infra/connectors/.
Princípios comuns a todas as fontes:
- Read-only: o DataVault apenas lê metadados, agregados e amostras — nunca escreve na fonte. Usa sempre os métodos oficiais dos vendors.
- Credenciais cifradas: a configuração é cifrada com Fernet antes de ser persistida e nunca aparece em logs nem em respostas da API.
- Fluxo no wizard: criar conexão → Testar ligação (mensagem de diagnóstico se falhar) → Descobrir datasets → agendar/executar scans.
- IP de saída fixo: com o Cloud NAT ativo (DEPLOY_GCP §12), todas as ligações às fontes saem de um IP fixo por região. O wizard mostra-o («Os nossos pedidos vêm de …») e também está em
GET /api/v1/public/egress-ips. Em produção ainda está por ativar: enquanto esse endpoint devolverconfigured: false, o wizard esconde a nota e a saída não tem IP fixo. Portas por conector, TLS e conectividade privada: REDE_ALLOWLIST.md. - Boa vizinhança com o DBA: todas as queries dos conectores SQL saem identificadas (
application_name/program_name/QUERY_TAGe o comentário/* DataVault scan <uuid> */), e cada conexão pode ter janelas de blackout, um máximo de queries em simultâneo, uma amostragem máxima e uma réplica de leitura (replica_host/replica_portem Postgres, MySQL e SQL Server). O relatório do que foi executado exporta-se em CSV/JSON. Guia completo: DBA.md. - Drivers opcionais: cada fonte tem um extra pip próprio. Sem o extra instalado, a app funciona na mesma:
GET /api/v1/connections/typesdevolveavailable=falsee omissing_extrado tipo, e o wizard mostra «driver não incluído nesta instalação» em vez de deixar criar uma conexão que falharia no teste. O mapa tipo → extra → módulos vive embackend/app/infra/connectors/extras.py(fonte única). O mesmo endpoint devolve o nome (label), a família (group) e a âncora da secção deste guia (docs_anchor) de cada tipo — é daí que a página Ajuda tira a lista de conectores suportados; um título novo aqui que mude uma âncora faz falhartests/test_connection_types.py.
Resumo
| Fonte | Tipo (connector_type) |
Extra pip | Campos do wizard |
|---|---|---|---|
| PostgreSQL | postgres |
datavault-backend[postgres] (psycopg2) |
host, port, database, user, password (ou url); replica_host?, replica_port?, tls_mode?, ssl_ca_pem? |
| MySQL/MariaDB | mysql |
datavault-backend[mysql] |
host, port, database, user, password (ou url mysql+pymysql://); replica_host?, replica_port?, tls_mode?, ssl_ca_pem? |
| SQLite | sqlite |
— (incluído) | path (ficheiro .db; usado em testes/demos) |
| CSV | csv |
datavault-backend[csv] (opcional) |
path (diretório com ficheiros .csv) |
| Google Cloud Storage | gcs |
datavault-backend[gcs] (gcsfs + pandas + pyarrow) |
bucket, prefix?, service_account_info (chave JSON) ou anonymous: true; max_files?, include_images? |
| Amazon S3 (e compatíveis) | s3 |
datavault-backend[s3] (s3fs + pandas + pyarrow) |
bucket, prefix?, access_key_id + secret_access_key ou anonymous: true; region?, endpoint_url?, max_files?, include_images? |
| Azure Blob Storage / ADLS Gen2 | adls |
datavault-backend[adls] (adlfs + pandas + pyarrow) |
account_name, container, prefix? e uma credencial: account_key, sas_token, tenant_id + client_id + client_secret ou anonymous: true; endpoint_suffix?, max_files?, include_images? |
| SFTP (file drops de parceiros e bancos) | sftp |
datavault-backend[sftp] (paramiko + pandas + pyarrow) |
host, port? (22), username, password ou private_key_pem (+ passphrase?), host_key_fingerprint (obrigatório em produção), root_path, prefix?, max_files?, include_images? |
| BigQuery | bigquery |
datavault-backend[bigquery] |
project, dataset?, credentials_json (obrigatório em produção); max_bytes_per_query?, max_bytes_per_scan?, partition_window_days? |
| Databricks | databricks |
datavault-backend[databricks] |
host, access_token, http_path, catalog?, schema? |
| Snowflake | snowflake |
datavault-backend[snowflake] |
account, user, password, database, schema?, warehouse?, role? |
| Salesforce | salesforce |
datavault-backend[salesforce] |
username, password, security_token, domain |
| Dynamics 365 | dynamics |
datavault-backend[dynamics] |
org_url, tenant_id, client_id, client_secret, entities? |
| Microsoft Fabric | fabric |
datavault-backend[fabric] + driver ODBC |
server, database, tenant_id, client_id, client_secret |
| SQL Server / Azure SQL / Synapse | mssql |
datavault-backend[mssql] (pymssql); modo Entra ID: + [fabric] e driver ODBC |
host, port?, database, user, password ou tenant_id, client_id, client_secret; application_intent?, replica_host?, replica_port? (ou url) |
| Oracle Database | oracle |
datavault-backend[oracle] (oracledb, modo thin) |
host, port?, service_name ou sid, user, password, schemas? (ou url) |
| MongoDB / Atlas | mongodb |
datavault-backend[mongodb] (pymongo) |
uri ou host, port?, user?, password?, auth_source?; database, collections?, sample_size?, tls_mode?, ssl_ca_pem? |
| Confluence Cloud (documental) | confluence |
— (incluído) | site, email, api_token, space_key?, mine_signals? |
| Jira Cloud (documental, sinais) | jira |
— (incluído) | site, email, api_token, project_key?, jql?, mine_signals? |
| SharePoint / Teams (documental) | sharepoint |
datavault-backend[sharepoint] |
site_urls, tenant_id, client_id, client_secret, folder?, drive_names?, include_images? |
| Google Drive (documental) | gdrive |
datavault-backend[gdrive] |
credentials_json, folder_ids e/ou shared_drive_id, folder?, subject?, include_images? |
| Google Sheets | gsheets |
datavault-backend[gdrive] |
credentials_json, spreadsheet_ids, header_row?, sheets?, subject? |
| Slack — canais (sinais) | slack |
— (incluído) | bot_token (xoxb-), channels, site?, lookback_days?, mine_signals? |
| Teams — canais (sinais) | teams |
datavault-backend[sharepoint] |
tenant_id, client_id, client_secret, channels, lookback_days?, mine_signals? |
| Apache Kafka (streaming) | kafka |
datavault-backend[kafka] |
bootstrap_servers, security_protocol?, sasl_mechanism?, sasl_username?, sasl_password?, topics?, value_format?, sample_size?, consumer_group?, schema_registry_url?, schema_registry_username?, schema_registry_password?, ssl_ca_pem? |
| Google Pub/Sub (streaming) | pubsub |
datavault-backend[pubsub] |
credentials_json, subscriptions, project_id?, value_format?, sample_size?, sample_window_seconds?, consumer_subscriptions? |
| SAP S/4HANA / Gateway (OData) | sap |
— (incluído) | base_url, auth? (basic, oauth2_client_credentials ou api_key) com username + password, token_url + client_id + client_secret + scope? ou api_key + api_key_header?; sap_client?, entity_sets?, sample_size? |
| OData v2/v4 (genérico) | odata |
— (incluído) | base_url, auth? (none, basic, oauth2_client_credentials ou api_key) e as credenciais do modo, como no sap; sap_client?, entity_sets?, sample_size? |
| API REST/JSON (genérica) | rest_api |
— (incluído) | base_url, auth? (none, bearer, basic ou api_key) com token, username + password ou api_key + api_key_header?; endpoints, records_path?, pagination?, total_path?, allowed_hosts?, contract?, sample_size?, max_pages? |
Estado de validação
Prova viva, por conector, de que a sequência que o produto usa funciona: test_connection → list_tables (ou list_documents nas fontes documentais) → get_columns → profile_table (1 tabela, amostra pequena) → fetch_rows(limit=5) e, quando o conector o implementa, table_metadata. Corre com backend/scripts/connector_matrix.py (lógica em app/infra/connectors/conformity.py) e cada linha fica classificada pelo tier mais forte que passou:
| Classificação | O que prova | Onde corre |
|---|---|---|
real_public |
Fonte pública real, sem conta (presets PUBDATA: RNAcentral, Rfam, Ensembl, OpenAQ, NOAA GHCN, feriados do Azure Open Datasets; GCS cloud-samples-data/bigquery/us-states; serviços de referência do OData.org, Northwind v2 e TripPin v4) e fixtures locais SQLite/CSV (seed/connectors/) |
--tier public — sem infra, local ou CI |
real_docker |
Serviço real em docker-compose.connectors.yml (postgres:16, mysql:8, mongo:7, Redpanda, MinIO, Azurite) com o seed de seed/connectors/ |
--tier docker — job docker do workflow connectors-matrix.yml |
real_sandbox |
Conta de teste do vendor (BigQuery austin_311 com chave de SA, Snowflake trial, Databricks, Salesforce Developer Edition, sandbox do SAP Business Accelerator Hub com API key, Confluence/Jira Cloud free, Slack de teste, projeto GCP de teste para Pub/Sub…) |
--tier sandbox — só tipos com os secrets DATAVAULT_MATRIX_<TIPO>_<CHAVE> todos definidos |
fake_transport |
O contrato do conector (parsing, paginação, guardas) contra os transportes falsos dos testes (backend/tests/fakes/connectors.py) — não prova a fonte real |
--tier fake |
driver_missing |
O driver do extra não está instalado onde a matriz correu | — |
stub |
Conector sem implementação, só no roadmap (hoje nenhum) | — |
unverified |
Correu contra uma fonte real mas nenhuma tentativa passou — o tier e a fonte ficam na coluna «Fonte», a classificação nunca diz real_* sem prova |
— |
untested |
Driver presente mas sem fonte nos tiers corridos | — |
Um passo que falha por rede ou timeout fica 🌐 inacessível (não ❌): uma fonte pública instável não conta como regressão. O gate do CI (--fail-on-regression) compara com a baseline docs/verificacao/connector-matrix.json e falha quando uma linha real_* que passava volta com fail — e, numa fonte real que nunca passou por inteiro, quando um passo que estava ✅ volta ❌. Mensagens de erro passam por redact(): nenhum valor de DATAVAULT_MATRIX_*, password, token ou chave privada chega ao JSON, ao Markdown ou aos logs (testado em tests/test_connector_matrix.py).
cd backend
python scripts/connector_matrix.py --tier public,fake --write-docs # sem Docker nem segredos
docker compose -f ../docker-compose.connectors.yml up -d --wait && python ../seed/connectors/seed.py
DATAVAULT_ALLOW_PRIVATE_NETWORKS=true python scripts/connector_matrix.py --tier docker --write-docs
--write-docs reescreve só a tabela entre os marcadores abaixo e junta os tiers corridos à baseline (os outros tiers ficam como estavam). Um conector novo tem de aparecer aqui — o teste da matriz falha se um tipo do CONNECTOR_REGISTRY não tiver linha.
Notas honestas (2026-09-25):
- Imagem de produção sem Kafka, Pub/Sub e Fabric. O
CONNECTOR_EXTRASdo build (cd.yml,gcp_deploy.sh,security.yml— iguais, testado) não incluikafka,pubsubnemfabric(pyodbc + msodbcsql18): em produção estes tipos aparecem no wizard como «driver não incluído nesta instalação». Incluí-los é uma decisão à parte. - Pub/Sub não usa o emulador. O conector fala só com os endpoints Google fixos (anti-SSRF) e não aceita
PUBSUB_EMULATOR_HOST; relaxar a guarda para um teste passar está fora de questão. A linha ficafake_transport+real_sandbox(quando há secrets). O job docker valida por isso 9 tipos emreal_docker(postgres, mysql, mssql/SQL Server 2022, oracle/Oracle 23ai Free, mongodb/MongoDB 7, s3/MinIO, adls/Azurite, sftp/atmoz/sftp, kafka/Redpanda) — o GCS tem o mesmo desenho de endpoint fixo do Pub/Sub e fica de fora pela mesma razão. - SQL Server (
mssql):real_dockersó no job da matriz. Não existe endpoint SQL Server público; a prova real é o SQL Server 2022 dodocker-compose.connectors.yml(seed/connectors/mssql.sql, login só comdb_datareader), corrida no job docker (--expect-min real_docker:9+tests/test_mssql_docker.py, que prova que o INSERT falha no servidor). Prova de 2026-09-25: run 36093070996 (commitc2a8c4c= código do1296f65+ workflow restrito ao serviçomssql, porque a imagemminio/miniodeixou de ser pública no Docker Hub), com os 14 testes detests/test_mssql_docker.pyverdes nos modosurle campos, incluindo o INSERT recusado pelo servidor ao logindb_datareader. O modo Azure SQL/Synapse com Entra ID ficafake_transport(montagem doodbc_connectverificada sobre transporte falso) até haver secretsDATAVAULT_MATRIX_MSSQL_*de uma sandbox Azure. - Oracle (
oracle):real_dockersó no job da matriz, ainda sem run. Não há Oracle público. A prova real é o Oracle 23ai Free (gvenzl/oracle-free:23-slim) dodocker-compose.connectors.yml, comseed/connectors/oracle.sqle o utilizadordatavault_rosó comCREATE SESSION+SELECT. Corre no job docker (--expect-min real_docker:9+tests/test_oracle_docker.py): ligação, descoberta sem schemas de sistema, tipos, perfil com CLOB e BLOB, linhas, estimativa do catálogo e o INSERT recusado com ORA-01456 numa tabela onde o GRANT o deixaria passar. O conector entrou sem essa corrida, porque foi feito numa máquina sem Docker: a linha ficadriver_missingaté à 1.ª corrida verde do job, que fica para um cartão à parte. - Azure Blob/ADLS (
adls): Azurite fora do compose na prova de 2026-10-09. A tentativareal_dockerda baseline correu contra o Azurite 3.35.0 instalado pelo npm (a versão da imagem do compose), porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo. O job docker corre o mesmo seed contra a imagemmcr.microsoft.com/azure-storage/azurite:3.35.0, maistests/test_adls_docker.py. A tentativareal_publiclê o Parquet dos feriados do Azure Open Datasets com acesso anónimo. - SFTP (
sftp): OpenSSH local fora do compose na prova de 2026-10-09. A tentativareal_dockerda baseline correu contra o OpenSSH 10.2 do macOS (internal-sftp -R, chave Ed25519), porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo. O job docker corretests/test_sftp_docker.pye a matriz contra o serviçosftpdo compose (atmoz/sftp, utilizador em chroot, chave de host gerada a cada run e fixada pelo fingerprint). A tentativareal_publicliga ao servidor público de testes da Rebex (test.rebex.net, utilizadordemo, só leitura), que só tem umreadme.txt: prova a chave fixada, a listagem e a leitura de um documento, sem tabelas. - SAP (
sap): a prova real é só o sandbox com API key. A fonte real dosapna matriz é o sandbox do SAP Business Accelerator Hub (oAPI_BUSINESS_PARTNERdo S/4HANA Cloud), com o secretDATAVAULT_MATRIX_SAP_APIKEY; sem ele, a linha ficafake_transport. O sandbox usa o modoapi_key, e por isso os modosbasiceoauth2_client_credentialsnão têm prova contra um SAP real na matriz. Oodatacorre no tier public contra os serviços de referência do OData.org (v2 e v4). - MongoDB (
mongodb):real_dockerno job da matriz,real_sandboxopcional. Não há um MongoDB público para a matriz. A prova real é omongo:7dodocker-compose.connectors.yml(seed/connectors/mongodb/, com tipos mistos, campos em falta, umorder_idduplicado, datas e documentos aninhados, e um utilizador só com o papelread), corrida no job docker comtests/test_mongodb_docker.py. Esse teste prova também que o servidor recusa o insert a esse utilizador. Com os secretsDATAVAULT_MATRIX_MONGODB_URIeDATAVAULT_MATRIX_MONGODB_DATABASEde um cluster Atlas M0 (gratuito), o tier sandbox corre também contra o Atlas. A tentativareal_dockerda baseline de 2026-10-09 correu contra o binário oficialmongod7.0.14 com o mesmo seed, fora do compose, porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo. - RNAcentral (postgres público): catálogo sim, dados não. Desde 2026-09-25 o utilizador público
readernão temUSAGEem nenhum schema (permission denied for schema rnacen) e oinformation_schema.columnsvem vazio. O conector passou a ler as colunas porpg_attribute+format_type, que nunca toca empg_collation, e por issotest+discover+columnspassam: é isso que ovalidate_prod.shverifica.profileerowsfalham do lado da EBI. A linha ficaunverifiedno tier public e a prova completa do postgres é a do job docker (real_docker). O gate vigia os passos que hoje passam, e secolumnsvoltar a ❌ o job falha. Avaliámos o Chado público da FlyBase como alternativa, mas está atrás de um pooler que recusa o parâmetro de arranquedefault_transaction_read_only, e a guarda read-only não se relaxa para um teste passar. Suportar poolers (PgBouncer/Supavisor) sem perder o read-only mecânico é trabalho à parte.
| Conector | Classificação | Estado | Fonte | test | discover | columns | profile | rows | Data | Commit | Driver | Extra na imagem prod | Run |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
adls |
real_public (+ real_docker) | pass | Feriados públicos — Azure Open Datasets (ADLS público) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-09 | 1eb40a1 |
2026.8.0 | sim | — |
bigquery |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | sim | — |
confluence |
fake_transport | pass | fake (tests/test_confluence.py) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
0.28.1 | sim | — |
csv |
real_public | pass | fixture seed/connectors/csv/ | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | 1ce0273 |
— | sim | — |
databricks |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | sim | — |
dynamics |
fake_transport | pass | fake (sessão Dataverse falsa) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | e9fc57f |
— | sim | — |
fabric |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | não | — |
gcs |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | sim | — |
gdrive |
fake_transport | pass | fake (tests/test_gdrive.py) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
— | não | — |
gsheets |
fake_transport | pass | fake (tests/test_gsheets_connector.py (folha pública «Class Data»)) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-09 | 5e325ca |
— | não | — |
jira |
fake_transport | pass | fake (API Jira falsa) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
0.28.1 | sim | — |
kafka |
fake_transport | pass | fake (tests/test_kafka_connector.py) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | e9fc57f |
— | não | — |
mongodb |
real_docker (+ fake_transport) | pass | mongod 7.0.14 (binário oficial), local, com o seed do compose | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-09 | f153a84 |
4.18.3 | sim | — |
mssql |
real_docker (+ fake_transport) | pass | SQL Server 2022 (compose) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | c2a8c4c |
2.4.1 | sim | run |
mysql |
real_public | pass | Rfam (EMBL-EBI) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | 1ce0273 |
1.2.0 | sim | — |
odata |
real_public (+ fake_transport) | pass | https://services.odata.org/V2/Northwind/Northwind.svc | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-09 | d181ba9 |
0.28.1 | sim | — |
oracle |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | sim | — |
postgres |
unverified | fail | RNAcentral (EMBL-EBI) [public] | ✅ | ✅ | ✅ | ❌ | · | 2026-09-25 | 1ce0273 |
2.9.12 | sim | — |
pubsub |
fake_transport | pass | fake (tests/test_pubsub_connector.py) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | e9fc57f |
— | não | — |
rest_api |
fake_transport | pass | fake (tests/test_rest_api_connector.py) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-10 | ef56027 |
0.28.1 | sim | — |
s3 |
real_public | pass | OpenAQ — qualidade do ar (S3 público) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | 1ce0273 |
2026.7.0 | sim | — |
salesforce |
fake_transport | pass | fake (cliente simple_salesforce falso) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | e9fc57f |
— | sim | — |
sap |
fake_transport | pass | fake (tests/test_odata_connector.py, Gateway v2) | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-10-09 | d181ba9 |
0.28.1 | sim | — |
sftp |
real_public (+ real_docker) | pass | test.rebex.net (SFTP público da Rebex) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-10-09 | bc1c8a0 |
5.0.0 | sim | — |
sharepoint |
fake_transport | pass | fake (tests/test_sharepoint.py) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
— | não | — |
slack |
fake_transport | pass | fake (tests/test_chat_signals.py) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
0.28.1 | sim | — |
snowflake |
driver_missing | driver_missing | — | · | · | · | · | · | — | — | — | sim | — |
sqlite |
real_public | pass | fixture seed/connectors/matrix.sql | ✅ | ✅ | ✅ | ✅ | ✅ | 2026-09-25 | 1ce0273 |
2.0.51 | sim | — |
teams |
fake_transport | pass | fake (tests/test_chat_signals.py) (documentos) | ✅ | ✅ | — | — | ✅ | 2026-09-25 | e9fc57f |
— | não | — |
Legenda: ✅ ok · ❌ falhou · 🌐 fonte inacessível (rede/timeout — não conta como regressão) · — não se aplica · · não corrido.
BigQuery
O que é preciso: uma service account do Google Cloud com leitura no projeto/dataset e a respetiva chave JSON.
Passo a passo:
- Na consola Google Cloud, seleciona o projeto → IAM & Admin → Service Accounts → Create service account (ex.:
datavault-reader). - Atribui os papéis mínimos:
BigQuery Job User(roles/bigquery.jobUser) ao nível do projeto — necessário para executar queries de profiling;BigQuery Data Viewer(roles/bigquery.dataViewer) — pode ser dado só ao(s) dataset(s) a analisar (Dataset → Sharing → Permissions) em vez do projeto inteiro, para privilégio mínimo.
- Na service account: Keys → Add key → Create new key → JSON. Descarrega o ficheiro.
- No wizard: preenche
project(ID do projeto, ex.:praxis-analytics), opcionalmentedataset, e cola o conteúdo completo do ficheiro JSON no campocredentials_json(incluindo as chavetas).credentials_path(caminho de um ficheiro no servidor) só é aceite quando os conectores de ficheiro estão ativos (DATAVAULT_ENABLE_FILE_CONNECTORS, desligado em produção).
Credenciais sempre explícitas (varredura 2026-09, A2): sem credentials_json a conexão é recusada — o DataVault nunca usa as credenciais do próprio servidor (ADC/service account do Cloud Run). O mesmo vale para GCS (service_account_info com a chave JSON, ou anonymous: true para buckets públicos) e S3 (access_key_id + secret_access_key, ou anonymous: true).
Wizard guiado (GRAPH-ONBOARD): para BigQuery e GCS (com credenciais) o passo 2 da nova conexão mostra o Guia de registo Google Cloud (API ativa → service account → papéis de leitura → chave JSON → campos) e o botão Testar acesso (POST /connections/check, config não guardado). Aqui o teste é um só passo (connection, o test_connection do conector); a UI liga a mensagem ao passo provável do guia (API desativada, 403/permissões, JSON/invalid_grant, recurso não encontrado).
Instalação: pip install "datavault-backend[bigquery]"
Limitações conhecidas:
- O profiling executa queries no BigQuery — há custo por bytes lidos, limitado pelos tetos da secção seguinte.
- Nomes de datasets são sensíveis a maiúsculas; o dataset tem de estar na região esperada pelo projeto.
- Se
credentials_jsonecredentials_pathforem ambos preenchidos, o JSON colado tem precedência. - A chave tem de ser de uma service account (
"type": "service_account"); outros tipos de credencial (ex.:external_account) são recusados. - O teste de ligação valida autenticação, projeto e existência do dataset (quando indicado), com mensagens distintas para cada caso.
- Os tetos de custo são campos da config, definidos ao criar a conexão. Para os mudar numa conexão que já existe, recria-a ou ajusta os valores da instalação (
DATAVAULT_BIGQUERY_*, em CONFIGURACAO.md), que valem para todas as conexões sem valor próprio.
Custo de um scan, tetos e partições
O BigQuery on-demand fatura os bytes das colunas que cada query lê, e o LIMIT não os reduz. Sem tetos, um scan a uma tabela de 2 TB com 100 colunas podia faturar dezenas de TB. Desde o cartão #293, o DataVault limita o custo antes de ler e não só depois (a telemetria de custo, COSTX, mede o que já foi faturado).
Modelo de custo por tabela. O scan conta por passagens. Cada passagem lê, no pior caso, a tabela inteira, com o tamanho de __TABLES__.size_bytes (bytes lógicos, a base da fatura on-demand). É um majorante, porque cada query lê só as suas colunas.
| Passagem | O que corre | Bytes (majorante) |
|---|---|---|
| Agregados | COUNT/MIN/MAX/AVG/STDDEV_POP por lotes de 10 colunas (o COUNT(DISTINCT) vai na mesma query) |
1× |
| Amostras por coluna | SELECT DISTINCT col … LIMIT 20, uma por coluna |
≈1×, somadas |
| Quantis | APPROX_QUANTILES das colunas numéricas (profundidade standard/deep) |
≤1× |
| Histogramas | SUM(CASE …) das colunas numéricas (standard/deep) |
≤1× |
| Linhas | SELECT … LIMIT 5000 para as regras por linha e os duplicados |
1× |
O COUNT(*) não lê colunas e o COUNT(DISTINCT) não acrescenta bytes (gasta slots, o que pesa em capacity pricing). Correlações e co-nulos (deep), segmentos, a regra unique e as regras SQL são uma query cada. Ficam fora da estimativa, mas sujeitas ao teto por query.
Metadados. Por tabela e scan, o DataVault lê __TABLES__ e três vistas de INFORMATION_SCHEMA (COLUMNS, TABLE_OPTIONS, PARTITIONS). Segundo a documentação da Google, cada consulta a INFORMATION_SCHEMA fatura no mínimo 10 MB on-demand. A leitura fica em cache durante o scan, e os monitores automáticos reutilizam-na.
Tetos (campos da config da conexão; sem valor próprio, valem os da instalação):
| Campo | Omissão | O que faz |
|---|---|---|
max_bytes_per_query |
50 GB (DATAVAULT_BIGQUERY_MAX_BYTES_PER_QUERY) |
Vai em cada job como maximum_bytes_billed. O BigQuery estima os bytes antes de correr e recusa a query sem a faturar. O mínimo é 10 MiB (10 485 760 bytes), o mínimo que o BigQuery fatura. Se o url já trouxer maximum_bytes_billed, fica o mais baixo dos dois. Numa tabela com clustering a estimativa do BigQuery é um majorante, e uma query pode ser recusada mesmo que os bytes reais coubessem. |
max_bytes_per_scan |
200 GB (DATAVAULT_BIGQUERY_MAX_BYTES_PER_SCAN) |
Teto da estimativa por tabela num scan (passagens × tamanho). |
partition_window_days |
7 (DATAVAULT_BIGQUERY_PARTITION_WINDOW_DAYS) |
Dias de partições perfilados quando é preciso uma janela. |
Degradação por ordem. Acima do teto por scan, o perfil degrada, por esta ordem:
- Salta o
COUNT(DISTINCT). Não poupa bytes, mas poupa slots. - Troca as amostras por coluna e as linhas cruas por uma query cada,
SELECT … TABLESAMPLE SYSTEM (p PERCENT), compcalculado para ler ~1 GB. - Salta os histogramas.
O passo 2 também se aplica quando uma passagem completa já passa o teto por query, porque um SELECT * seria recusado. Se nem assim couber, uma tabela particionada por data passa à janela de partições. Uma tabela sem partições temporais é recusada antes de qualquer query aos dados, com a mensagem dos tetos. O que se decidiu fica em metrics_json.cost_guard do perfil da tabela: os tetos, scanned_bytes, estimated_bytes, planned_bytes, degraded e sample_percent. O log regista scan_cost_guarded.
Partições. O DataVault lê INFORMATION_SCHEMA.COLUMNS.is_partitioning_column, TABLE_OPTIONS.require_partition_filter e PARTITIONS (linhas, bytes lógicos e última escrita por partição). Perfila só as partições dos últimos partition_window_days dias (hoje incluído) em dois casos:
- quando a tabela exige filtro de partição (
require_partition_filter = true, prática comum em contas enterprise; sem isto, o primeiroCOUNT(*)falhava e o dataset ficavafailedsem explicação); - quando a tabela inteira passa um dos tetos e está particionada por data.
A partição pode ser por coluna DATE/TIMESTAMP/DATETIME ou por hora de ingestão, com a pseudocoluna _PARTITIONTIME. O filtro é coluna >= <início da janela>, com uma constante, para o BigQuery podar as partições. A janela vale para o perfil e para as passagens seguintes do scan: distribuições, segmentos, a regra unique (que passa a dizer que não contou a tabela inteira) e as linhas.
As regras SQL (expression, custom_sql) correm o SQL da regra tal como está. Numa tabela com filtro obrigatório, o filtro de partição tem de estar na própria regra. Sem ele, ou acima do teto por query, a regra fica com o erro em PT a dizer o que mudar na regra ou na conexão, e o scan continua. Fora de um scan, uma leitura avulsa de linhas de uma tabela acima do teto por scan não é recusada: sai por TABLESAMPLE.
O perfil por janela segue o contrato dos perfis amostrados (#290): o row_count é o da tabela inteira (soma de PARTITIONS, ou __TABLES__), e as métricas das colunas são as da janela (sampled, sample_rows). Assim, o volume e o drift continuam comparáveis entre scans. metrics_json.cost_guard.window guarda column, days, since, reason (require_partition_filter, max_bytes_per_query ou max_bytes_per_scan), o número de partições e as linhas da janela. Uma tabela particionada por intervalo de inteiros com filtro obrigatório não tem janela temporal, e o perfil é recusado com uma mensagem a explicar porquê.
Monitores automáticos. O table_metadata passa a usar INFORMATION_SCHEMA.PARTITIONS: row_count é a soma das partições e last_modified a última escrita. Se a vista não for legível, usa __TABLES__. Segundo a documentação da Google, a vista PARTITIONS ainda está em pré-GA.
Erros acionáveis. «Query exceeded limit for bytes billed» passa a «A tabela 'x' excede o teto de 50 GB por query desta conexão (maximum_bytes_billed): o BigQuery recusou a query sem a faturar», seguido do que fazer (subir max_bytes_per_query, reduzir a janela ou particionar a tabela por data). «Cannot query over table … partition elimination» passa a uma mensagem sobre as permissões de metadados. O dataset fica failed, com a razão em PT no teatro do scan e no resumo do scan (stats.dataset_errors). Uma recusa por teto nunca é engolida pelos fallbacks do perfil, porque daria um perfil «concluído» sem métricas.
Permissões. COLUMNS, TABLE_OPTIONS e PARTITIONS exigem bigquery.tables.get e bigquery.tables.list, já incluídas em BigQuery Data Viewer. Sem elas, o perfil segue sem janela nem contas por partição. Numa tabela com filtro obrigatório, a mensagem diz que permissões faltam.
Mudança de comportamento (upgrade). Uma tabela que, numa só query, lia mais de 50 GB passa a ser perfilada por janela (se estiver particionada por data) ou falha com a mensagem do teto. Uma tabela cuja estimativa passe os 200 GB degrada. Para manter o comportamento anterior, sobe os tetos na instalação ou na conexão.
Databricks
O que é preciso: um SQL Warehouse ativo, um Personal Access Token (PAT) e o HTTP path do warehouse.
Passo a passo:
- PAT: no workspace, canto superior direito → User Settings → Developer → Access tokens → Generate new token. Copia o token (só é mostrado uma vez). Usa de preferência um utilizador/service principal com acesso apenas de leitura aos catálogos a analisar.
- HTTP path: SQL Warehouses → (o teu warehouse) → Connection details → HTTP path (formato
/sql/1.0/warehouses/<id>). - Host: no mesmo ecrã, o Server hostname (ex.:
adb-1234567890123456.7.azuredatabricks.netoudbc-abc12345-de67.cloud.databricks.com). Podes colar com ou semhttps://— é normalizado. - No wizard:
host,access_token,http_path; opcionalmentecatalogeschema(Unity Catalog) para limitar a descoberta.
Instalação: pip install "datavault-backend[databricks]"
Limitações conhecidas:
- O warehouse tem de estar a correr: um warehouse parado com auto-stop pode fazer o primeiro teste falhar por timeout — volta a tentar após 1-2 minutos.
- Permissões Unity Catalog necessárias:
USE CATALOG,USE SCHEMAeSELECTnas tabelas. - PATs expiram conforme a política do workspace — renova-os e atualiza a conexão.
- O teste de ligação distingue na mensagem: token inválido vs host errado vs
http_patherrado.
Execução no vosso Databricks (opcional, ADR 0005 degrau B)
Para medir duplicados de linha inteira, checks e Cramér's V na tabela completa sem trazer
linhas para fora do workspace, o DataVault pode submeter o pacote público
datavault-profiler como one-time run na vossa
conta. O job lê as tabelas e devolve só métricas agregadas por HTTPS à API do DataVault.
Estado: ainda não corre nos scans. O pacote, o cliente da Jobs API e a ingestão existem no código, mas nenhum scan submete o job ainda; até lá, também no Databricks as regras de linha e os duplicados usam a amostra descrita em Segurança. O resto desta secção descreve como funciona.
- Pegada declarada: um run efémero (
runs/submit, não cria Job persistente) com o nomedatavault-profiler <id>, visível no histórico. Nada é carregado para DBFS, Volumes ou ficheiros do workspace; o código vem do PyPI com versão fixada. O job nunca escreve dados. - Credencial dedicada (config
remote_jobs, cifrada com Fernet, nunca devolvida pela API): token de um service principal — não o PAT do warehouse."remote_jobs": { "token": "<token do SP>", "compute": {"mode": "serverless"} }compute.mode:existing_cluster(cluster_id),policy(policy_id,spark_version,node_type_id,num_workers?) ouserverless.hostecatalogsão opcionais (por omissão, os da conexão). - Permissões mínimas do SP:
USE CATALOG,USE SCHEMAeSELECTnas tabelas; no compute,CAN_ATTACH_TOno cluster ouCAN_USEna cluster policy ou serverless jobs ativo. Nada deCAN_MANAGE, admin do workspace nem escrita em catálogos. - Rede: saída HTTPS do workspace para a API do DataVault (
DATAVAULT_API_PUBLIC_BASE_URL). - Custo: DBUs na vossa conta; cada run tem
timeout_seconds(por omissão 30 min) e é cancelado (runs/cancel) se o exceder. - Chamadas à Jobs API (allowlist fechada no cliente):
POST runs/submit,POST runs/cancel,GET runs/get,GET runs/get-output,GET scim/v2/Me. Qualquer outra é bloqueada antes de sair do DataVault.
Snowflake
O que é preciso: um utilizador read-only dedicado, com role e warehouse próprios.
Passo a passo (executar como ACCOUNTADMIN/SECURITYADMIN):
CREATE ROLE IF NOT EXISTS DATAVAULT_READER;
GRANT USAGE ON WAREHOUSE <wh> TO ROLE DATAVAULT_READER;
GRANT USAGE ON DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT USAGE ON ALL SCHEMAS IN DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT SELECT ON ALL TABLES IN DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT SELECT ON FUTURE TABLES IN DATABASE <db> TO ROLE DATAVAULT_READER;
CREATE USER IF NOT EXISTS datavault_svc PASSWORD='<forte>' DEFAULT_ROLE=DATAVAULT_READER;
GRANT ROLE DATAVAULT_READER TO USER datavault_svc;
O account é o account identifier (ex.: xy12345.eu-west-1 ou org-conta), visível em Admin → Accounts ou no URL do Snowsight.
Campos do wizard: account, user, password, database; opcionais schema, warehouse, role.
Instalação: pip install "datavault-backend[snowflake]"
Limitações conhecidas: o warehouse consome créditos durante o profiling (usa um XS dedicado com auto-suspend curto); sem role, é usada a default do utilizador.
Dynamics 365 (Dataverse)
O que é preciso: uma app registration no Entra ID com um segredo, e um application user no ambiente Dataverse com perfil de leitura.
Passo a passo:
- App registration: entra.microsoft.com → Identity → Applications → App registrations → New registration (ex.:
datavault-reader). Anota o Application (client) ID (client_id) e o Directory (tenant) ID (tenant_id) da página Overview. - Segredo: na app → Certificates & secrets → New client secret. Copia o valor (
client_secret) — só é mostrado uma vez. - Permissão API (para consentimento): API permissions → Add a permission → Dynamics CRM → Delegated →
user_impersonation→ Grant admin consent. Nota: para o fluxo server-to-server usado pelo DataVault, o que dá efetivamente acesso é o passo seguinte. - Application user no Dataverse (essencial): admin.powerplatform.microsoft.com → Environments → (o teu ambiente) → Settings → Users + permissions → Application users → + New app user → escolhe a app registration → atribui um security role com leitura nas entidades a analisar (ex.: um role personalizado read-only, ou
Basic User+ read nas tabelas relevantes). - URL da organização (
org_url): o endereço do ambiente, ex.:https://a-tua-org.crm4.dynamics.com(visível no admin center ou no browser). Pode ser colado com ou semhttps://.
Campos do wizard: org_url, tenant_id, client_id, client_secret; opcional entities — lista (ou string separada por vírgulas) de nomes de conjunto (plural, ex.: accounts,contacts,leads,opportunities,incidents). Default: accounts, contacts, leads, opportunities.
Instalação: pip install "datavault-backend[dynamics]"
Limitações conhecidas:
- O
row_countusa$count=truedo OData, que o Dataverse limita a 5000 — contagens acima disso aparecem truncadas. - O profiling é feito sobre uma amostra (primeiras ~1000 linhas via
$top), não sobre a tabela inteira; percentagens de nulos e distintos são relativas à amostra. - A Web API tem throttling (limites de pedidos por utilizador); scans de muitas entidades podem abrandar.
- O teste de ligação faz token +
WhoAmIe distingue: segredo inválido vsclient_id/tenant_iderrados vs falta de application user no ambiente (403).
Confluence / Jira (Atlassian Cloud, fonte documental)
Duas fontes documentais sobre a mesma conta Atlassian Cloud (email + API token), as mesmas credenciais da integração de tickets Jira em Notificações. Não há tabelas nem scans tabulares: o conteúdo entra pelo botão Documentos (ou POST /connections/{id}/documents).
Credenciais — do lado do cliente:
- Uma conta Atlassian dedicada (ex.:
datavault@empresa.pt) só com leitura: permissão View nos espaços Confluence a analisar e Browse projects nos projetos Jira a ler. - Com essa conta, em https://id.atlassian.com/manage-profile/security/api-tokens → Create API token. Copia o token (
ATATT…), que só é mostrado uma vez. O conector autentica por Basic auth (email + token) no endereço do site. - No wizard:
site(ex.:a-tua-org.atlassian.net, com ou semhttps://), oemailda conta, oapi_tokene, opcionalmente, o espaço (Confluence,space_key) ou o projeto (Jira,project_key). Pela API, o Jira aceita tambémjql.
Confluence — como funciona: lista as páginas current do espaço (sem space_key, todas as páginas que a conta vê), 50 por pedido, até ao limit da análise (200 por omissão, máximo 1000). Cada página é um documento confluence/<id>/<título>.html com o corpo em storage format (HTML) e a data da última versão. Teto de 5 MB por página. Sinais: desligados por omissão, porque a documentação interna não vai ao LLM sem ninguém pedir. mine_signals: true na configuração liga-os.
Jira — como funciona: pesquisa por GET /rest/api/3/search/jql (o /search legado responde 410). A JQL efetiva é project = <project_key> combinada com o jql da configuração, ordenada por updated DESC (ou pelo ORDER BY do jql). Sem projeto nem JQL, usa updated >= -30d. No máximo 500 issues por análise, paginadas por nextPageToken. Cada issue é um documento jira/<CHAVE>/<resumo>.txt com resumo, estado, labels, descrição (ADF → texto) e os 30 comentários mais recentes, estes com o nome de quem comentou. Teto de 2 MB por issue (o texto é cortado). Sinais: ligados por omissão, porque é para isso que a fonte existe. Cada issue passa pelo pipeline SIGNALS (pré-filtro por glossário DQ → extração → ligação ao catálogo → upsert idempotente por issue) e o sinal liga a https://<site>/browse/<CHAVE>. mine_signals: false desliga-os.
Diagnóstico do teste de ligação: o Confluence lê GET /wiki/rest/api/space?limit=1 e o Jira GET /rest/api/3/myself (a mensagem diz com que conta autenticou). 401 = email ou token recusados (no Jira também 403, que inclui a falta de permissões), 404 = site, espaço ou JQL errados. Uma resposta que não é JSON (página de SSO, proxy ou manutenção) tem mensagem própria. No Jira, o 429 (rate limit) e o 410 (endpoint descontinuado) também.
Segurança: só GETs, pelo ReadOnlyHttpClient (anti-SSRF + pinning de DNS), ao site indicado, que também passa pela guarda na criação da conexão. A chave de projeto é validada (^[A-Z][A-Z0-9_]{0,9}$) antes de entrar na JQL. Os ids de página e as chaves de issue são validados antes de entrarem no URL.
Instalação: nenhuma — os dois conectores só usam httpx, que é dependência base. Vão na imagem de produção.
Limitações conhecidas:
- Só Atlassian Cloud: os caminhos são os da Cloud (
/wiki/rest/api,/rest/api/3), e Server/Data Center ficam fora. - No Confluence, só páginas: blog posts, anexos e comentários não são lidos.
- No Jira, 500 issues e 30 comentários por issue em cada análise. Um 429 do Jira não é repetido: na listagem falha a análise, na leitura de uma issue salta essa issue.
- Estado de validação: só
fake_transport(ver a matriz acima). Ainda não há prova contra uma instância Atlassian real.
SharePoint / Teams (Microsoft Graph, fonte documental)
Decisão de registo: ADR 0003, opção A — a app é registada pelo cliente no seu tenant, com o menor privilégio possível (Sites.Selected). Os ficheiros de um canal do Teams vivem no site SharePoint da equipa: um site de equipa conta como site. Mensagens do Teams estão fora de âmbito.
O que é preciso: alguém com o papel Cloud Application Administrator (ou Privileged Role Administrator) para o consentimento e um SharePoint Administrator (ou dono do site) para a concessão por site.
Passo a passo:
- Entra ID → App registrations → New registration (single tenant, sem redirect URI). Anota o Application (client) ID e o Directory (tenant) ID.
- API permissions → Microsoft Graph → Application →
Sites.Selected→ Grant admin consent. Não usesSites.Read.AllnemFiles.Read.All. - Certificates & secrets → New client secret (validade ≤ 12 meses). Certificado: cartão
GRAPH-CERT. - Conceder read à app em cada site pretendido: Graph
POST /sites/{site-id}/permissionscomroles: ["read"](Graph Explorer) ouGrant-PnPAzureADAppSitePermission -Permissions Read. - No wizard: URLs dos sites (um por linha, ex.:
https://a-tua-org.sharepoint.com/sites/Qualidade),tenant_id,client_id,client_secrete, opcionalmente, uma pasta dentro de cada biblioteca (folder). Pela API tambémdrive_names(lista de bibliotecas a incluir).
Instalação: pip install "datavault-backend[sharepoint]" (msal, a mesma biblioteca do Dynamics).
Como funciona: fonte documental — não há tabelas nem scans tabulares. O botão Documentos percorre as bibliotecas de cada site (subpastas até 5 níveis, paginação @odata.nextLink) e cada ficheiro PDF/DOCX/TXT/MD/HTML entra na análise de documentos (UNSTR). Teto de 20 MB por ficheiro, verificado antes do download.
Segurança: só GETs pelo ReadOnlyHttpClient (guard anti-SSRF + pinning de DNS); cada @odata.nextLink tem de apontar para https://graph.microsoft.com/v1.0/ (lição A1); o download segue o 302 para *.sharepoint.com sem o header Authorization. A única saída fora do cliente é o POST do token que o msal faz a login.microsoftonline.com.
Diagnóstico do teste de ligação: segredo inválido/expirado, client_id ou tenant_id errados (códigos AADSTS), 403 = falta a concessão read no site (Sites.Selected), 404 = URL do site errado. Throttling (429/503) respeita Retry-After (até 2 tentativas).
Wizard guiado (GRAPH-ONBOARD): no passo 2 da nova conexão, o Guia de registo mostra os 5 passos acima com um «ecrã» textual do portal (menus em inglês), os campos exatos (com botão copiar) e os erros comuns de cada passo. O botão Testar acesso envia o config ainda não guardado para POST /connections/check e recebe um resultado por passo, na ordem do guia:
step |
O que prova | Falha típica → passo do guia |
|---|---|---|
fields |
campos completos, tenant_id e URLs dos sites bem formados (sem rede) |
5 |
dependency |
msal instalado no servidor DataVault |
— (operação Praxis) |
register |
tenant e app registration reconhecidos (AADSTS90002 / AADSTS700016) | 1 |
secret |
client secret aceite (AADSTS7000215 inválido, AADSTS7000222 expirado) | 3 |
consent |
o token traz Sites.Selected no claim roles (lido sem validar assinatura, só para diagnóstico); sem roles = falta Grant admin consent; Sites.Read.All/Files.Read.All/… = aviso de privilégio excessivo |
2 |
site_grant (um por site) |
GET /sites/{host}:/{path} + bibliotecas visíveis; 403 = falta a concessão read, 404 = URL errado |
4 |
Um passo que depende de outro que falhou vem skipped. Avisos não bloqueiam. O token e o segredo nunca entram nas mensagens nem nos logs (o log onboarding_access_checked leva só workspace, tipo, ok e os step falhados).
Google Drive (Drive API v3, fonte documental)
Decisão de registo: ADR 0003, opção A — a credencial é do cliente, no projeto GCP dele, e o DataVault pede sempre só o scope https://www.googleapis.com/auth/drive.readonly. A base documental (limites, contrato, helpers) é a mesma do SharePoint: app/infra/connectors/documents.py.
Três modos de credencial (credentials_json), pela ordem recomendada:
- Service account + partilha (por omissão): chave JSON de uma SA sem papéis IAM; o cliente partilha as pastas/Shared Drives com o email da SA como Leitor. O alcance é exatamente o que foi partilhado.
- Service account + domain-wide delegation (só se a política de partilha do Workspace bloquear
iam.gserviceaccount.com): a mesma chave +subject(email do utilizador técnico impersonado). No Admin Console → Security → API controls → Domain-wide delegation: o Unique ID numérico da SA com apenasdrive.readonly. É mais largo (vê tudo o que osubjectvê): usar um utilizador com acesso só às pastas certas. - App OAuth do cliente: JSON
"type": "authorized_user"(client_id,client_secretda app OAuth registada no projeto do cliente +refresh_tokende um utilizador técnico). O refresh pede sódrive.readonly; se esse scope não foi consentido, o Google recusa. App em modo Testing expira o refresh token em 7 dias — publicar como Internal.
Qualquer outro type (ex.: external_account) é recusado na criação: nunca ADC nem credenciais do servidor.
Passo a passo (modo 1):
- Projeto GCP do cliente → APIs & Services → Library → ativar a Google Drive API.
- IAM & Admin → Service Accounts → criar a SA sem papéis.
- Keys → Add key → JSON (ou, mais tarde, Workload Identity Federation — cartão
GRAPH-CERT). - No Drive: Share de cada pasta (ou membro Viewer do Shared Drive) com o email da SA.
- No wizard: JSON da credencial, pastas (um URL
https://drive.google.com/drive/folders/<id>ou id por linha, até 20),shared_drive_idopcional, subpasta opcional (folder, caminho de nomes dentro de cada raiz) e, só no modo 2, o utilizador impersonado.
Instalação: pip install "datavault-backend[gdrive]" (google-auth + requests, o transporte oficial do refresh).
Como funciona: fonte documental — sem tabelas nem scans tabulares. O botão Documentos percorre cada raiz com files.list ('<pasta>' in parents and trashed = false, supportsAllDrives/includeItemsFromAllDrives, corpora=drive no Shared Drive), subpastas até 5 níveis, paginação por pageToken e teto de 500 pedidos. Ficheiros PDF/DOCX/TXT/MD/HTML entram por files.get?alt=media; Google Docs entram exportados em HTML e Google Slides em texto (files.export); Sheets, Forms e atalhos ficam de fora. Caminhos gdrive/<file-id>/<nome>. Teto de 20 MB por ficheiro, verificado antes do download quando o Drive declara o tamanho (e sempre sobre o corpo). O prefixo pedido na análise é uma subpasta dentro das raízes configuradas — o alcance nunca sai do que foi partilhado.
Segurança: só GETs pelo ReadOnlyHttpClient (guard anti-SSRF + pinning de DNS) para https://www.googleapis.com/drive/v3; a paginação é um parâmetro, nunca um URL devolvido. Ids de pasta/ficheiro validados (^[A-Za-z0-9_-]+$) antes de entrarem no URL ou na query q; nomes de subpasta escapados na q. O token_uri do JSON colado é ignorado e fixado em https://oauth2.googleapis.com/token (um JSON adulterado mandaria o JWT assinado ou o refresh token para outro host); universe_domain ≠ googleapis.com é recusado. A única saída fora da Drive API é esse POST do google-auth. Chave privada, refresh token e access token nunca entram em mensagens nem logs.
Wizard guiado e diagnóstico: o Guia de registo Google Drive (API → SA → chave → partilha → delegação opcional → campos) e o Testar acesso devolvem um resultado por passo:
step |
O que prova | Falha típica |
|---|---|---|
fields |
JSON válido (service_account/authorized_user), pastas/ids bem formados, subject só com SA (sem rede) |
JSON incompleto, URL que não é drive.google.com/…/folders/<id> |
dependency |
google-auth instalado no servidor DataVault |
— (operação Praxis) |
key |
credencial aceite pelo Google com drive.readonly |
invalid_grant (chave apagada / refresh token revogado), invalid_client, invalid_scope |
dwd (só com subject) |
delegação autorizada para o Client ID | unauthorized_client, utilizador inexistente |
api |
Google Drive API ativa no projeto | accessNotConfigured / SERVICE_DISABLED |
share (uma por pasta) |
a raiz é visível e é uma pasta/Shared Drive | 404 = não partilhada com o email da SA (o Drive responde 404, não 403) |
Throttling (429/503 e 403 userRateLimitExceeded/rateLimitExceeded) respeita Retry-After (até 2 tentativas, espera ≤ 10 s).
Google Sheets (Sheets API v4)
Cada separador de uma folha Google é um dataset com o nome «título da folha/separador» (cartão #213). A credencial é a do Google Drive, com os mesmos três modos: a chave JSON de uma service account com a folha partilhada como Leitor, a mesma chave com subject (domain-wide delegation) ou o JSON authorized_user de uma app OAuth do cliente. O token_uri do JSON é fixado e o universe_domain validado pelo mesmo código (GoogleApiMixin, em app/infra/connectors/gdrive.py). Implementação: app/infra/connectors/gsheets.py.
Config: credentials_json, spreadsheet_ids (ids ou URLs https://docs.google.com/spreadsheets/d/<id>/…, um por linha, até 20; ids ^[A-Za-z0-9_-]+$), header_row? (linha do cabeçalho, 1 por omissão, até 1000), sheets? (filtro de separadores pelo nome) e subject?. A criação devolve 422 (netguard.GoogleSheetsConfigError) a uma credencial que não seja service_account/authorized_user, a um universo que não seja googleapis.com, a um id ou URL mal formado e a um header_row fora de 1–1000.
Scopes: spreadsheets.readonly para a Sheets API. A data de modificação vem do Drive (files.get?fields=modifiedTime), que não aceita esse scope: esse pedido leva um token à parte com drive.metadata.readonly. Com domain-wide delegation, autoriza os dois scopes no Admin Console. Sem o segundo, ou sem a Drive API ativa, a leitura continua e a frescura fica por medir.
Passo a passo (service account):
- Projeto GCP do cliente → APIs & Services → Library → ativar a Google Sheets API e, para a frescura, a Google Drive API.
- IAM & Admin → Service Accounts → criar a SA sem papéis e a chave JSON (como no Drive).
- Na folha: Partilhar com o email da SA, papel Leitor.
- No wizard: JSON da credencial, folhas (um URL ou id por linha), linha do cabeçalho e, se quiseres, os separadores a ler.
Instalação: pip install "datavault-backend[gdrive]", o extra do Drive (google-auth + requests). Não há extra novo.
Como lê:
spreadsheets.get(sósheets.properties) lista os separadores. Só os de grelha (sheetTypeGRID) viram dataset: gráficos e ligações ao BigQuery não têm células. Se duas folhas tiverem o mesmo título, a segunda leva o início do id no nome.values.getcomvalueRenderOption=UNFORMATTED_VALUEedateTimeRenderOption=SERIAL_NUMBER, em blocos de 10 000 linhas sem passar orowCountda grelha, até 100 000 linhas por separador (ROW_LIMIT; acima disso o perfil dizsampled). A leitura para no primeiro bloco inteiro vazio.- Colunas: o texto da linha de cabeçalho. Uma célula vazia dá à coluna a letra dela (
C); um nome repetido ganha_2. Números e booleanos chegam tipados. Umspreadsheets.getcom a máscaraeffectiveFormat.numberFormat.typesobre as primeiras 200 linhas diz que colunas são datas (DATE/DATE_TIME na maioria): nessas, o número de série passa a data no fuso da folha, e as células TIME passam aHH:MM:SS. A coerção e o perfil (nulos, distintos, mín./máx., média, amostras) são os do CSV (coerce_valueseprofile_columns), em memória:00123fica texto. row_countconta as linhas não vazias abaixo do cabeçalho. As vazias ficam emsource_metrics.blank_rowse as células com erro de fórmula (#N/A,#REF!…) emsource_metrics.error_cells.- Um separador vazio, sem cabeçalho ou só com o cabeçalho continua a ser um dataset: o perfil traz o aviso em
source_metrics.warningse nada levanta exceção. - Frescura: a primeira coluna de data. Sem nenhuma, vale o
modifiedTimeda folha no Drive (por folha, não por separador), que é também otable_metadata.last_modified. - Cada scan tem 500 pedidos HTTP. Esgotado o orçamento, o separador seguinte falha com essa mensagem.
Segurança: só GETs pelo ReadOnlyHttpClient (anti-SSRF + pinning de DNS) a https://sheets.googleapis.com/v4/spreadsheets e https://www.googleapis.com/drive/v3/files. Os hosts estão fixos no código e nada do config entra neles. O nome do separador vai citado ('Sheet1'!1:10000) e codificado no caminho. O módulo não tem values.update, append nem batchUpdate: tests/test_gsheets_connector.py prova-o com um transporte falso que recusa POST/PUT.
Testar acesso: os passos do Google Drive, com a Sheets API:
step |
O que prova | Falha típica |
|---|---|---|
fields |
JSON válido, ids/URLs bem formados, header_row, subject só com SA (sem rede) |
URL que não é docs.google.com/spreadsheets/d/<id> |
dependency |
google-auth instalado no servidor DataVault |
— (operação Praxis) |
key |
credencial aceite pelo Google com spreadsheets.readonly |
invalid_grant, invalid_client, invalid_scope |
dwd (só com subject) |
delegação autorizada para o Client ID | unauthorized_client, utilizador inexistente |
api |
Google Sheets API ativa no projeto; aviso (warn) se a Google Drive API não estiver (fica sem frescura) |
SERVICE_DISABLED |
share (uma por folha) |
a folha é visível | 403 ou 404 = não partilhada com o email da SA |
Throttling (429/503) respeita Retry-After, como no Drive.
Slack e Teams — canais de chat como fonte de sinais (CHATSIG, ADR 0008)
Extensão do SIGNALS: os fios de conversa (mensagem + respostas) em canais escolhidos pelo
cliente entram no mesmo pipeline dos tickets Jira (pré-filtro por glossário DQ → extração Haiku ou
heurística → ligação ao catálogo → upsert idempotente por source_ref com content_hash). Cada
fio é um documento slack/<canal>-<ts>/… ou teams/<chave>-<mensagem>/…; o corpus aparece no
catálogo como documentos/canais e os sinais na página Sinais. Corre com o botão
Documentos (ou POST /connections/{id}/documents).
Privacidade — opt-in por canal, nunca DMs:
- Só os canais listados em
channelssão lidos; sem lista, a conexão é recusada. Um caminho forjado para outro canal é recusado antes de qualquer pedido. - Slack: ids de mensagem direta (
D…) são recusados no config;conversations.infoconfirma que cada canal não é DM nem conversa de grupo (is_im/is_mpim) antes de ler mensagens. Só bot tokens (xoxb-): um user token daria acesso às DMs de quem o gerou. - Teams: só canais de equipa (
19:…@thread.tacv2); ids de chat (1:1, grupo, reunião) são recusados e nenhum endpoint/chatsé chamado. - Autores nunca entram no texto enviado à IA (nem nomes, nem ids); menções a pessoas passam a
@utilizador. Mensagens de bots/aplicações, de sistema e apagadas são ignoradas — os alertas do próprio DataVault num canal não viram «queixas». - Janela limitada:
lookback_days(30 por omissão, máx. 90); até 20 canais por conexão e 100 respostas por fio.mine_signals: falsedesliga a mineração mantendo a conexão.
Slack — do lado do cliente:
- https://api.slack.com/apps → Create New App (from scratch) no workspace da organização.
- OAuth & Permissions → Bot Token Scopes:
channels:history,channels:reade, só se houver canais privados a minerar,groups:history,groups:read. Nuncaim:*nemmpim:*(o «testar acesso» avisa se existirem). - Install to Workspace e copiar o Bot User OAuth Token (
xoxb-…). - Em cada canal escolhido:
/invite @nome-da-app(um bot token só lê canais onde está). - No DataVault: token, canais (
#dados-problemas, idC0…ou link…/archives/C0…) e, opcionalmente, o endereço do workspace (a-tua-org.slack.com) para os links dos sinais.
Teams — do lado do cliente (a mesma app registada do SharePoint serve; ver ADR 0003/0008):
- Permissão mínima: RSC
ChannelMessage.Read.Group(Application), concedida pelo dono de cada equipa ao instalar a app nessa equipa. Alternativa tenant-wide:ChannelMessage.Read.All(Application) com admin consent. Nenhuma permissãoChat.*. - As mensagens de canais são uma protected API da Microsoft: o cliente submete o pedido de acesso (formulário Protected APIs in Microsoft Teams) para a app no seu tenant.
- No DataVault:
tenant_id,client_id,client_secrete os canais — um link por linha («Obter ligação para o canal» no Teams) ou<team-id>/<channel-id>.
Read-only: Slack só por métodos de leitura da Web API oficial (auth.test,
conversations.info|list|history|replies) via GET; Teams por GETs ao Graph v1.0. Ambos passam pelo
ReadOnlyHttpClient (anti-SSRF + pinning); tokens e segredos nunca aparecem em logs nem erros.
Apache Kafka (tópicos como datasets)
O que é preciso: os bootstrap servers e uma conta só de leitura — ACL Read + Describe nos tópicos a monitorizar e, se quiseres o lag de uma aplicação, Describe no respetivo consumer group. No Confluent Cloud: uma API key de service account com esses papéis (SASL_SSL + PLAIN). Para Avro: URL e API key do Schema Registry (só leitura).
Instalação: pip install "datavault-backend[kafka]" (confluent-kafka, cliente oficial sobre o librdkafka). O Avro é decodificado sem dependências extra; o Schema Registry é lido por HTTP.
Como funciona (v1, sem consumo contínuo): cada tópico configurado (ou, sem topics, todos os não internos — até 200) é um dataset. Cada scan lê uma amostra das últimas sample_size mensagens (default 500, máximo 5000), repartida pelas partições a partir da cauda (até 64 partições, 30 s de orçamento), e perfila-as como linhas: campos de topo do JSON/registo Avro são colunas (objetos e listas aninhados ficam como texto JSON), mais _kafka_partition, _kafka_offset e _kafka_timestamp. row_count = mensagens retidas (soma de high − low watermark).
Métricas próprias (em profile_results.metrics_json.source_metrics da linha de tabela):
last_message_age_seconds/latest_message_at— frescura dos eventos; olatest_timestampdo perfil é o timestamp da mensagem mais recente, por isso a regra de freshness e os monitores automáticos funcionam sem nada de especial;malformed_pct/malformed_reasons— mensagens com valor que não se conseguem ler como objeto (invalid_json,not_an_object,invalid_avro,schema_not_found,schema_registry_missing,missing_schema_id,unsupported_schema_type); tombstones (valor nulo) contam à parte emtombstones;sample_lag— mensagens que ficaram por ler na cabeça das partições quando a amostra terminou (produção durante o scan ou leitura cortada pelo orçamento;sample_timed_out);consumer_group_lag— comconsumer_group, lag real dessa aplicação (high watermark − offset committed), lido com OffsetFetch;schema_conformity— comschema_registry_url, quantas mensagens da amostra conformam com o schema registado (checked,violations,violation_pct,reasons,schema_ids,subject);nullsem registry. Ver «Regras sobre streams» abaixo.
Os monitores automáticos usam só watermarks (change_counter = soma dos high watermarks) e o timestamp da última mensagem de cada partição.
Formatos: value_format = auto (default: wire format Confluent → Schema Registry; caso contrário JSON), json ou avro. Schemas JSON do registry são suportados; PROTOBUF fica fora do v1 (conta como malformada).
Regras sobre streams (cartão #103). Quatro tipos de regra ao nível do tópico, avaliados na amostra de cada scan; quando falham abrem issues (rule_violation, com o tipo em subtype) e entram no verdict do dataset (GET /datasets/{id}/verdict) exatamente como as restantes — uma regra critical a falhar dá fail.
| Tipo | Parâmetros | Passa quando | Dimensão do score |
|---|---|---|---|
max_event_age |
max_age_seconds (obrigatório), column? |
o evento mais recente da amostra tem ≤ N segundos. Sem column usa o timestamp do broker (last_message_age_seconds); com column usa o maior event-time desse campo do payload (ISO-8601, epoch em s ou ms) |
atualidade |
schema_conformity |
max_violation_pct? (default 0) |
% de mensagens fora do schema do Schema Registry ≤ limite | validade |
required_fields |
fields (lista ou texto separado por vírgulas; a.b para campos aninhados), max_violation_pct? (default 0) |
% de eventos sem algum dos campos (ausente ou null) ≤ limite |
validade |
max_malformed_pct |
max_pct (obrigatório) |
malformed_pct da amostra ≤ limite (tombstones não contam) |
validade |
Conformidade com o schema: mensagens com o wire format da Confluent são validadas contra o schema do id que trazem — Avro que decodifica é conforme (invalid_avro e schema_not_found são violações); schemas JSON validam o subconjunto usual do JSON Schema (type, required, properties, additionalProperties: false, enum/const, items, anyOf/oneOf/allOf, $ref local). Mensagens JSON sem framing são validadas contra a última versão do subject <tópico>-value (TopicNameStrategy), seja ele JSON Schema ou Avro (campos sem default são obrigatórios; campos extra são tolerados). Motivos: missing_required, type_mismatch, enum_mismatch, unexpected_field, no_branch_matched, invalid_json, invalid_avro, schema_not_found. Sem registry, ou sem nenhuma mensagem com schema, a regra falha com a explicação em details.error (tal como freshness sem timestamp). Só contadores e códigos saem do conector — nunca payloads.
Onboarding: no primeiro scan de um tópico, além das regras genéricas, são criadas max_malformed_pct (≤ 1%, warning) e — se o registry validou alguma mensagem — schema_conformity (0%, warning).
Exemplos (POST /api/v1/rules, ou a secção rules do YAML de GitOps):
{"dataset_id": "<orders>", "rule_type": "max_event_age", "params": {"max_age_seconds": 300}, "severity": "critical"}
{"dataset_id": "<orders>", "rule_type": "max_event_age", "params": {"max_age_seconds": 900, "column": "event_time"}}
{"dataset_id": "<orders>", "rule_type": "schema_conformity", "params": {"max_violation_pct": 0.5}}
{"dataset_id": "<orders>", "rule_type": "required_fields", "params": {"fields": ["order_id", "customer.id"], "max_violation_pct": 0}}
{"dataset_id": "<orders>", "rule_type": "max_malformed_pct", "params": {"max_pct": 1}}
rules:
- dataset: orders
type: max_event_age
params: {max_age_seconds: 300}
severity: critical
- dataset: orders
type: required_fields
params: {fields: [order_id, customer.id]}
Leitura do resultado: observed é a idade em segundos (max_event_age) ou a percentagem observada (restantes); details traz source (broker_timestamp, event_timestamp num ficheiro de logs, rows ou profile), reasons por motivo, missing_by_field por campo e checked_count. max_event_age e required_fields também funcionam fora de streams (usam latest_timestamp / o perfil das colunas); schema_conformity só em streams (Kafka e Pub/Sub — ver abaixo) e max_malformed_pct também nos ficheiros de logs dos buckets.
Segurança / read-only: o consumidor usa assign() — nunca entra num consumer group, nunca provoca rebalances e nunca faz commit (enable.auto.commit=false). O config do librdkafka é montado a partir de uma allowlist (nenhuma propriedade arbitrária do cliente passa: ssl.ca.location, plugins ou comandos Kerberos seriam LFI/RCE); CA própria só como PEM (ssl_ca_pem). Bootstrap servers, brokers anunciados nos metadados e o Schema Registry passam pela guarda anti-SSRF; o registry só por GET no ReadOnlyHttpClient e exige https. Payloads nunca são logados; as métricas só guardam contadores.
Google Pub/Sub (tópicos como datasets — par do Kafka)
O que é preciso: uma chave JSON de service account do teu projeto GCP e, em cada tópico a monitorizar, uma subscrição pull dedicada ao DataVault com a label datavault-sampler=true:
gcloud pubsub subscriptions create datavault-orders --topic=orders \
--labels=datavault-sampler=true --message-retention-duration=1h --expiration-period=never
gcloud pubsub subscriptions add-iam-policy-binding datavault-orders \
--member=serviceAccount:datavault@<projeto>.iam.gserviceaccount.com --role=roles/pubsub.subscriber
Papéis da service account: roles/pubsub.subscriber só nas subscrições de amostragem; roles/pubsub.viewer no projeto (ler a configuração das subscrições e os schemas dos tópicos); roles/monitoring.viewer se quiseres o backlog das subscrições dos teus consumidores.
Instalação: pip install "datavault-backend[pubsub]" (google-cloud-pubsub + google-cloud-monitoring, clientes oficiais). Avro reutiliza o decoder próprio da base de streaming.
Decisão — como amostrar sem roubar mensagens. No Pub/Sub ler é fazer pull numa subscrição, e cada subscrição tem o seu cursor. Alternativas avaliadas:
| Opção | Porque não |
|---|---|
| Pull na subscrição dos consumidores sem ack | As mensagens ficam retidas pelo DataVault até expirar o ack deadline (atraso para a aplicação), delivery_attempt sobe e, com dead-letter policy, podem acabar na DLQ. Rouba mensagens. |
| Snapshot + seek na subscrição dos consumidores | Exige pubsub.snapshots.create/seek (escrita no projeto do cliente); durante a amostra as mensagens ficam retidas; o seek de volta reentrega aos consumidores tudo o que processaram nesse intervalo (duplicados). |
| Subscrição de amostragem dedicada (escolhida) | Cada subscrição recebe a sua própria cópia de cada mensagem: pull + ack nela não mexe em nenhuma outra. Custo: throughput de entrega só das mensagens puxadas (≤ sample_size por scan); o backlog não confirmado é descartado pela retenção. |
O conector recusa antes de qualquer pull subscrições sem a label datavault-sampler=true, push, de exportação (BigQuery/Cloud Storage) ou desanexadas, e duas subscrições de amostragem no mesmo tópico. O seek usa-se, sim, mas só na subscrição dedicada: antes de cada amostra ela recua para agora − sample_window_seconds (default 3600; 0 desliga). Isto descarta backlog antigo — sem isso, um scan diário amostraria mensagens de ontem e a frescura daria falsos alarmes — e, com retain_acked_messages ou retenção no tópico, repõe a janela já lida.
Como funciona: cada subscrição em subscriptions (id, ou projects/<p>/subscriptions/<id>) dá um dataset com o nome do tópico (schema = projeto). Cada scan faz pull de até sample_size mensagens (default 500, máximo 5000, 30 s de orçamento, para após dois pulls vazios) e confirma-as; perfil, colunas, linhas das regras e contagem usam a mesma amostra (um só pull por scan). Colunas = campos de topo do JSON/Avro, mais _pubsub_message_id e _pubsub_publish_time. row_count = mensagens na amostra. Os monitores automáticos (table_metadata) nunca fazem pull — consumiriam a subscrição dedicada a cada ciclo.
Métricas (source_metrics, kind: "pubsub") — o núcleo é o mesmo do Kafka (streaming.sample_metrics), por isso as regras de streams, a freshness e as sugestões de onboarding funcionam iguais:
latest_message_at/last_message_age_seconds— pelopublish_time;latest_timestampdo perfil = o mais recente;malformed_pct/malformed_reasons(invalid_json,not_an_object,invalid_avro,schema_not_found,schema_unavailable,missing_schema,unsupported_schema_type); mensagens semdata(só atributos) contam emtombstones;consumer_group_lag— comconsumer_subscriptions(as subscrições reais dos teus consumidores), soma donum_undelivered_messagesdas que leem o tópico, lida no Cloud Monitoring (nunca pull nelas); detalhe por subscrição emconsumer_subscriptionseconsumer_oldest_unacked_age_seconds;schema_conformity— em tópicos com schema do Pub/Sub, a partir dos atributosgoogclient_schemaname/googclient_schemaencoding/googclient_schemarevisionidde cada mensagem: AvroBINARYque decodifica é conforme;JSONé validado contra o schema Avro dessa revisão. Protobuf binário fica fora (malformadaunsupported_schema_type).nullem tópicos sem schema — a regra explica;sampling_subscription,sample_window_start,subscription_filter(a amostra respeita o filtro da subscrição),ack_failed.
Segurança / read-only: nunca publica nem cria/altera/apaga recursos; as únicas escritas são o cursor da subscrição dedicada (ack e seek), verificada pela label antes de qualquer pull/seek. Endpoints fixos da Google (sem hosts vindos do config); só chave de service account (token_uri fixado em oauth2.googleapis.com, universo googleapis.com), nunca ADC do servidor — validado na criação da conexão. Payloads, chave e tokens nunca são logados.
Microsoft Fabric (SQL analytics endpoint)
O que é preciso: um service principal do Entra ID com acesso ao workspace Fabric, o driver ODBC 18 da Microsoft no sistema, e o SQL analytics endpoint do Lakehouse/Warehouse.
Passo a passo:
- App registration + segredo: igual aos passos 1-2 do Dynamics (podes reutilizar a mesma app) →
tenant_id,client_id,client_secret. - Ativar service principals no Fabric: app.fabric.microsoft.com → Settings → Admin portal → Tenant settings → Developer settings → "Service principals can use Fabric APIs" → Enabled (o admin do tenant pode restringir a um grupo de segurança que inclua a app).
- Acesso ao workspace: no workspace → Manage access → + Add people or groups → adiciona o service principal com papel Viewer (suficiente para leitura via SQL endpoint).
- Endpoint e base de dados: no Lakehouse/Warehouse → Settings → SQL analytics endpoint → SQL connection string — é o
server(ex.:xxxx-yyyy.datawarehouse.fabric.microsoft.com);databaseé o nome do Lakehouse/Warehouse tal como aparece no workspace. - Driver ODBC de sistema (obrigatório, além do extra pip):
- macOS:
brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release && HOMEBREW_ACCEPT_EULA=Y brew install msodbcsql18 - Debian/Ubuntu: seguir as instruções da Microsoft e
sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18
- macOS:
Campos do wizard: server, database, tenant_id, client_id, client_secret (opcionais: port, odbc_driver).
Instalação: pip install "datavault-backend[fabric]" + driver ODBC acima.
Limitações conhecidas:
- O SQL analytics endpoint de um Lakehouse é read-only por natureza (ideal para o DataVault), mas os dados Delta podem demorar alguns minutos a refletir-se no endpoint após escrita.
- Schemas de sistema (
INFORMATION_SCHEMA,sys,queryinsights) são excluídos da descoberta de tabelas. - A ligação exige
Encrypt=yes(TLS) — o DataVault configura-o automaticamente; ambientes com inspeção TLS podem precisar dos certificados corretos no sistema. - Sem o driver ODBC de sistema, o teste de ligação devolve as instruções de instalação brew/apt.
- Checksums, sketches HLL e amostra de linhas pelo hash da chave usam
HASHBYTES('SHA2_256', CAST(col AS VARCHAR(max))): o Fabric Warehouse não temNVARCHAR(oVARCHARé UTF-8). Por isso ochecksum_dialectéfabrice o data-diff não compara checksums do Fabric com os de um SQL Server (os bytes do texto são outros). Se a fonte recusar a amostra por hash, o scan usa as primeiras linhas e registasample_hash_fallbackno log do worker.
SQL Server / Azure SQL / Synapse (mssql)
A fonte dos ERPs portugueses (Primavera, PHC, Sage) e do mid-market Microsoft. Um só tipo cobre SQL Server on-premises (2016+), Azure SQL Database/Managed Instance e Synapse (dedicated e serverless SQL pools) — todos falam T-SQL pelo mesmo dialeto.
Dois modos de autenticação (o wizard escolhe pela presença de tenant_id):
| Modo | Para quê | Driver | Campos |
|---|---|---|---|
| Autenticação SQL | SQL Server on-prem/VM, Azure SQL com login SQL | mssql+pymssql — wheels com FreeTDS embutido, sem driver ODBC de sistema (funciona na imagem de produção) |
host, port (1433), database, user, password |
| Entra ID service principal | Azure SQL, Synapse | mssql+pyodbc com Authentication=ActiveDirectoryServicePrincipal — o mesmo caminho do Fabric |
host, database, tenant_id, client_id, client_secret (opcional: port, odbc_driver) |
Em ambos: application_intent: "readonly" (opcional) envia ApplicationIntent=ReadOnly, que num Availability Group com réplicas legíveis ou no Azure SQL read scale-out encaminha a sessão para a réplica de leitura (que recusa escritas e não carrega o primário). O host aceita também o formato do portal Azure (tcp:servidor.database.windows.net,1433). Outros prefixos de protocolo (np:, lpc:, admin:) são recusados. O anti-SSRF (netguard) valida o host já sem o prefixo tcp:, sem a porta ,1433 e sem a instância nomeada (\\INSTANCIA), na criação da conexão e outra vez em runtime antes de cada ligação.
Login read-only (SQL Server / Azure SQL com autenticação SQL) — o SQL Server não tem um modo read-only de sessão equivalente ao default_transaction_read_only do Postgres: a garantia vem dos GRANTs. Cria um login só com db_datareader:
-- no master (SQL Server) — em Azure SQL, na base master do servidor lógico
CREATE LOGIN datavault_ro WITH PASSWORD = '<password forte>';
-- em CADA base de dados a analisar
USE <base>;
CREATE USER datavault_ro FOR LOGIN datavault_ro;
ALTER ROLE db_datareader ADD MEMBER datavault_ro;
-- opcional: contagens de linhas pelo catálogo de partições (sem isto usa sys.partitions)
GRANT VIEW DATABASE STATE TO datavault_ro; -- SQL Server 2022/Azure SQL: VIEW DATABASE PERFORMANCE STATE
Em Azure SQL podes, em alternativa, criar um contained user diretamente na base (CREATE USER datavault_ro WITH PASSWORD = '…') e ligar com esse utilizador.
Service principal (Azure SQL / Synapse):
- App registration + segredo: igual aos passos 1-2 do Dynamics (podes reutilizar a app) →
tenant_id,client_id,client_secret. - Admin Entra no servidor: o servidor lógico tem de ter um administrador Entra ID definido (portal → SQL server → Microsoft Entra ID → Set admin).
- Utilizador na base, ligado como admin Entra:
Synapse dedicated pool: o mesmo, na base do pool. Synapse serverless: na base criada sobre o lake, e o service principal precisa ainda de ler o storage (Storage Blob Data Reader) para as views/external tables.CREATE USER [nome-da-app] FROM EXTERNAL PROVIDER; ALTER ROLE db_datareader ADD MEMBER [nome-da-app]; - Firewall: adiciona o IP de saída do DataVault às regras de firewall do servidor lógico (erro 40615 = IP recusado).
- Driver ODBC 18 de sistema (instruções na secção Fabric) +
pip install "datavault-backend[mssql,fabric]".
Não disponível na imagem de produção. A imagem do DataVault (cd.yml/
gcp_deploy.sh) traz o extra[mssql](pymssql), mas não o[fabric](pyodbc) nem o ODBC Driver 18 de sistema. Nessa instalação oGET /connections/typesdevolveunavailable_modes: {"entra": "fabric"}e o wizard desativa o botão «Entra ID», com a indicação do extra em falta. Em self-hosted, instala os dois para ativar o modo.
TLS: sempre. No modo Entra (ODBC) vai Encrypt=yes;TrustServerCertificate=no — certificado verificado. No modo SQL auth o pymssql pede cifra obrigatória (encryption=require), mas o FreeTDS embutido nas wheels não valida o certificado do servidor (não há CA configurável por ligação): a sessão é cifrada, mas não protegida contra um intermediário com certificado falso. Para verificação completa do certificado usa o modo Entra ID (ou um url mssql+pyodbc:// com login SQL, que exige o driver ODBC).
O que o conector faz:
- Descoberta exclui os schemas
sys,INFORMATION_SCHEMA,gueste as roles fixasdb_*. - Profiling por push-down T-SQL: contagens, nulos, distintos, min/max, média/desvio, zeros/negativos; quantis por
NTILE(20)(oPERCENTILE_CONTdo T-SQL só existe como função de janela);bitfica fora do min/max; comprimentos de texto não são calculados (LEN()ignora espaços finais — números enganadores).varbinaryfica fora de distintos/min/max; tipos exóticos (xml,geography,sql_variant,hierarchyid) degradam para a contagem de nulos sem falhar o scan. - Metadados baratos (monitores automáticos): contagem de linhas por
sys.dm_db_partition_stats(heap/índice clustered, sem scan; com um login sódb_datareadercai parasys.partitions). Só no conectormssql: o Fabric mantém o comportamento anterior (sem contagem de catálogo) porque estas vistas não foram validadas num Fabric Warehouse.sys.tables.modify_dateé exposto como última alteração de esquema — muda com DDL, não comINSERT/UPDATE, por isso nunca é usado como frescura dos dados; a frescura vem de uma coluna temporal da tabela. - Timeout de query do driver (
timeoutdo pymssql /Connection.timeoutdo pyodbc) a partir deDATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S— não hástatement_timeoutde sessão em T-SQL. - Checksums e sketches HLL (data-diff/INCR) pelos primeiros 4 bytes de
HASHBYTES('SHA2_256', CAST(col AS NVARCHAR(max)))(cartão #317). Não se usa oCHECKSUM: segue a collation, e numa collation CI «A» e «a» davam o mesmo hash — um falso «igual» no data-diff. - Amostra de linhas pelo hash da chave primária (ver Amostra de linhas); sem chave, as primeiras linhas por ordem da 1.ª coluna.
- Password e
client_secretnunca aparecem nas mensagens de erro (são removidos mesmo quando o driver ecoa o connection string).
url completa: mssql+pymssql://user:pass@host:1433/base ou mssql+pyodbc://… — só com os params da allow-list do dialeto mssql (tabela na secção PostgreSQL / MySQL / SQLite). No pymssql, connect_timeout e applicationintent=readonly são traduzidos para os kwargs do driver.
Fora do âmbito: CDC, Change Tracking e sys.dm_exec_* (linhagem/queries); autenticação Windows/Kerberos e ActiveDirectoryPassword na UI.
Oracle Database (oracle)
É a base de dados de muitos ERPs legados, da banca, das telcos e do setor público. O conector usa o python-oracledb em modo thin: fala o protocolo Oracle Net em Python puro, sem Instant Client nem bibliotecas de sistema, por isso funciona na imagem de produção (extra [oracle]). Precisa do Oracle Database 12.1 ou mais recente, o mínimo do modo thin.
Campos: host (só o nome ou o IP), port (1521), service_name ou sid, user, password e, opcional, schemas (lista, ou texto separado por vírgulas). A ligação vai sempre para o host e a port indicados, com o serviço ou o SID explícitos: o conector não lê aliases TNS, tnsnames.ora nem wallets do servidor. Um host com porta, caminho, ?, ( ou = (Easy Connect com parâmetros, descritor (DESCRIPTION=…)) é recusado. O anti-SSRF valida esse mesmo host na criação da conexão e outra vez em runtime.
Utilizador read-only. Cria um utilizador só com CREATE SESSION e SELECT nas tabelas a analisar. Evita o SELECT ANY TABLE, que abre todos os schemas, incluindo os do próprio Oracle.
CREATE USER datavault_reader IDENTIFIED BY "<password forte>";
GRANT CREATE SESSION TO datavault_reader;
-- uma linha por tabela ou vista a analisar
GRANT SELECT ON hr.employees TO datavault_reader;
GRANT SELECT ON hr.departments TO datavault_reader;
Num schema com muitas tabelas, gera os GRANTs a partir do catálogo (como dono do schema ou DBA) e corre o resultado. Uma tabela criada depois precisa do seu GRANT.
SELECT 'GRANT SELECT ON ' || owner || '.' || table_name || ' TO datavault_reader;'
FROM all_tables WHERE owner = 'HR';
Read-only mecânico. Além dos GRANTs, cada transação do conector começa com SET TRANSACTION READ ONLY (listener begin do engine). Dentro dela o Oracle recusa INSERT, UPDATE, DELETE e MERGE com ORA-01456, mesmo que o utilizador tenha o GRANT. O DDL faz commit implícito e sai dessa transação: contra CREATE ou DROP vale só a falta do privilégio, por isso o utilizador não deve ter mais do que os GRANTs acima. Numa transação só de leitura, todas as consultas veem a base no mesmo instante. Num perfil longo de uma tabela muito alterada pode aparecer o ORA-01555 («snapshot too old»), como em qualquer leitura consistente longa.
O que o conector faz:
- Descoberta: os schemas com
ALL_USERS.ORACLE_MAINTAINED = 'N'mais o do próprio utilizador. Se oALL_USERSnão tiver essa coluna, todos menos uma lista fixa de schemas do Oracle (SYS,SYSTEM,XDB,MDSYS,CTXSYS,OUTLN,DBSNMP,AUDSYS,WMSYS,OLAPSYS,ORDSYS,LBACSYS,DVSYS,GSMADMIN_INTERNAL,APEX_*e outros). Comschemas, só esses. Os nomes seguem a convenção do SQLAlchemy: um identificador sem aspas, em maiúsculas no catálogo (HR.EMPLOYEES), aparece em minúsculas (hr.employees). A reflexão cita-os; nenhum nome é interpolado no SQL. Tabelas, views e materialized views saem com o tipo: uma view tem o perfil limitado das views (ver Chaves, partições e tipo do objeto). - Tipos:
NUMBER(p, 0)eINTEGERsão inteiros.NUMBER(p, s)eNUMBERsem precisão são numéricos (podem ter casas decimais) e aparecem comoNUMERIC(p, s)eNUMERIC.DATEtraz hora e conta como coluna temporal.CLOB,NCLOB,BLOBeLONGficam fora deCOUNT(DISTINCT),MIN/MAXeORDER BY, que o Oracle recusa nesses tipos (ORA-00932, ORA-00997), e também doLENGTH, das contagens de vazios e só espaços e dotreat_as_nullda regranot_null: contam-se os nulos e as linhas de amostra trazem o valor. - Profiling por push-down: contagens, nulos, distintos, min/max, média e desvio-padrão (
STDDEV_POP), zeros e negativos, comprimentos de texto (LENGTH, em caracteres); quantis porNTILE(20). OLIMITsai comoFETCH FIRST n ROWS ONLY. - Metadados baratos (monitores automáticos): a contagem é o
ALL_TABLES.NUM_ROWS, marcada como estimativa (row_count_estimated, com oLAST_ANALYZEDemrow_count_as_of), porque é a contagem da última recolha doDBMS_STATSe não a de agora. Quando oALL_TAB_MODIFICATIONSé visível, o DML desde essa recolha (INSERTS + UPDATES + DELETES) serve de contador de escritas. O Oracle grava essa vista com atraso e limpa-a a cada recolha, por isso oTIMESTAMPdela não é usado como frescura dos dados. - Timeout: o Oracle não tem
statement_timeoutde sessão. Vai ocall_timeoutdo oracledb (por ida e volta à base) a partir deDATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S. Ao expirar, o driver interrompe a chamada no servidor e levanta DPY-4024, que nunca se repete. Repete-se só uma ligação perdida (tabela das repetições abaixo). - Amostra de linhas: até
DATAVAULT_SAMPLE_FULL_SCAN_MAX_ROWSlinhas, as primeiras pela ordem da chave primária (ou da 1.ª coluna que não seja LOB); acima disso, as primeiras que o Oracle devolver, semORDER BY, para não ordenar a tabela inteira. Este conector não tem amostragem por hash, checksums nem sketches HLL: o data-diff compara só as linhas lidas pela chave, e uma tabela acima deDATAVAULT_PROFILE_DISTINCT_MAX_ROWSfica sem o distinto aproximado do sketch. Também não tem regras SQL por push-down. - A password nunca aparece nas mensagens de erro.
TLS: a ligação é TCP sem TLS. Esta versão não suporta TCPS com wallet nem a Native Network Encryption, que o oracledb só tem no modo thick: um servidor que exija NNE recusa a ligação com DPY-3001. Até lá, liga por uma rede privada (VPN, interconnect).
url completa: oracle+oracledb://user:pass@host:1521/?service_name=ORCLPDB1 ou oracle+oracledb://user:pass@host:1521/SID, só com service_name, sid e connect_timeout (segundos, o tcp_connect_timeout do oracledb). O driver é sempre o oracledb em modo thin. Os outros parâmetros do oracledb.connect ficam de fora: config_dir e wallet_location leem ficheiros do servidor, https_proxy e protocol mudam o destino ou o transporte.
Fora do âmbito: modo thick (Instant Client), TCPS e wallets, Kerberos/RADIUS, SELECT ANY TABLE, V$SQL/AWR (linhagem por queries) e a amostragem nativa SAMPLE (p).
MongoDB (coleções como datasets)
Cada coleção de uma base é um dataset. O MongoDB não guarda schema: o DataVault infere-o de uma amostra aleatória da coleção e mede os campos com tipos mistos e os campos em falta. Implementação em app/infra/connectors/mongodb.py.
Ligação: uri ou campos.
| Modo | Campos |
|---|---|
uri |
mongodb://user:password@host1:27017,host2:27017/?replicaSet=rs0 ou, no Atlas, mongodb+srv://user:password@cluster0.abcde.mongodb.net/ |
| Campos | host, port? (27017), user?, password?, auth_source? (admin) |
Nos dois modos: database (a base a ler), collections? (lista; vazio = as coleções da base, até 200, sem as system.*, as views e as time-series) e sample_size? (documentos por amostra: 1 000 por omissão, 10 000 no máximo).
TLS, nos dois modos: tls_mode? e ssl_ca_pem?, com as regras do Postgres e do MySQL (TLS). Sem tls_mode, a ligação é verify-full: cifrada, com o certificado e o hostname do servidor verificados contra as CAs públicas do certifi. Uma instância com CA privada leva o PEM da CA em ssl_ca_pem. Ligar sem verificar o certificado (require) ou sem TLS (disable) pede confirmação explícita e fica na auditoria.
Do uri só passam estas opções: authSource, replicaSet, tls (ou ssl, que tem de coincidir com o tls_mode: um tls=false pede o tls_mode disable), readPreference, connectTimeoutMS, serverSelectionTimeoutMS e retryReads. A string do Atlas traz retryWrites, w e appName, que são aceites e descartadas. Qualquer outra opção recusa a conexão com 422, incluindo tlsCAFile e tlsCertificateKeyFile (leem ficheiros do servidor) e directConnection.
Utilizador só de leitura. Cria-o na base admin, com o papel read só na base a analisar:
// mongosh, ligado como administrador
use admin
db.createUser({
user: "datavault_ro",
pwd: passwordPrompt(),
roles: [{ role: "read", db: "loja" }]
})
Com o utilizador na admin, o authSource é admin, o valor por omissão do modo de campos. O papel read chega para tudo o que o conector lê, $collStats incluído.
MongoDB Atlas:
- Database Access → Add New Database User: autenticação por password e o privilégio
readsó na base a analisar (ou o papel Only read any database). - Network Access → IP Access List: adiciona o IP de saída do DataVault. Sem isto, a ligação falha por tempo («Não foi possível contactar o MongoDB»).
- Connect → Drivers: copia a string
mongodb+srv://…para o campouri, com a password do passo 1.
O que o conector lê (driver oficial pymongo):
pingno teste da ligação, com a latência, elist_collection_namesna descoberta;- uma amostra aleatória por
aggregate([{"$sample": {"size": N}}]), para as colunas e o perfil; estimated_document_countpara a contagem, dos metadados da coleção e sem ler documentos;$collStatspara o tamanho; se o utilizador não o puder ler, o perfil segue sem tamanho;list_indexese, para a frescura, umfind().sort().limit(1)preso porhinta um índice: um por campo indexado (até 10; no perfil, só os de data) e outro pelo_id. Cada um lê a última entrada do índice. Ficam de fora os índices em que isso não acontece: os parciais, os ocultos, os com collation e os wildcard, de texto, hash ou geo;find().limit()para as linhas das regras.
Não chama mais nada: tests/test_mongodb_connector.py liga o conector a um cliente falso que regista cada chamada e falha com qualquer outra. A preferência de leitura é secondaryPreferred por omissão (as leituras vão a um secundário quando o há), o retryWrites vai desligado e a amostra, a contagem e o find das linhas têm um teto de 60 s no servidor (maxTimeMS); cada leitura da frescura tem 5 s.
Schema inferido:
- colunas = campos de topo, pela ordem em que aparecem na amostra (até 500), com o
_idprimeiro e sempre como texto; - tipo = o da maioria dos valores. Inteiros e decimais (
int,long,double,Decimal128) contam como o mesmo número: um campo com os dois ficafloat, sem conflito; - objetos e arrays aninhados entram como texto JSON, como no Kafka; os subcampos não passam a colunas.
Métricas próprias (source_metrics, kind: "mongodb"), por campo em fields:
| Chave | O que mede |
|---|---|
types |
quantos valores de cada tipo a amostra tinha |
majority_type |
o tipo da coluna |
type_conflict_pct |
% dos valores não nulos com outro tipo (ex.: "12,50" num campo numérico) |
missing_pct |
% dos documentos sem o campo |
present, missing, explicit_null |
as contagens por trás das percentagens |
distinct |
quantos valores distintos a amostra tinha, de todos os tipos |
fields_with_type_conflicts lista os campos com conflitos. estimated_document_count, storage e newest_insert_at (a hora do _id ObjectId mais recente) completam as métricas. As métricas da coluna (mín/máx, média, comprimentos) são do tipo maioritário, sobre a amostra. O null_count conta os documentos sem o campo e os null explícitos; um valor de outro tipo não conta como nulo, conta no type_conflict_pct. O distinct_count conta os valores presentes de todos os tipos, por tipo e valor, como um índice único do MongoDB: 1 e 1.0 são o mesmo valor, 1 e "1" são dois. Nulos e distintos ficam assim na mesma base, e uma chave toda distinta com alguns valores em texto não aparece com repetidos na regra unique.
Contagem e amostra. O row_count vem do estimated_document_count e o perfil marca-o como estimativa (row_count_estimated) e como amostrado (sample_rows). Quando a amostra lê a coleção inteira (menos documentos do que o sample_size), a contagem é exata e o perfil não é amostrado.
Frescura. Num perfil amostrado, o máximo de um campo de data indexado sai do índice, exato, e substitui o máximo da amostra (o valor da amostra fica em exact_column_metrics). Nos monitores automáticos, last_modified é o mais recente entre esses máximos e a hora do _id ObjectId mais recente; datas mais de 24 h no futuro (prazos, validades) ficam de fora. Sem índices de data, a frescura das regras vem do máximo da amostra.
Segurança:
- o
urinunca chega ao driver:mongodb_target, emapp/infra/connectors/netguard.py, extrai hosts, credenciais e opções, e o conector abre oMongoClientsó com esse resultado; - cada host passa pela guarda anti-SSRF na criação da conexão e outra vez antes de cada ligação. Num
mongodb+srv://, o DataVault resolve o registo SRV, recusa alvos fora do domínio do cluster e valida cada alvo; do registo TXT só aproveitaauthSourceereplicaSet; - os membros que o replica set anuncia passam pela mesma guarda. A monitorização do driver contacta-os sem credenciais; as operações, e com elas a autenticação, só vão para os membros que a guarda aceita (
server_selector). Se algum membro cair num destino bloqueado, a ligação é fechada e recusada; - sockets locais (
/tmp/mongodb-27017.sock) são recusados; - em
verify-full, o pymongo recebetlsCAFilecom o bundle docertifiou com a CA do cliente, escrita num ficheiro temporário 0600 que é apagado noclose()(tls.mongodb_tls_args). OtlsCAFiledouricontinua recusado, porque leria um ficheiro do servidor; - os documentos nunca vão para os logs.
Fora do âmbito: views e coleções time-series, subcampos como colunas, change streams e autenticação X.509, LDAP ou Kerberos (authMechanism está fora das opções aceites).
Salesforce
O que é preciso: utilizador (idealmente read-only), password e security token.
Passo a passo:
- Security token: com sessão iniciada → avatar → Settings → My Personal Information → Reset My Security Token — chega por email. (Se a organização usa IP ranges confiáveis, o token pode não ser exigido, mas o conector pede-o sempre.)
- Recomendado: um utilizador de integração com um Permission Set apenas de leitura nos objetos a analisar.
domain:login(produção) outest(sandbox).
Campos do wizard: username, password, security_token, domain.
Instalação: pip install "datavault-backend[salesforce]"
Limitações conhecidas: o MVP expõe os sObjects Account, Contact, Lead e Opportunity; profiling básico (contagens via SOQL, sem métricas por coluna); consome chamadas API da org.
CSV
O que é preciso: um diretório local (ou montado) com ficheiros .csv — cada ficheiro é tratado como uma tabela.
Campos do wizard: path (diretório).
Instalação: funciona sem extras (stdlib); pip install "datavault-backend[csv]" adiciona pandas para partir o texto mais depressa. Os tipos são os mesmos com e sem pandas.
Como o ficheiro é lido (cartão #289, em app/infra/connectors/csv_file.py):
- Encoding: o BOM decide primeiro (UTF-8 ou UTF-16, o «Texto Unicode» do Excel). Sem BOM, tenta
utf-8 → cp1252 → latin-1. Um ficheiro latin-1 só com letras acentuadas é também cp1252 válido e é reportado como cp1252, com o mesmo texto. - Delimitador: é decidido pelo
csv.Sniffersobre os primeiros 64 KiB, só entre,;tab e|. Quando não há veredicto (ficheiro de uma coluna), fica,. O encoding e o delimitador ficam no perfil da tabela (file_format, ex.:{"delimiter": ";", "encoding": "cp1252"}). - Tipos: a coerção é do DataVault, não do pandas (
app/infra/connectors/profiling.py). Um valor só passa a inteiro sem zeros à esquerda ("0"sim,"08001"não), por isso códigos postais, SKUs, NIF e telefones ficam texto. Só o campo vazio sem aspas é NULL:N/Ae-são valores e ficam visíveis no perfil. Como noCOPY … CSVdo Postgres, o campo vazio sem aspas é NULL e""entre aspas é texto vazio (empty_count), em Python 3.12 ou mais recente (a imagem Docker usa o 3.13; em 3.11 o leitor não os distingue e os dois contam como NULL); num ficheiro com o cabeçalho todo entre aspas e sem nenhum campo vazio sem aspas, o escritor citou tudo e o""conta como NULL. Numa coluna numérica, de datas ou booleana,''é sempre NULL (app/infra/connectors/csv_file.py,parse_csv_linesecoerce_table). O GCS/S3 lê os CSV pelo mesmo leitor.NaN/infnuma coluna numérica contam como float, comnan_countà parte dos nulos. Datas só em ISO-8601, e todas com fuso ou todas sem; uma coluna que mistura as duas fica texto, porque nenhum fuso é inferido. Os literaistrue/false(True,TRUE…) são booleanos. - Tetos: o perfil lê até
DATAVAULT_CSV_PROFILE_MAX_ROWSlinhas (1 000 000) eDATAVAULT_CSV_PROFILE_MAX_BYTESbytes (256 MB). Acima disso o perfil é das primeiras linhas e dizsampledesample_rows(a UI mostra «perfil sobre amostra de N linhas»). - Amostra de linhas (regras de linha, duplicados; cartão #317): uma linha em cada k das que o perfil lê, a partir de
semente mod k— espalha-se pelo ficheiro lido em vez de ficar nas primeiras 5 000.
Limitações conhecidas: decimais PT (1.234,56) e datas dd/mm/aaaa ficam texto (nenhum locale é inferido; a máscara de formato do perfil descreve-os). Números com mais de ~16 dígitos significativos perdem precisão (float). Num CSV não se distingue string vazia de NULL. O caso a caso está no corpus adversarial.
Google Cloud Storage / Amazon S3 (ficheiros como tabelas)
Data lake em object storage. Cada ficheiro *.parquet, *.csv ou *.csv.gz sob bucket/prefix é uma tabela, com o caminho relativo ao prefixo (sem extensão) como nome. Os ficheiros de eventos e logs (*.jsonl, *.ndjson, *.log e os mesmos em .gz) também, com uma linha por evento (ver abaixo). Os CSV são lidos como no conector CSV: encoding e delimitador pelo início do ficheiro (64 KiB), dtype=str no pandas e a mesma coerção de tipos. O mesmo ficheiro dá o mesmo perfil no disco e num bucket. Os Parquet mantêm os tipos nativos. As colunas de texto chamam-se text neste conector. Implementação comum em app/infra/connectors/object_storage.py (fsspec).
Credenciais sempre explícitas (A2): ou a credencial do cliente, ou anonymous: true para buckets públicos. Sem nenhuma das duas, a conexão é recusada: o DataVault nunca usa as credenciais do próprio servidor (ADC do Cloud Run, cadeia boto). anonymous tem de ser um booleano ("false" em texto é recusado).
Google Cloud Storage — do lado do cliente:
- Na consola Google Cloud: IAM & Admin → Service Accounts → Create service account (ex.:
datavault-reader), sem papéis ao nível do projeto. - No bucket: Permissions → Grant access à service account com Storage Object Viewer (
roles/storage.objectViewer), que lista e lê objetos e não escreve nada. - Na service account: Keys → Add key → Create new key → JSON.
- No wizard:
bucket(semgs://),prefixopcional (ex.:gold/vendas) e o conteúdo completo do JSON no campo «JSON da service account» (vai comoservice_account_info). Para um bucket público, marca «Acesso anónimo» e deixa a chave em branco.
Com credenciais, o passo 2 da nova conexão mostra o Guia de registo Google Cloud e o botão Testar acesso (ver a secção BigQuery).
Amazon S3 (e compatíveis) — do lado do cliente:
- Um utilizador IAM (ou role com chaves de acesso) só de leitura, com uma política deste género:
{ "Version": "2012-10-17", "Statement": [ {"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::<bucket>"}, {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::<bucket>/<prefixo>/*"} ] } - Security credentials → Create access key →
access_key_idesecret_access_key. - No wizard:
bucket,prefixopcional, as duas chaves (ou «Acesso anónimo» para buckets públicos do AWS Open Data),regionopcional (ex.:eu-west-1) eendpoint_urlopcional para serviços compatíveis com S3 (MinIO, Cloudflare R2, …).
Campos: bucket, prefix?, max_files? (200 por omissão) e as credenciais acima. protocol não é configurável: um conector gcs só fala gcs/gs e um s3 só s3 (sem isto, file ou http davam leitura local ou SSRF).
Instalação: pip install "datavault-backend[gcs]" ou pip install "datavault-backend[s3]" (as duas vão na imagem de produção). Os PDF da análise de documentos precisam também do extra [documents]; os PDF digitalizados (sem camada de texto) precisam do extra [ocr] e do Tesseract do sistema (os três vão na imagem de produção).
Limitações conhecidas:
- A descoberta lista no máximo
max_filesficheiros (200 por omissão), por ordem alfabética do caminho. Os Parquet/CSV entram primeiro no teto e os ficheiros de eventos e logs só depois, por isso um.lognovo nunca tira o lugar a um dataset que já estava no catálogo. Um ficheiro de eventos com o nome de um Parquet/CSV (app.jsonlao lado deapp.csv) fica de fora; entre dois ficheiros de eventos com o mesmo nome (app.jsonleapp.jsonl.gz) fica o primeiro no alfabeto. - O perfil lê as primeiras 100 000 linhas de cada ficheiro (os Parquet só pelos row-groups necessários). As métricas das colunas (nulos, distintos, média, desvio, amostras) são dessa amostra, não do ficheiro inteiro, e o perfil diz
sampled/sample_rowsquando o ficheiro continuava — cada coluna levafrom_sample(a UI mostra «perfil sobre amostra de N linhas»). Perfila no máximo 120 colunas (DATAVAULT_PROFILE_MAX_COLUMNS), escolhidas pela prioridade de tabelas largas; o catálogo guarda todas. - O
row_counté sempre do ficheiro inteiro, mesmo num perfil amostrado: a regrarow_count_min, o monitor de volume e o driftrow_count_dropveem o número real. Parquet:num_rowsdo footer, sem ler dados; quando todos os row-groups têm estatísticas, os nulos (qualquer tipo) e o mín/máx (inteiros, floats, timestamps e datas) de cada coluna passam a ser exatos e a coluna lista-os emexact_metrics— strings ficam com o mín/máx da amostra (os writers truncam essas estatísticas). CSV/CSV.GZ: segunda passagem em streaming (csv.reader, células com quebras de linha entre aspas contam uma linha) atéDATAVAULT_OBJECT_STORAGE_COUNT_MAX_BYTES(512 MB, medidos a cada leitura nos bytes do ficheiro e, num.gz, também nos descomprimidos — um.gzmuito compressível expande milhares de vezes e a contagem para na primeira medida que passar o teto, mesmo a meio de uma linha); acima disso a contagem é extrapolada pela razão observada (linhas contadas × tamanho do ficheiro ÷ bytes do ficheiro consumidos — num.gz, os comprimidos que o inflate consumiu) e o perfil dizrow_count_estimated(a UI mostra «contagem de linhas estimada»; o drift de volume di-lo na descrição). Ficheiros abaixo das 100 000 linhas não fazem segunda leitura; um.gztruncado ou corrompido, ou uma célula acima de 64 Mi caracteres, depois da amostra não chumba o perfil (a contagem para aí e fica estimada). A contagem sobe o limite de campo do módulocsvdo worker para 64 Mi caracteres (global ao processo, fica subido). Nota: nas colunas float de Parquet o pandas funde NaN com NULL na amostra — com estatísticas no footer onull_countconta só NULL. - Os distintos, a média, os zeros e as amostras de valores continuam a ser da amostra. Por isso, quem os compara com o volume usa as linhas lidas e não o
row_countdo ficheiro inteiro. É o caso da regrauniquede uma coluna, das sugestões de chave, dos alertas e tipos semânticos do perfil, do gerador de dados sintéticos e da estimativa da regra no browser. Num Parquet com nulos exatos do footer, a coluna guarda os nulos da amostra emsample_null_count. Sem esta base, uma chave única de um ficheiro acima das 100 000 linhas aparecia cheia de repetidos (método). - Um CSV em ASCII nos primeiros 64 KiB e em cp1252 mais à frente falha na leitura com a mensagem do pandas, em vez de trocar caracteres em silêncio.
- Ficheiros Parquet e CSV acima de 2 GB são recusados no perfil («Particiona a origem»). Os ficheiros de eventos e logs não têm este teto: são lidos em streaming só até ao teto de linhas.
- Sem monitores automáticos baratos: não há metadados de catálogo. A frescura vem da primeira coluna temporal do ficheiro.
- Cada scan faz pedidos GET e transfere dados do bucket, e o fornecedor cloud cobra-os.
- O
endpoint_urldo S3 passa pela guarda anti-SSRF. No SaaS tem de ser um endpoint público; um MinIO numa rede privada só é alcançável em self-hosted comDATAVAULT_ALLOW_PRIVATE_NETWORKS=true.
Ficheiros de eventos e logs (cartão #183): .jsonl, .ndjson, .log, .jsonl.gz, .ndjson.gz e .log.gz (webhooks arquivados, access logs, logs de aplicações). Cada linha é um evento, lido por esta ordem: JSON, syslog (RFC 5424 e RFC 3164), nginx/apache (combined e common) e logfmt key=value (pelo menos dois pares). Uma linha que não encaixa em nenhum conta como malformada, com o motivo (invalid_json, not_an_object, unparseable, line_too_long acima de 1 MiB). Os logs no formato por omissão do logging do Python ou do log4j (2026-10-09 10:00:00,123 INFO app: …) não encaixam em nenhum: todas as linhas contam como unparseable e a regra max_malformed_pct falha sempre. Para os medir, a aplicação tem de escrever os logs em JSON ou em logfmt. Um timestamp que em UTC sai dos anos 1 a 9999 (9999-12-31T23:59:59-03:00, por exemplo) ou que traz um fuso de 24 horas ou mais não torna a linha malformada: o evento conta, mas sem timestamp legível. Num access log a data faz parte do formato, e uma data assim faz a linha contar como unparseable. As colunas vêm das chaves dos eventos, como num tópico Kafka: as primeiras 1 000 chaves distintas, pela ordem em que aparecem (columns_capped diz se havia mais). Os exports do Cloud Logging para o GCS são ficheiros .json e não entram: um .json num bucket tanto pode ter um evento por linha como um documento inteiro. Implementação em app/infra/connectors/event_files.py.
- Leitura: as primeiras
DATAVAULT_LOGFILE_MAX_LINESlinhas (50 000 por omissão), em blocos de 64 KiB e com o.gzdescomprimido em streaming: o ficheiro nunca fica todo em memória. A leitura também para aos 256 MiB descomprimidos ou aos 30 s. Se o ficheiro continuava, o perfil dizsamplede o número de linhas é extrapolado pelos bytes lidos (row_count_estimated). Um.gztruncado depois de linhas lidas não chumba o perfil: a leitura para aí. - Métricas (
source_metricscomkind: "logfile"): formato dominante, linhas lidas, % de malformadas e motivos, % de linhas com timestamp legível, % de pares de linhas por ordem cronológica, maior intervalo entre eventos (segundos), distribuição por nível (debug,info,warning,error,criticaleotherpara um nível que não se reconhece), número de conjuntos de chaves distintos (mais do que um indica mudança de schema entre versões da app), os 20 templates de mensagem mais frequentes com a contagem de cada um epii_kinds, o número de mensagens com cada tipo de dado pessoal (email, NIF com dígito de controlo, IBAN e telemóvel portugueses, cartão de pagamento que passa o Luhn). - Templates: o template é a mensagem com dígitos →
<n>, UUID →<uuid>, hexadecimal →<hex>, IP →<ip>e email →<email>. Nomes de pessoas e o resto do texto ficam como estão: «Pagamento recusado para Maria Santos» fica igual. Com amostras reais nos prompts (o modo por omissão), o diagnóstico de causa-raiz manda os templates ao Claude, como as amostras das colunas. Com amostras sintéticas, a IA recebe só as contagens, sem o texto. - Regras: as regras de streams aplicam-se como num tópico (
max_malformed_pct,max_event_age,required_fields). Omax_event_agee a frescura do dataset usam o evento mais recente das linhas lidas. Num ficheiro maior do que o teto de leitura, são as primeiras linhas, e um ficheiro que cresce por baixo parece mais antigo do que é (o mesmo acontece num CSV). O onboarding sugeremax_malformed_pcta 1%.schema_conformitynão se aplica: um ficheiro não tem schema registado. - Dados pessoais: com mensagens com dados pessoais, o scan abre um issue de monitor automático
log_pii(aviso) só com as contagens por tipo. Marcado «ignorado», não volta a abrir nesse dataset. - UI: o detalhe do dataset mostra o painel «Ficheiro de logs» com as métricas, os níveis em barras e a tabela de templates.
Documentos no mesmo bucket: ficheiros PDF, DOCX, TXT, MD e HTML sob o prefixo não são tabelas. Entram pela análise de documentos (botão Documentos, UNSTR), com um teto de 50 MB por documento.
PDF digitalizados (OCR local, cartão #418): um PDF sem camada de texto passa pelo OCR no worker (Tesseract 5 da imagem, português, inglês e espanhol), página a página, com teto de páginas (DATAVAULT_DOCUMENTS_OCR_MAX_PAGES, 50) e prazo por documento (DATAVAULT_DOCUMENTS_OCR_TIMEOUT_SECONDS, 180 s), dentro de um orçamento por scan (DATAVAULT_DOCUMENTS_OCR_SCAN_BUDGET_SECONDS, 3 h). O documento fica marcado com o método e a confiança média, e o corpus abre o problema «N documentos lidos por OCR». O OCR é local: o documento não sai da instalação, e nada é escrito de volta no bucket. Vale para todas as fontes documentais (GCS, S3, SharePoint, Google Drive, Confluence). Sem o OCR instalado, o PDF fica fora do índice com «precisa de OCR» (método).
Imagens no mesmo bucket (cartão #182): com include_images: true na conexão (caixa «Incluir imagens na análise de documentos» no wizard; desligado por omissão), os ficheiros JPEG, PNG, WebP, GIF, TIFF e BMP entram também na análise de documentos. Vale para GCS, S3, SharePoint e Google Drive. Cada imagem é perfilada no worker só com o Pillow, sem GPU nem LLM: formato, largura × altura, ficheiro corrompido ou truncado, EXIF (se há GPS, a data de captura e a marca e modelo do aparelho, nunca o número de série), SHA-256 e um dHash de 64 bits. O corpus ganha o bloco images, com os duplicados exatos (SHA-256) e os quase duplicados (dHash a ≤ DATAVAULT_IMAGE_NEAR_DUP_HAMMING bits, 8 por omissão), os formatos, as resoluções, a % com GPS, a % corrompida e a idade pelo EXIF face à data do ficheiro. Abre os problemas image_corrupt, image_near_duplicates e image_gps_pii. Uma fotografia com coordenadas GPS é um dado pessoal de localização, por isso o problema aberto ou reconhecido bloqueia a prontidão para RAG e para treino, também na regra min_ai_readiness da política de dados. Uma imagem acima de DATAVAULT_IMAGE_MAX_PIXELS (50 MP) não é descodificada e fica «demasiado grande». Os píxeis e o texto do EXIF não vão para a BD, para o LLM nem para os logs, e a secção «Imagens» do dataset mostra só métricas, sem miniaturas. Sem o extra [images] (o Pillow, que a imagem de produção já tem pelo extra [ocr]), as imagens contam como não analisáveis e o scan termina na mesma. Imagens e documentos partilham o limit do pedido de análise (200 por omissão, até 1000). No GCS e no S3, o corte segue a ordem alfabética dos caminhos; no SharePoint e no Drive, a ordem da listagem. Para as imagens não deixarem documentos de fora, sobe o limit ou aponta o prefix a uma pasta só de imagens.
Azure Blob Storage / ADLS Gen2 (ficheiros como tabelas)
Data lake em Azure: Blob Storage e ADLS Gen2 (contas com namespace hierárquico), incluindo a conta de armazenamento de um workspace Synapse e as external locations do Databricks em Azure. O conector adls (AdlsConnector em app/infra/connectors/object_storage.py) é o código do GCS e do S3 sobre o adlfs, com o protocolo fsspec abfs. O container faz de bucket: cada Parquet, CSV ou .csv.gz sob container/prefix é uma tabela. Ficheiros de eventos e logs, documentos (PDF, DOCX, TXT, MD, HTML), OCR, imagens (include_images) e limitações seguem a secção do GCS e do S3.
Uma credencial, sempre explícita (A2). A conexão leva exatamente uma destas: a chave da conta (account_key), um token SAS (sas_token), um service principal (tenant_id + client_id + client_secret) ou anonymous: true para containers públicos. Sem nenhuma, ou com duas, a criação é recusada com 422. O DataVault nunca usa as credenciais do servidor. O adlfs recebe sempre anon=True, por isso sem credencial fica anónimo e nunca recorre ao DefaultAzureCredential (identidade gerida, Azure CLI, variáveis de ambiente). O conector recusa ligar se o servidor tiver definida uma variável AZURE_STORAGE_* que o adlfs leria no lugar da credencial da conexão, como a AZURE_STORAGE_CONNECTION_STRING. As mensagens de erro dizem só o nome dos campos e das variáveis, nunca o valor.
Service principal (recomendado) — do lado do cliente:
- No Entra ID: App registrations → New registration (ex.:
datavault-reader) e, em Certificates & secrets, um client secret com data de expiração. - No container: Access Control (IAM) → Add role assignment → Storage Blob Data Reader para a app. O papel lista e lê blobs e não escreve nada. Atribuído no container, e não na conta, a app não vê os outros containers.
- No wizard: a conta de armazenamento (
account_name, a parte antes de.blob.core.windows.net), ocontainer, oprefixopcional e, em «Service principal (Entra ID)», o ID do tenant, o ID da aplicação e o secret.
Token SAS: gera-o no container (Shared access tokens) só com as permissões Read e List e com data de expiração, e cola o token (começa por sv= e inclui sig=), não o URL. Quando expira, o teste de ligação e os scans falham até colares outro.
Chave da conta: funciona, mas dá à chave acesso total à conta, escrita incluída. O conector só lê (find, info e open('rb')); o service principal ou o SAS limitam o que uma credencial vazada permite.
Container público: «Acesso anónimo», para containers com o nível de acesso Container. O preset «Feriados públicos — Azure Open Datasets» usa-o.
Nuvens soberanas: endpoint_suffix aceita core.windows.net (por omissão), core.usgovcloudapi.net (Azure US Government) e core.chinacloudapi.cn (Azure China, 21Vianet), e recusa qualquer outro. O host é sempre <account_name>.blob.<endpoint_suffix>, e o service principal pede o token ao Entra ID da mesma nuvem.
Azurite (desenvolvimento e CI): endpoint_url (ex.: http://127.0.0.1:10000/devstoreaccount1) aponta o conector ao emulador. Só é aceite com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true; em produção é recusado com 422. Com endpoint_url, a credencial é a chave, o SAS ou o acesso anónimo.
Campos: account_name, container, prefix?, max_files? (200 por omissão), include_images?, endpoint_suffix? e uma credencial. protocol não é configurável: abfs, abfss e az são ignorados (o conector fala sempre abfs) e qualquer outro valor é recusado.
Instalação: pip install "datavault-backend[adls]" (o adlfs, que traz o azure-storage-blob e o azure-identity, mais o pandas e o pyarrow). Vai na imagem de produção. Sem o extra, o conector responde com a instrução pip.
OneLake e Synapse: o OneLake do Microsoft Fabric fala a API do ADLS Gen2 num host próprio (onelake.dfs.fabric.microsoft.com), fora das nuvens que este conector aceita. Para um Lakehouse ou Warehouse do Fabric, usa o conector fabric. Num workspace Synapse, aponta o adls à conta de armazenamento do workspace e ao sistema de ficheiros onde estão os ficheiros; as tabelas dos SQL pools leem-se pelo mssql.
Rede: uma conta com firewall (Networking → Enabled from selected virtual networks and IP addresses) tem de aceitar o IP de saída do DataVault (ver o topo deste guia).
SFTP (file drops de parceiros e bancos)
Bancos, parceiros logísticos, seguradoras e redes EDI entregam ficheiros numa pasta SFTP. O conector sftp (SftpConnector em app/infra/connectors/sftp.py) lê essa pasta com o código do GCS e do S3, sobre o protocolo fsspec sftp (paramiko). A pasta root_path faz de bucket: cada Parquet, CSV ou .csv.gz sob root_path/prefix é uma tabela, tal como os ficheiros de eventos e logs. Os PDF, DOCX, TXT, MD e HTML entram na análise de documentos. Leitura, OCR, imagens (include_images) e limitações seguem a secção do GCS e do S3.
«O ficheiro de hoje não chegou». A data de alteração de cada ficheiro (o mtime do stat SFTP) é a última modificação que os monitores automáticos guardam em cada scan. Quando o parceiro substitui o mesmo ficheiro a cada entrega (ex.: pagamentos.csv), o monitor de frescura aprende a cadência das entregas e abre um problema quando a seguinte se atrasa. Um ficheiro com a data no nome (pagamentos_20261009.csv) é um dataset novo a cada entrega, com uma só data, e esse monitor não vê o atraso.
Chave do servidor fixada. host_key_fingerprint é o fingerprint SHA256 da chave do servidor, no formato que o OpenSSH imprime:
ssh-keyscan -t ed25519 sftp.parceiro.example | ssh-keygen -lf -
# 256 SHA256:… sftp.parceiro.example (ED25519)
Confirma o valor com o parceiro por outro canal: o ssh-keyscan pergunta ao próprio servidor e não prova que é o certo. O wizard aceita a linha inteira ou só o SHA256:…. O conector compara-o com a chave que o servidor apresenta, antes de autenticar. Se não coincidir, a ligação cai sem enviar a password nem ler dados, e o teste de ligação diz o tipo de chave que o servidor apresentou. O paramiko negoceia primeiro a chave Ed25519; num servidor sem ela, usa -t ecdsa ou -t rsa. Se o parceiro só te der o fingerprint RSA ou ECDSA de um servidor que também tem Ed25519, cola a linha inteira do ssh-keygen -lf, que acaba no tipo (ex.: (RSA)): o conector negoceia então só as chaves desse tipo. Em produção (DATAVAULT_ENV=prod) o campo é obrigatório e a criação sem ele é recusada com 422. Fora de produção, uma conexão criada pela API sem o campo aceita a chave sem verificação, e o teste de ligação mostra o fingerprint que o servidor apresentou; o wizard pede-o sempre. O formato MD5 não é aceite.
Só a credencial da conexão. A conexão leva password ou a chave privada em PEM (private_key_pem, com passphrase se estiver cifrada), nunca as duas. Cola o conteúdo da chave, que começa por -----BEGIN: um caminho de ficheiro é recusado com 422. O conector nunca lê o known_hosts, as chaves nem o agente SSH do servidor DataVault. Aceita chaves Ed25519, ECDSA e RSA. As mensagens de erro dizem só o nome dos campos.
Só leitura, dentro de root_path. O conector lista pastas, faz stat e abre ficheiros em rb. Escrever, apagar, renomear, criar pastas e descarregar para o disco do servidor DataVault estão bloqueados no próprio filesystem do conector. root_path é um caminho absoluto (ex.: /outbound) e prefix uma subpasta dele, os dois sem ... Cada pasta listada e cada ficheiro lido ou consultado (stat) tem de estar dentro de root_path, também depois de o servidor resolver os links: um prefix que seja um link para fora é recusado. Os links simbólicos não entram na listagem nem são abertos.
Do lado do parceiro (ou da tua equipa):
- Um utilizador só para o DataVault, preso à pasta das entregas e só com leitura. No OpenSSH:
ChrootDirectorypara a pasta eForceCommand internal-sftp -R(o-Rpõe o servidor SFTP em só leitura). Num serviço SFTP gerido, uma política só de leitura para esse utilizador. - Uma credencial para esse utilizador. Com chave, gera um par só para isto (
ssh-keygen -t ed25519 -f datavault_sftp), põe o.pubnoauthorized_keysdo utilizador e cola a privada no wizard. - O fingerprint da chave do servidor (acima).
- Se o servidor filtra por IP, autoriza o IP de saída do DataVault (ver o topo deste guia).
Rede: o host é só o nome ou o IP, sem sftp://, porta nem caminho, e a porta é um inteiro (22 por omissão). A guarda anti-SSRF confere o nome e cada IP resolvido antes de abrir o socket, e o paramiko liga ao IP que passou a guarda. Em produção, redes privadas e loopback são recusadas; o endpoint de metadata da cloud é recusado sempre.
Campos: host, port?, username, password ou private_key_pem (+ passphrase?), host_key_fingerprint, root_path, prefix?, max_files? (200 por omissão), include_images?. protocol não é configurável: só sftp; o alias ssh do fsspec e qualquer outro valor são recusados.
Instalação: pip install "datavault-backend[sftp]" (o paramiko, mais o pandas e o pyarrow). Vai na imagem de produção. Sem o extra, o conector responde com a instrução pip.
FTPS e FTP não são suportados. O protocolo ftp do fsspec não faz TLS de forma fiável, e o FTP sem TLS manda a password em claro. A um parceiro que só tem FTPS, pede SFTP ou um bucket.
PostgreSQL / MySQL / SQLite
- PostgreSQL (
postgres, extrapostgres): cria um utilizador read-only (GRANT CONNECT ON DATABASE,GRANT USAGE ON SCHEMA,GRANT SELECT ON ALL TABLES IN SCHEMA ...). Campos:host,port,database,user,password, ouurlcompleta. - MySQL/MariaDB (
mysql, extramysql):GRANT SELECT ON <db>.* TO 'datavault'@'%';. Campos iguais ao Postgres. - SQLite (
sqlite, incluído):pathpara o ficheiro.db— pensado para testes e demos, não para produção.
Réplica de leitura (Postgres, MySQL e SQL Server): replica_host e replica_port (opcional) põem todas as ligações do DataVault na réplica, com as mesmas credenciais e sem fallback para o primário. Detalhes em DBA.md.
TLS (Postgres e MySQL/MariaDB)
Desde o cartão #449, cada conexão Postgres ou MySQL tem um tls_mode:
tls_mode |
O que faz | Como se escolhe |
|---|---|---|
verify-full |
Cifra a ligação e verifica o certificado e o hostname do servidor. Sem TLS, ou com um certificado que não confere, a ligação não abre. | Default das conexões novas. |
require |
Cifra a ligação, mas aceita qualquer certificado. Um intermediário pode fazer-se passar pela fonte. | Confirmação explícita (tls_downgrade_confirmed: true na API, um diálogo no wizard). Fica na auditoria como connection_tls_downgraded, só com o nome, o tipo e o modo. |
disable |
Liga sem TLS: credenciais e dados viajam em claro. | A mesma confirmação, e também fica na auditoria. |
- CA do servidor (
ssl_ca_pem). Sem ela, o certificado tem de vir de uma CA pública (o bundle da Mozilla, viacertifi). O RDS, o Cloud SQL, o Azure Database e as CAs internas são privadas: cola o certificado da CA em PEM (texto que começa por-----BEGIN CERTIFICATE-----). Um bundle com várias CAs também serve, até 256 KB, que chega para o bundle global do RDS. O PEM é cifrado com o resto do config e nunca aparece na API, nos logs nem na auditoria. Só se aceita comverify-full. Caminhos de ficheiro e chaves privadas são recusados com 422. - Drivers. No Postgres, a libpq recebe
sslmodeesslrootcert. A CA própria vai para um ficheiro temporário 0600, que vive tanto quanto o engine e é apagado no fim. Não se usa osslrootcert=systemda libpq: o psycopg2-binary da imagem embute uma libpq 17 com um OpenSSL que procura as CAs do sistema numa pasta da build do wheel que não existe. No MySQL, o PyMySQL (≥ 1.2) recebe umSSLContextcom a CA em memória e recusa ligar se o servidor não anunciar TLS. Comtls_mode, a ligação usa sempre o driver oficial do conector (postgresql://oupostgresql+psycopg2://;mysql+pymysql://oumariadb+pymysql://). urlcompleto. Osslmodedo URL só é aceite se for igual aotls_mode.allow,prefereverify-casão recusados: os dois primeiros caem para claro sem avisar e o terceiro não confere o hostname.sslrootcert,sslcertesslkeycontinuam fora da allow-list.- Réplica de leitura. Com
verify-full, o certificado da réplica tem de ser válido para oreplica_host. - MongoDB (#211). As conexões MongoDB têm o mesmo
tls_mode, o mesmossl_ca_peme a mesma confirmação. Não há conexões MongoDB anteriores à regra: semtls_mode, ligam emverify-full. O pymongo recebetlsetlsCAFile(o bundle docertifiou a CA própria num ficheiro 0600) e, emrequire,tlsAllowInvalidCertificatesetlsAllowInvalidHostnames. Otlsdouritem de coincidir com otls_mode. Detalhes na secção do MongoDB. Prova:backend/tests/test_mongodb_tls.py(opções do driver, API e auditoria) ebackend/tests/test_mongodb_tls_docker.py, contra omongodb-tlsdo compose (perfiltls, só com TLS e o certificado dopostgres-tls). - Conexões criadas antes do #449. Não têm
tls_modee mantêm o comportamento antigo: a libpq emprefere o PyMySQL em modo «preferred», que cifram se o servidor deixar, sem verificar, e podem cair para claro. A app mostra-as como «TLS não verificado», e a API devolvetls_mode: nulletls_verified: false. Para as verificar, recria-se a conexão. Mudar o default destas conexões é decisão do owner (ENCRIPTACAO_RESIDENCIA.md §8). - Fontes públicas da galeria. O Postgres do RNAcentral e o MySQL do Ensembl não aceitam TLS. O MySQL do Rfam usa o certificado autoassinado que o MySQL gera. Os presets trazem
disableerequire, e a app pede a mesma confirmação antes de os ligar. - Prova.
backend/tests/test_connector_tls.pycobre os kwargs por modo e o PyMySQL com TLS real contra um servidor MySQL falso.backend/tests/test_postgres_tls_docker.pycorre contra um Postgres de certificado autoassinado: o serviçopostgres-tlsdodocker-compose.connectors.yml, no tier docker da matriz.
Garantias mecânicas (além dos GRANTs): as sessões Postgres abrem com default_transaction_read_only=on e o SQLite com mode=ro. Uma url completa tem de usar o dialeto do próprio conector (ex.: postgresql://… num conector postgres) e só aceita os query params da allow-list do dialeto (qualquer outro é recusado com 422, a apontar para os campos estruturados; a regra também é aplicada em runtime a conexões já gravadas):
| Dialeto | Query params permitidos |
|---|---|
postgresql |
sslmode (só verify-full, require ou disable, igual ao tls_mode), application_name, connect_timeout |
mysql / mariadb |
charset, connect_timeout, read_timeout |
sqlite |
mode=ro, uri=true |
snowflake |
warehouse, role, application, login_timeout, network_timeout |
bigquery |
location, arraysize, list_tables_page_size, maximum_bytes_billed |
databricks |
http_path, catalog, schema |
mssql (Fabric, SQL Server/Azure SQL/Synapse) |
driver (ODBC Driver 17/18 for SQL Server), Authentication (ActiveDirectoryServicePrincipal/ActiveDirectoryPassword), Encrypt (ativo), TrustServerCertificate=no, ApplicationIntent=ReadOnly (só este valor), connect_timeout |
oracle |
service_name, sid, connect_timeout |
Ficam de fora, entre outros, host/hostaddr, options, local_infile, init_command, odbc_connect, credentials_path e authenticator.
Destino revalidado ao ligar (cartão #339): cada host e URL do config volta à guarda anti-SSRF antes de abrir a ligação, uma vez por engine (BaseConnector.assert_destination_allowed), e não só ao gravar a conexão. Uma conexão gravada antes de uma regra nova, ou um nome que entretanto passou a resolver para a rede interna (DNS rebinding), é recusada antes de qualquer ligação TCP. O nome é resolvido uma vez e, se algum dos IPs (IPv4 ou IPv6) for interno, a ligação é recusada. O scan marca o dataset como falhado com a razão «Destino de conexão não permitido neste ambiente: '
| Dialeto | Destino |
|---|---|
PostgreSQL (psycopg2, psycopg) |
os IPs validados vão em hostaddr e o host fica com o nome, repetido um por IP: a libpq liga aos IPs e verifica o certificado contra o nome |
MySQL/MariaDB (pymysql) |
o socket TCP abre-se para o primeiro IP validado que aceite a ligação e o PyMySQL fica com o hostname, contra o qual o TLS continua a verificar o certificado |
SQL Server, Fabric, Oracle, Snowflake, BigQuery (url), Databricks e outros drivers do Postgres/MySQL |
só a revalidação: o driver resolve o nome outra vez ao ligar |
S3 (endpoint_url), Azure (endpoint_url do Azurite) e Dynamics 365 (org_url) |
revalidação ao abrir o filesystem ou o cliente (o Dynamics fixa o IP em cada pedido pelo ReadOnlyHttpClient) |
Com as redes privadas bloqueadas (produção), os drivers com IP fixado (psycopg2, psycopg e pymysql) também recusam um host que não resolve, um caminho (socket Unix local) e uma lista de hosts separados por vírgula: a guarda não teria validado o destino a que o driver ia ligar. Com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true (desenvolvimento), o socket local e um nome que não resolve ligam como antes, sem IP fixado.
Desvio-padrão: calculado pela função nativa STDDEV_POP (Postgres, MySQL/MariaDB, BigQuery, Snowflake, Databricks, Oracle) ou STDEVP (SQL Server, Azure SQL, Synapse, Fabric). No SQLite é um agregado em Python registado em cada ligação: exato em inteiros e Welford no resto, na mesma query dos outros agregados. A fórmula antiga E[x²] − E[x]² em vírgula flutuante cancelava-se em colunas de grande magnitude e devolvia 0,0 numa coluna epoch-millis com dispersão 100 (o valor certo é 28,9). Fica só como fallback de dialetos desconhecidos. Uma média ou desvio que a fonte devolva como NaN/±Inf (ex.: float8 com 'NaN' no Postgres) fica «desconhecido» no perfil.
Tempo máximo por query e repetições
DATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S (120 s) é o tempo máximo de cada query em todas as fontes SQL. A fonte cancela a query no servidor, e um agregado aberrante não deixa o warehouse ligado até ao teto do fornecedor (no Snowflake, 2 dias). Cada dialeto recebe-o pela sua via (app/infra/connectors/timeouts.py, cartão #346):
| Fonte | Como o teto chega à fonte |
|---|---|
| PostgreSQL | -c statement_timeout nas options da ligação |
| MySQL | SET SESSION max_execution_time (ms) em cada ligação nova e o read_timeout do PyMySQL 30 s acima do teto mais longo das sessões |
| MariaDB | SET SESSION max_statement_time (s) em cada ligação nova (o MariaDB não tem max_execution_time) e o mesmo read_timeout |
| Snowflake | STATEMENT_TIMEOUT_IN_SECONDS nos session_parameters, ao lado do QUERY_TAG |
| Databricks | SET STATEMENT_TIMEOUT em cada sessão nova |
| BigQuery | job_timeout_ms no QueryJobConfig por omissão do cliente, junto ao maximum_bytes_billed |
| SQL Server (pymssql) | timeout nos connect_args |
| SQL Server (pyodbc) e Fabric | Connection.timeout de cada ligação. O timeout do connect do pyodbc é o de login |
| Oracle | call_timeout (ms) do oracledb em cada ligação nova. Conta por ida e volta à base; ao expirar, o driver interrompe a chamada no servidor (DPY-4024) |
Com 0, nenhum dialeto recebe teto. As regras SQL e o ensaio da remediação mudam o teto da sessão só durante a sua query e repõem o da ligação no fim. Um MySQL ou Databricks que recuse o SET (versão antiga) liga na mesma, com o teto do servidor, e deixa connector_statement_timeout_unsupported no log.
Repetições. Um erro passageiro da fonte deixava o dataset falhado nesse scan e um buraco no histórico de drift e anomalias. Agora o perfil, o perfil incremental, as linhas, os metadados e a contagem de uma tabela repetem-se até DATAVAULT_CONNECTOR_RETRY_ATTEMPTS vezes (3), com esperas de até 2 s e 4 s (exponenciais, no máximo 30 s, com jitter). Só se repete o que é passageiro em cada dialeto (app/infra/connectors/retry.py):
| Fonte | Repete |
|---|---|
| PostgreSQL | SQLSTATE 08xxx, 57P01/57P02/57P03 (failover), 53300 (sem ligações livres), 40001/40P01 (conflito numa réplica, deadlock) |
| MySQL/MariaDB | 2003, 2006, 2013 (salvo o read_timeout), 1040, 1213 |
| BigQuery | rateLimitExceeded, backendError, internalError, HTTP 429/500/502/503 |
| Snowflake | 390114 (token da sessão expirado) e warehouse suspenso ou a retomar |
| Databricks | warehouse a arrancar, TEMPORARILY_UNAVAILABLE |
| SQL Server/Fabric | SQLSTATE 08xxx do ODBC e os erros passageiros do Azure SQL (1205, 40197, 40501, 40613, 49918–49920) |
| Oracle | só a ligação que o SQLAlchemy dá por perdida: ORA-00028, ORA-03113, ORA-03114, ORA-03135, ORA-01033, ORA-02396, DPY-1001 e DPY-4011 |
| Todas | ligação reposta ou recusada pelo outro lado |
Nunca se repetem os timeouts (a query cancelada por tempo custaria outra vez o mesmo), os erros de autenticação e de permissão, as recusas do DataVault (teto de bytes, destino recusado pela guarda anti-SSRF) nem um erro de configuração. O SQLite nunca repete. As repetições de um dataset somam no máximo DATAVAULT_CONNECTOR_RETRY_BUDGET_S (60 s, esperas e tentativas repetidas): esgotado o orçamento, o dataset falha com o último erro. Cada repetição fica no log connector_retry com o correlation_id do scan, a tentativa e só o nome da classe do erro (a mensagem do driver pode trazer dados). Quando houve repetições, o resumo do scan guarda o total em retries. Um erro resolvido por uma repetição não conta para o circuit breaker da conexão.
Tabelas largas
Cada agregado é um scan à coluna, e num warehouse é faturado. Por isso uma tabela com mais de DATAVAULT_PROFILE_MAX_COLUMNS colunas (120) perfila só 120. Até ao cartão #289 eram as primeiras 120 e o catálogo do dataset ficava só com elas: numa tabela SAP/CRM de 300 colunas, 180 desapareciam do catálogo e o monitor de mudanças de schema (SCHEMACHG) ficava cego a elas. Agora:
- o catálogo guarda todas as colunas, lidas na mesma passagem do perfil (sem uma segunda ida à fonte);
- as colunas perfiladas são escolhidas por prioridade: chaves (PK,
id,*_id), colunas de data/hora, nomes com dados pessoais (email,nif,iban,telefone, …) e depois a ordem do esquema. A escolha é determinística e estável entre scans, e é a mesma no perfil incremental. Vive emapp/infra/connectors/profiling.py; - o perfil da tabela regista
profiled_columns,skipped_columnsecolumn_count(o catálogo). A UI mostra «120 de 300 colunas perfiladas» e marca as restantes como «não perfilada»; - o diff de schema compara catálogos completos. O issue
schema_changede uma tabela larga diz quantas colunas foram perfiladas e que o diff cobre o catálogo todo. No primeiro scan depois desta versão o perfil anterior ainda não tem a fotografia do catálogo, e o diff compara só as colunas que esse perfil via. Assim não aparecem ~180 colunas «novas» falsas, e a nota do issue explica-o.
Amostra de linhas por dialeto
As regras que precisam de linhas (pattern, required_fields) e os duplicados leem até 5 000 linhas por tabela (ROW_FETCH_LIMIT, sempre abaixo da amostragem máxima da fonte); a qualidade de rótulos lê com o mesmo método, até ao seu próprio teto. Desde o cartão #317 essas linhas são uma amostra escolhida pela própria fonte, com uma semente fixa por dataset — a mesma de scan para scan, para os clusters de duplicados e as violações de padrão não abrirem e fecharem sem os dados mudarem. Só SELECTs:
| Fonte | Como amostra | Repete com a mesma semente |
|---|---|---|
| PostgreSQL | TABLESAMPLE BERNOULLI (p) REPEATABLE (s); acima de DATAVAULT_SAMPLE_FULL_SCAN_MAX_ROWS (25 M), TABLESAMPLE SYSTEM (blocos) |
sim |
| BigQuery | TABLESAMPLE SYSTEM (p PERCENT) — só os blocos amostrados são faturados |
não (o BigQuery não tem REPEATABLE) |
| Snowflake | SAMPLE SYSTEM (p) SEED (s) |
sim |
| Databricks | TABLESAMPLE (p PERCENT) REPEATABLE (s) |
sim |
| MySQL/MariaDB, SQLite, SQL Server/Azure SQL/Synapse/Fabric | WHERE hash(chave primária#s) mod 10⁶ < k, ordenado pelo hash (até ao mesmo teto de 25 M) |
sim |
| sem chave primária, vista, ou amostra nativa recusada | as primeiras linhas por ordem da 1.ª coluna (sem ordem acima do teto e nos warehouses) | só com ordem |
| CSV, GCS, S3 | uma linha em cada k das que o perfil lê | sim |
p sai do row_count do perfil (~1,5× o limite, cortado em Python pelo hash das linhas); uma vista ou foreign table no PostgreSQL recusa TABLESAMPLE e a amostra passa às primeiras linhas, com o evento sample_native_fallback no log do worker. O perfil grava o método em metrics_json["sample"] (method, rows, fraction, seed, deterministic) e a app mostra-o num selo junto às colunas; o issue de duplicados e os issues das regras de padrão e de validade dizem-no no texto. Um scan manual com «Nova amostra» (resample no POST /scans) usa outra semente só nesse scan.
Os sample_values do perfil (até 20 por coluna, para os tipos semânticos e a deteção de PII) saem de uma amostra de 1 000 linhas pelo mesmo método, em vez de um SELECT DISTINCT … LIMIT 20 por coluna: o perfil SQL faz 2 + ⌈colunas/10⌉ queries por tabela, onde antes fazia 1 + ⌈colunas/10⌉ + colunas (numa tabela de 120 colunas, 120 queries a mais; no BigQuery, 120 leituras faturadas). Há colunas que voltam a pedir os seus valores à parte: as quase todas NULL e as de poucas categorias que a amostra não viu todas, para a proposta de valores aceites não perder uma categoria rara. Com a guarda de custo do BigQuery degradada, nenhuma volta. No perfil incremental a amostra fica na janela nova e nunca é TABLESAMPLE, que corre antes do WHERE e leria a tabela inteira: vai pelo hash da chave ou pelas primeiras linhas pela coluna temporal. O hash de 32 bits por dialeto (hashtext, CRC32, dv_hash, FARM_FINGERPRINT, HASH, xxhash64, HASHBYTES) serve também os checksums do data-diff e os sketches HLL do perfil incremental, que passam a existir no BigQuery, Snowflake, Databricks e SQL Server. O texto que o hash lê é fiel ao valor. No BigQuery, ARRAY, STRUCT, JSON e BYTES vão por TO_JSON_STRING. No SQL Server e no Fabric, datetime, float, money e binários vão por CONVERT com estilo explícito, porque o CAST perdia segundos e dígitos. Uma coluna que a fonte não consegue escrever em texto fica sem checksum e o data-diff compara-a pela amostra. Detalhes e limitações em Métodos › Amostragem.
Chaves, partições e tipo do objeto
Desde o cartão #347 o DataVault lê o que a fonte declara sobre cada objeto. Lê só o catálogo, nunca dados, na descoberta e em cada scan. Uma parte que a conta não possa ler fica vazia e o scan segue.
| Fonte | Tipo do objeto | Chaves primárias e estrangeiras | Partições e clustering |
|---|---|---|---|
| PostgreSQL | tabela (também a particionada), view, materialized view e foreign table (externa), pelo inspector do SQLAlchemy | inspector | — |
| MySQL/MariaDB, SQLite, SQL Server, Fabric | tabela e view, pelo inspector | inspector | — |
| BigQuery | INFORMATION_SCHEMA.TABLES.table_type |
TABLE_CONSTRAINTS, KEY_COLUMN_USAGE e CONSTRAINT_COLUMN_USAGE |
COLUMNS.is_partitioning_column e clustering_ordinal_position |
| Snowflake | INFORMATION_SCHEMA.TABLES.TABLE_TYPE |
inspector | CLUSTERING_KEY (só nomes simples: uma expressão como TO_DATE(ts) fica de fora) |
| Databricks | tabela e view, pelo inspector | inspector | DESCRIBE DETAIL (partitionColumns, clusteringColumns) |
No BigQuery, no Snowflake e no Databricks as chaves são informativas: a fonte não as impõe. O DataVault usa-as como chave, nunca como prova de unicidade (a regra unique mede-a).
Permissões. Nenhuma nova. No BigQuery as vistas de INFORMATION_SCHEMA pedem bigquery.tables.get e bigquery.tables.list, já incluídas em BigQuery Data Viewer. São até cinco consultas por dataset e scan (MATERIALIZED_VIEWS só se o dataset tiver materialized views, CONSTRAINT_COLUMN_USAGE só se tiver chaves estrangeiras), cada uma faturada no mínimo a 10 MB.
Onde aparece. O catálogo mostra o tipo ao lado do nome (views, materialized views, tabelas externas) e as marcas «PK», «partição» e «clustering» nas colunas. A API devolve kind em cada dataset e primary_key, partition e clustering em cada coluna do detalhe. A regra unique sugerida no primeiro scan fica na chave primária declarada; os nomes (id, *_id) ficam como recurso. Uma chave estrangeira declarada vira uma aresta declared na linhagem, antes da convenção por nome. O data-diff usa a chave primária da base quando o pedido não traz key_columns, e a sugestão de chaves do matching põe primeiro a chave primária que as duas fontes declaram com os mesmos nomes.
Views e tabelas externas. Cada query a uma view executa o SQL dela por inteiro, e a uma tabela externa lê os ficheiros. Por omissão perfilam em basic. A query do COUNT(*) corre sobre o objeto inteiro e traz também o mínimo e o máximo das colunas de data e hora: a contagem fica exata, porque os monitores de volume a comparam entre scans, e a frescura vem da linha mais recente, não das primeiras lidas. Os outros agregados e as amostras vêm só das primeiras 100 000 linhas, sem COUNT(DISTINCT), e acima disso o perfil diz sampled. Numa tabela só com inserções, as primeiras linhas são quase sempre as mais antigas: os nulos, os vazios, os sentinelas, os intervalos numéricos e as amostras das linhas novas ficam por medir. Quando uma view grande passa do perfil completo ao limitado, as métricas dela podem saltar uma vez no scan seguinte. No BigQuery o LIMIT não reduz os bytes faturados de uma view, e o teto maximum_bytes_billed da conexão continua a recusar a query que o passar. Para o perfil completo, um admin escolhe a profundidade standard ou deep do dataset em Datasets → Operações em massa; basic mantém o perfil limitado.
Materialized views. Perfilam como tabelas. Nos monitores automáticos, a frescura (last_modified) é o último refresh: MATERIALIZED_VIEWS.last_refresh_time no BigQuery e refreshed_on de SHOW MATERIALIZED VIEWS no Snowflake.
Corpus adversarial de profiling
Os casos abaixo não são exóticos, são o dia-a-dia de um ERP português ou espanhol. O corpus existe para podermos afirmar «confiem nos números» com prova. Vive em backend/tests/fixtures/adversarial/ e tem duas partes: um gerador (build_corpus.py, com os dados de cada caso e o tipo SQL declarado de cada coluna) e as expectativas, um YAML por caso em cases/. O formato está no README.md da pasta. O teste backend/tests/test_adversarial_corpus.py gera o corpus num diretório temporário, perfila cada caso pelo conector CSV (nas vias pandas e stdlib, que têm de concordar) e pelo SQLite, e compara com o YAML. As tolerâncias são explícitas: rel=1e-9 por omissão e abs=1e-6 no desvio-padrão epoch. Cada perfil do corpus tem também de gravar-se em JSON estrito.
| Caso | O que prova |
|---|---|
bom_utf8, bom_utf16 |
BOM do Excel (UTF-8 e UTF-16 LE): nada de no nome da primeira coluna |
ponto_e_virgula_cp1252, tab_latin1, latin1_bytes_c1 |
;, tab e | + cp1252/latin-1; o perfil diz delimiter/encoding |
aspas_e_quebras, uma_coluna |
aspas, delimitadores e quebras de linha dentro de campos; Sniffer sem veredicto |
zeros_a_esquerda |
08001, 000123, 0034… ficam texto; "0" é inteiro |
decimais_pt_e_datas_pt, sentinelas, vazio_vs_null |
1.234,56, 31/12/2024, N/A, -, 9999-12-31; vazio vs NULL |
nan_inf |
NaN/±Inf: nan_count, fora da média e das amostras, JSON sem NaN |
fusos_mistos, dst_lisboa, datas_futuras |
naive vs aware (o perfil CSV rebentava), 01:30 duplicado a 2026-10-25 em Lisboa, datas em 2099 |
decimal_38_10, bigint_extremos, epoch_millis |
precisão numérica; stddev bate com statistics.pstdev a 1e-6 |
nfc_nfd |
«José» composto e decomposto |
tabela_vazia, uma_linha, coluna_toda_null |
limites: 0 linhas, 1 linha (desvio 0), coluna só com NULL |
tabela_larga_300 |
300 colunas: catálogo com 300, 120 perfiladas pela prioridade |
identificadores_reservados, sem_pk, vista |
Order Details, colunas select/order/group, tabela sem PK, uma vista |
Limitações registadas (e não corrigidas). Cada YAML diz em known_limitations o que o comportamento atual ainda não resolve:
- decimais PT e datas
dd/mm/aaaaficam texto; 9999-12-31e datas futuras entram no máximo da coluna e nolatest_timestampque o conector devolve (a frescura já não os usa: desde o cartão #292 o domínio põe essas colunas fora da escolha da coluna de frescura e levanta o alertafuture_timestamps);- NFC e NFD contam como valores distintos;
- as duas 01:30 da mudança de hora são o mesmo valor sem fuso;
DECIMAL(38,10)perde precisão em float e no REAL do SQLite;- o SQLite guarda NaN como NULL, por isso o
nan_countsó é medido nos ficheiros.
Primeiro scan depois desta versão (transição). As colunas de ficheiros (CSV, GCS, S3) cujo tipo estava errado mudam uma vez: integer para string/text nos códigos com zeros à esquerda ou com sentinelas, e text para datetime nas datas ISO dos buckets. O SCHEMACHG regista-o como type_changed. Não é suprimido, porque o tipo anterior estava errado e o issue documenta a correção.
Como o #207 corre o mesmo corpus contra fontes reais. A matriz de validação real por conector (#207) reutiliza CASES e os YAML sem os copiar:
- Carga: para cada caso com o alvo SQL, cria a tabela com o
sql_typede cada coluna traduzido para o dialeto. Por exemplo,INTEGER→bigint/BIGINT/INT64;REAL→double precision/DOUBLE/FLOAT64;DECIMAL(38,10)→numeric(38,10)/DECIMAL(38,10)/BIGNUMERIC;TIMESTAMP→timestamp/DATETIME/DATETIME. Depois insere as mesmas linhas com parâmetros do driver, nunca por interpolação. Postgres 16 e MySQL 8 correm nodocker-compose.connectors.ymldo jobdockerda matriz; o BigQuery corre no tiersandbox, com os secretsDATAVAULT_MATRIX_BIGQUERY_*. - Perfil: corre com o conector real (
postgres,mysql,bigquery), viaprofile_table, como num scan. - Comparação: o runner de
test_adversarial_corpus.pyjá fundeallcom a secção do alvo. O #207 acrescenta secçõespostgres:,mysql:ebigquery:aos YAML, só com o que muda por dialeto: nomes de tipo, a precisão exata donumeric(38,10), o NaN que o Postgres guarda emfloat8, e onan_countpor SQL nativo, que é âmbito do #207. O resto (all) tem de passar tal como está. - O que só uma fonte real prova: o
STDDEV_POPnativo no casoepoch_millis(Postgres, MySQL, BigQuery) e o fallback doinformation_schemacomCAST(:s AS CHAR)num MySQL real. Até esta versão oCAST(:s AS TEXT)falhava no MySQL e a reflexão caía em silêncio para[]. Hoje só o texto da query está coberto por teste. Falta também a leitura deDECIMAL(38,10)sem perda.
SAP
O conector sap (cartão #212) lê o SAP S/4HANA e o SAP Gateway pelos serviços OData, v2 e v4, que o próprio SAP publica. Cada entity set do serviço é uma tabela. Assenta na base OData comum (app/infra/connectors/odata.py), a mesma do OData genérico e do Dynamics 365. A parte própria do SAP está em app/infra/connectors/sap.py.
URL do serviço (base_url): a raiz de um serviço OData, sempre em https. Cada conexão lê um serviço; para ler outro, cria outra conexão.
- On-premise (SAP Gateway):
https://<host>/sap/opu/odata/sap/API_BUSINESS_PARTNER. O último segmento é o nome técnico do serviço. - S/4HANA Cloud:
https://<tenant>-api.s4hanacloud.ondemand.com/sap/opu/odata/sap/API_BUSINESS_PARTNER.
Autenticação (auth):
basic(por omissão):usernameepasswordde um utilizador de comunicação dedicado ao DataVault.oauth2_client_credentials:token_url,client_id,client_secretescope(opcional). O pedido do token vai só aotoken_url, que tem de ser https e passa pela guarda anti-SSRF.api_key:api_keyeapi_key_header(opcional; por omissãoAPIKey, o cabeçalho do sandbox do SAP Business Accelerator Hub). É o modo para experimentar contra o sandbox.
O modo none (sem credenciais) é recusado no sap. A password, o client_secret e a api_key nunca voltam a ser mostrados nem aparecem em logs.
Outros campos:
sap_client(opcional): o mandante, com três dígitos (ex.:100). Vai comosap-client=100em cada pedido.entity_sets(opcional): lista, ou texto separado por vírgulas, dos entity sets a ler (ex.:A_BusinessPartner,A_BusinessPartnerAddress). Por omissão, todos os do$metadata, até 200.sample_size(opcional): linhas da amostra do perfil, de 1 a 5000 (1000 por omissão).
Autorizações — do lado do cliente:
- On-premise: ativa o serviço no Gateway (transação
/IWFND/MAINT_SERVICE) e dá ao utilizador de comunicação um papel com a autorizaçãoS_SERVICEdesse serviço. No back-end, o papel só precisa de autorizações de visualização sobre os dados que o serviço expõe. - S/4HANA Cloud: cria um communication arrangement para o cenário de comunicação que expõe o serviço, com um communication user, e usa as credenciais desse utilizador no modo
basic.
Teste de ligação: o test_connection lê o $metadata e pede uma linha ($top=1) da primeira entidade. Quando falha, diz o que corrigir:
| Resposta do SAP | Diagnóstico |
|---|---|
| 401 | Utilizador e password, ou as credenciais do modo, recusados |
| 403 | Falta ao utilizador a autorização S_SERVICE do serviço |
| 404 | Serviço não ativado no Gateway (/IWFND/MAINT_SERVICE) ou URL errado |
400, com sap_client |
Mandante (sap-client) errado |
Só leitura: os pedidos ao serviço são só GET, pelo ReadOnlyHttpClient (guarda anti-SSRF e pinning de DNS). O conector nunca pede o token CSRF (X-CSRF-Token: Fetch), que o SAP só exige às escritas. Com oauth2_client_credentials, o único POST é o pedido do token, ao token_url.
Colunas, contagem e amostra:
- As colunas e os tipos vêm do
$metadata(EDMX), com os tipos Edm mapeados para os do DataVault. - O número de linhas vem do serviço:
$inlinecount=allpagesno OData v2,$countno v4 e, se a resposta não trouxer a contagem,GET <EntitySet>/$count. - As métricas das colunas são calculadas sobre uma amostra de
sample_sizelinhas ($top, com$format=json), como no Dynamics: as percentagens de nulos e de distintos referem-se à amostra. - As datas
/Date(…)/do v2 e osEdm.DateTimeOffsetpassam a UTC. OsEdm.Int64eEdm.Decimal, que o v2 manda como texto, passam a número. Vale para o perfil e para as linhas que os scans leem (odata.py:convert_value,row_value). Nas linhas, oEdm.DecimalficaDecimalexato, como nos conectores SQL. - A paginação segue o
__next(v2) e o@odata.nextLink(v4) só quando o link tem a mesma origem (esquema, host e porta) dobase_url. Um link para outra origem é recusado. - O conector pede as linhas em blocos de 1000 (
$top). Se o serviço servir um bloco em páginas mais pequenas (o Northwind usa páginas de 20 e de 500), o conector segue os links. Quando o bloco chega completo, pede o seguinte com$skip, até aosample_size(no perfil) ou ao limite de linhas do scan (odata.py:_collect).
Validação ao gravar (422): base_url em falta ou sem https, auth desconhecido ou none, campos da autenticação em falta, sap_client sem três dígitos, token_url sem https, api_key_header inválido, nomes inválidos em entity_sets e sample_size fora de 1 a 5000.
Instalação: nenhum extra. O conector usa só as dependências base (httpx).
Rede: em produção, a guarda anti-SSRF recusa destinos em redes privadas. Um Gateway on-premise tem de estar acessível em https a partir do IP de saída do DataVault (ver o topo deste guia).
Fora do âmbito: a ligação direta à base de dados HANA (hdbcli). O DataVault lê o SAP pelos serviços OData.
Integração com processos SAP e Salesforce
«Integra com o nosso SAP?» — a resposta curta:
- Lemos o SAP S/4HANA e o SAP Gateway por OData, o Salesforce, o Dynamics 365 e as bases de dados dos ERP que correm em SQL Server — Primavera, PHC e Sage. Outros serviços OData v2 e v4 leem-se pelo OData genérico.
- Nunca escrevemos no ERP nem no CRM, nem instalamos plugins dentro do SAP ou do Salesforce: seria abrir mão do acesso read-only. A correção sai em SQL/PySpark ou como Pull Request, para o teu processo aprovar e aplicar.
- Não validamos o registo no momento em que é gravado (dentro da transação SAP ou Salesforce): medimos o que ficou gravado, no scan seguinte. Validar no ponto de entrada, com escrita, é o território das suites de MDM e de qualidade (SAP MDG, Informatica).
OData (genérico)
O conector odata (cartão #212) lê serviços OData v2 e v4 que não têm conector próprio no DataVault: serviços OData internos da empresa ou serviços públicos. Cada entity set é uma tabela. É a base do SAP (app/infra/connectors/odata.py) e comporta-se da mesma forma: só GET, colunas pelo $metadata, contagem pelo serviço, perfil sobre uma amostra e paginação só na mesma origem do base_url. O teste de ligação lê o $metadata e pede uma linha da primeira entidade.
Campos:
base_url(https): a raiz do serviço, onde o<base_url>/$metadataresponde (ex.:https://services.odata.org/V2/Northwind/Northwind.svc).auth:nonepor omissão, para serviços públicos sem credenciais. Os modosbasic,oauth2_client_credentialseapi_keylevam os mesmos campos do SAP.sap_client,entity_setsesample_size: como no SAP.
Limites: só os entity sets declarados no $metadata da raiz (odata.py: parse_metadata, list_tables). Um caminho por dentro de outra entidade, como Company('…')/Customers, fica fora. O Business Central não foi validado.
Serviços públicos: com auth none, como os serviços de referência do OData.org, https://services.odata.org/V2/Northwind/Northwind.svc/ (v2) e https://services.odata.org/TripPinRESTierService/ (v4). São as fontes do odata na matriz de conectores (tier public).
Validação ao gravar (422): a mesma do SAP, exceto que o none é aceite.
Instalação: nenhum extra (httpx).
Conectores próprios: para o Dataverse do Dynamics 365 usa o dynamics, e para o SAP o sap.
API REST/JSON (genérica)
O conector rest_api (cartão #269) lê APIs e microsserviços REST que devolvem JSON e não têm conector próprio. Cada endpoint GET configurado é um dataset, perfilado sobre uma amostra (app/infra/connectors/rest_api.py). O teste de ligação pede a primeira página do primeiro endpoint.
Campos:
base_url(https): a raiz da API, sem query (ex.:https://api.example.com/v1).auth:nonepor omissão;bearercomtoken;basiccomusernameepassword;api_keycomapi_keynum header próprio (api_key_header, por omissãoX-API-Key). A chave nunca vai na query: um segredo no URL fica nos logs dos proxies.endpoints: a lista de endpoints, cada um um caminho (/users) ou um objeto compath,name?,records_path?,params?,pagination?,total_path?econtract_path?. Onameé o nome do dataset; sem ele, vem do caminho.records_path: onde está a lista no payload, em JSONPath simples ($.data,$.data.items[*]). Sem ele, a lista é o próprio payload ou a primeira chave habitual com objetos (data,items,results,records,value,entries).pagination:type=none,offset(offset_paramelimit_param),page(page_param,page_start0 ou 1),cursor(cursor_pathno corpo ecursor_paramno pedido) oulink(headerLinkcomrel="next", ounext_pathno corpo);page_sizeaté 1000. Vale para todos os endpoints e cada endpoint pode ter a sua.total_path: onde a API diz o total de registos (ex.:$.meta.total); sem ele, o número de linhas é o que a amostra leu.allowed_hosts: hosts extra para onde os links de paginação podem apontar (ex.: um CDN). Recebem os pedidos sem a credencial.sample_size(por omissão 1000, máximo 5000) emax_pages(por omissão 50, máximo 500): tetos da leitura de cada endpoint.
Como lê: só GET pelo ReadOnlyHttpClient (anti-SSRF com pinning de DNS). A leitura para quando uma página vem vazia ou incompleta, quando um cursor se repete, ao fim de max_pages páginas ou quando a amostra está cheia (rest_api.py: _collect). Os campos de topo de cada registo são as colunas; objetos e listas aninhados ficam como texto JSON nas linhas e com o tipo object ou array no catálogo.
Allowlist de hosts: cada pedido, incluindo os links de paginação e cada salto de um redirect, só sai para o host da base_url (na mesma origem) ou para um host de allowed_hosts; outro destino é recusado antes do pedido (rest_api.py: _check_host). A credencial só segue para a origem da base_url. Um host da allowlist que resolva para um IP interno continua bloqueado pelo guard anti-SSRF.
Contrato da API: com contract (um documento OpenAPI 3.x ou Swagger 2.0, em JSON ou YAML), cada perfil compara o schema da resposta 200 do GET do endpoint (o contract_path, ou o próprio path) com os registos da amostra, campo a campo, incluindo os aninhados (morada.cidade). O scan abre um issue schema_change com fonte contract e o impacto a jusante quando há drift: um campo required que não veio em nenhum registo («removida»), um campo que o contrato não declara («nova») ou um tipo fora do declarado (app/infra/connectors/api_contract.py; app/domain/scans/schema_changes.py: record_contract_drift). Um campo opcional ausente da amostra não conta, e uma amostra sem registos também não. O schema de um registo desce pelo caminho de onde a lista veio: o records_path, ou o que a deteção escolheu ($.data num envelope {"data": […]}) (rest_api.py: locate_records). Só se seguem os $ref do próprio documento: o contrato nunca manda o conector buscar nada. Um $ref em ciclo, como numa árvore, corta-se onde volta atrás, e cada schema lê-se uma só vez. A leitura tem tetos: 64 níveis de allOf/anyOf/oneOf encaixados e 1 000 000 passos, em que conta cada elemento percorrido (uma propriedade, um nome do required ou uma entrada de type copiados, um salto de $ref, uma chave de paths, content ou messages), mesmo quando vários schemas o partilham por $ref ou âncora YAML (api_contract.py: _SchemaReader). As chaves de fusão YAML (<<) copiam no máximo 1 000 000 pares (api_contract.py: _ContractLoader). Nos tópicos Kafka, o mesmo contract aceita um documento AsyncAPI 2.x ou 3.x e compara o payload da mensagem do canal com o nome do tópico (kafka.py: _contract_check).
Validação ao gravar (422): base_url em https e sem credenciais no URL, modo de autenticação e campos do modo, caminhos que começam por / sem .. nem query, JSONPath simples, tipo de paginação conhecido (o cursor exige cursor_path), api_key_header que não seja um header de protocolo, allowed_hosts só com nomes de host, tetos inteiros e um contract legível dentro dos tetos da leitura, com todos os schemas lidos já ao gravar (api_contract.py: validate_contract). Num tópico Kafka, um contract que não seja AsyncAPI, ou que passe esses tetos, também dá 422.
Instalação: nenhum extra (httpx).
Limites: sem OAuth2 nem cookies de sessão, e sem POST de pesquisa (uma API que só lista por POST fica fora). O header Link lido é o da RFC 8288.
Tickets (Jira, Linear, ServiceNow, Azure DevOps, GitHub, GitLab)
Não é uma fonte de dados: é a saída para a ferramenta de tickets da equipa. O DataVault cria o ticket a partir de um issue, incidente ou sugestão (botão «Criar ticket», tickets em lote e cartão do assistente) e guarda o link na entidade. A integração configura-se em Notificações → Tickets (admin). O «Testar ligação» valida o config como ao gravar e lê os destinos do fornecedor sem criar nada (POST /notification-channels/tickets/connection-test). Os seis fornecedores cumprem o mesmo contrato TicketProvider (app/infra/tickets/base.py: validate_config, create, get_status, list_targets) e estão no registo app/infra/tickets/registry.py.
| Fornecedor | kind |
API e corpo | Autenticação | Destinos (list_targets) |
Prioridade sugerida | Estado (get_status) |
|---|---|---|---|---|---|---|
| Jira Cloud | jira |
REST v3 (/rest/api/3/issue), corpo em ADF |
Basic (email + API token) | projetos e tipos de issue (project/search?expand=issueTypes) |
no corpo do ticket | statusCategory do issue |
| Linear | linear |
GraphQL (issueCreate), corpo em markdown |
API key pessoal | equipas (teams) |
campo priority (1 urgente … 4 baixa) |
state.type do issue |
| ServiceNow | servicenow |
Table API (/api/now/table/{tabela}): incident, sn_customerservice_task ou outra |
Basic (conta de integração) ou OAuth client credentials | grupos de atribuição ativos (sys_user_group) |
urgency e impact (o ServiceNow calcula a prioridade) |
state e active do registo |
| Azure DevOps Boards | azure_devops |
REST wit/workitems/${tipo} com JSON Patch, descrição em HTML |
PAT (Work Items: Read & write) | o projeto e os tipos de work item (wit/workitemtypes) |
no corpo do work item | System.State do work item |
| GitHub Issues | github |
REST POST /repos/{owner}/{repo}/issues, corpo em markdown |
token (PAT fine-grained com Issues: write) ou a GitHub App da integração git | o repositório (GET /repos/{owner}/{repo}, confirma que tem issues) |
no corpo; labels datavault e data-quality |
state da issue (open/closed) |
| GitLab Issues | gitlab |
REST v4 POST /projects/{id}/issues, descrição em markdown |
token (PRIVATE-TOKEN, scope api) ou o da integração git |
o projeto (GET /projects/{id}, confirma que tem issues) |
no corpo; labels datavault e data-quality |
state da issue (opened/closed) |
Escrita: cada fornecedor escreve para criar o ticket. Na sincronização (cartão #381), o Jira e o Linear também comentam num ticket já ligado (comment, ligado por omissão) e, com transition na política da integração, fecham-no ou reabrem-no, só nos tickets do projeto (Jira) ou da equipa (Linear) da integração; nenhum edita outros campos. Há dois POST que não escrevem: o Linear lê por queries GraphQL e o ServiceNow em OAuth troca as credenciais por um token em POST /oauth_token.do. A sincronização de incidentes com o ServiceNow nos dois sentidos fica para o cartão #255.
Segurança: o config é cifrado com Fernet (TicketIntegration.config_encrypted) e a API nunca o devolve. O host que o cliente configura (site do Jira, instância do ServiceNow, base_url do Azure DevOps Server, api_url do GitHub Enterprise ou do GitLab self-managed) passa pela guarda anti-SSRF ao gravar e a cada pedido, com pinning do IP e sem redirects (app/infra/network/outbound.py). Os erros levam um excerto da resposta do fornecedor sem as credenciais, e os fornecedores não escrevem logs (app/infra/tickets/http.py).
Timeouts e repetições: cada pedido tem 20 s. As leituras (destinos, estado e o token OAuth) tentam até 3 vezes em 429 e 5xx, com esperas de 0,5 s e 1 s, ou o Retry-After até 5 s. A criação não se repete, porque depois de um 5xx o ticket pode já existir. Um 429 na criação sobe com o Retry-After e os tickets em lote voltam a tentar com o seu backoff (app/domain/integrations/ticket_bulk.py).
GitHub e GitLab com a integração git: em vez de um token próprio, o config pode levar git_integration_id. O token (ou a GitHub App) e o api_url vêm então da integração git do mesmo workspace e do mesmo fornecedor, e o repositório por omissão é o dela.
Interop
Contratos ODCS ↔ datacontract-cli (DCSPEC)
O contrato ODCS v3.1 que o DataVault exporta (GET /datasets/{id}/contract.yaml, domain/contracts/service.py) é verificado contra o datacontract-cli, a ferramenta de referência do ecossistema Bitol. A verificação é opcional e não corre no CI normal:
Suite local
backend/tests/test_interop_datacontract.py(marcadaslow): exporta o contrato de um dataset fixture com todos os tipos de regra, corre o CLI e reimporta o resultado com os nossos importadores (domain/rules/importers.py). Sem o bináriodatacontractos testes saltam.python -m venv /tmp/dc && /tmp/dc/bin/pip install -r backend/requirements-interop.txt cd backend && DATACONTRACT_BIN=/tmp/dc/bin/datacontract python -m pytest -m slow tests/test_interop_datacontract.pyJob de CI
.github/workflows/interop.yml(sóworkflow_dispatch, com a versão do CLI como input): instala o CLI num venv isolado e corre a suite comDATACONTRACT_REQUIRED=1(a ausência do binário falha em vez de saltar). Correr antes de subirODCS_API_VERSIONou de mexer no export/import ODCS.
O datacontract-cli é dependência de DEV apenas (requirements-interop.txt, fixado): traz as suas próprias versões de pydantic/sqlglot, por isso fica num venv à parte e nunca entra na imagem de produção.
Resultado (datacontract-cli 1.2.1, ODCS v3.1.0) — critério: round-trip sem perda nos campos que suportamos.
| Passo | Resultado |
|---|---|
datacontract lint |
Válido contra o JSON Schema ODCS v3.1.0. |
import odcs → export odcs |
Sem perda: o documento reimportado é igual ao original (o CLI só reordena chaves e quebra linhas longas). Regras extraídas, skipped e compare_contract_to_schema idênticos. |
| Export → reimport no DataVault | Recupera not_null, unique, pattern, row_count_min e freshness. range viaja como check type: sql e é declarado em skipped (limitação do standard: não há metric de intervalo). |
tenant, domain, dataProduct, team (cartão #148) |
Válidos e sem perda no round-trip. O team usa o objeto da v3.1.0 ({members: [...]}); o array solto da v3.0 também passa no lint, mas está depreciado. |
Bundle do data product (GET /domains/{id}/contract) |
O CLI lê um contrato por ficheiro (um YAML com vários documentos dá «found another document»). A suite parte o bundle e cada contrato passa no lint e no round-trip import/export odcs sem perda. |
Incompatibilidades encontradas
- Bug nosso, corrigido: a SLA
latencyexportaelement: <schema>.<tabela>(physicalName) e o importador ODCS lia o ponto comotabela.coluna— a regra de freshness voltava presa a uma coluna inexistente (encomendas). O importador passou a reconhecername/physicalNameantes de extrair a coluna (teste de regressão emtest_rule_interop.py). export sodacl(CLI):invalidValuescomarguments.patternsai comoinvalid_count(col) = 0sem o regex — o padrão perde-se. O checksql(range) sai como métrica SodaCL user-defined (o nosso importador SodaCL declara-o emskipped) e a SLAlatencynão é emitida.nullValues,duplicateValues,requirederowCountsobrevivem.export dbt-models(CLI): só emitenot_null(derequired/nullValues);duplicateValuesnão viraunique. Semserversno contrato, osdata_typesaem no dialeto por omissão do CLI (NUMBER,TIMESTAMP_TZ) — para dbt, preferir o nossocontract.dbt.yml, que mapeia a partir delogicalTypee emiteunique.- Tabela na conversão: o SodaCL do CLI usa o
physicalName(vendas.encomendas) e o dbt/SQL usa oname(encomendas). O match de dataset no import aceita ambos, mas convém saber qual aparece em cada artefacto. - Tipos lógicos: exportamos
TIMESTAMPcomologicalType: date(ophysicalTypepreserva o tipo real). O ODCS v3.1 temtimestamp; não foi mudado aqui porquecompare_contract_to_schemae o mapeamento dbt dependem do valor atual — candidato a cartão próprio.