Skip to content

feat(credentials): registro de IA com scope/hint + cofre de integrações (EVO-2250) - #23

Merged
gomessguii merged 21 commits into
developfrom
feat/evo-2250-ai-credentials-key-hint
Jul 30, 2026
Merged

feat(credentials): registro de IA com scope/hint + cofre de integrações (EVO-2250)#23
gomessguii merged 21 commits into
developfrom
feat/evo-2250-ai-credentials-key-hint

Conversation

@DavidsonGomes

@DavidsonGomes DavidsonGomes commented Jul 29, 2026

Copy link
Copy Markdown
Member

Épicos 1 e 2 no core: key_hint (a chave nunca mais volta ao browser), scope na cadeia instalação→conta, imported_from, e o cofre evo_core_integration_credentials (kind static/oauth, cifra Fernet, sync OAuth por referência que nunca copia token, referenced_by agregado, migration-state fail-closed). 16 commits, suíte inteira verde, revisão adversarial cruzada. Ordem de merge da feature: auth → core → CRM + processor → vendor/crm (front) → frontend (bump) → superprojeto.

Guia de validação tela a tela: _evo-output/planning-artifacts/config-ia-unificada/guia-teste-dev-evo-2250.md (no superprojeto). Card: EVO-2250.

Summary by Sourcery

Introduce an integration credential vault and wire agent, tool, MCP, and Knowledge Nexus flows to use encrypted, scoped secrets by reference while ensuring keys never leave the browser again.

New Features:

  • Add evo_core_integration_credentials vault with static credentials, Fernet encryption, kind/scope semantics, and per-consumer migration state API.
  • Expose a new integration credential HTTP API with listing, creation, update, deletion, OAuth connection reconciliation, and usage aggregation.
  • Support credential scopes (installation vs account) on API keys and integration credentials, and expose OpenAI-compatibility metadata in API key responses.
  • Enable Knowledge Nexus space discovery to resolve saved credentials and URLs server-side using vault references instead of inline keys.

Enhancements:

  • Stop returning secret values in agent integration configs, custom tools, custom MCP servers, and API key responses, replacing them with masked hints or redacted maps.
  • Preserve stored secrets across updates for integrations, tools, and MCP servers so sanitized round-trips do not erase credentials.
  • Add credential reference wiring from custom tools, custom MCP servers, agents, and channel bots to the vault, plus reporting of who uses each credential.
  • Extend tenant stamping to the integration credential vault and add supporting migrations and indexes for uniqueness, scope, and owner coherence.

Build:

  • Add new database migrations for key hints, credential scopes, imported-from markers, credential_refs columns on tools/MCPs, and the integration credentials vault with kind/content and uniqueness constraints.

Tests:

  • Add extensive unit tests for API key and integration credential handlers, model helpers, OAuth sync, migration state, reference aggregation, secret redaction/merging, and Nexus space resolution security invariants.

…o provedor

A API devolvia a chave cifrada de toda credencial ao browser: ApiKeyResponse
carregava Key e o List entregava tudo. Não era plaintext (Fernet), mas era
exposição sem motivo e impedia a UI de renderizar um mask útil.

Agora o create/update deriva key_hint dos últimos 4 caracteres do valor em
claro antes de cifrar, e a resposta troca Key por KeyHint. Update com chave
vazia passa a manter a credencial armazenada (Updates do GORM pula campos
zerados), em vez de recusar a requisição.

Acrescenta openai_compatible derivado do provider para a UI mostrar quais
funções a credencial atende.

Refs EVO-2250 (story 1.1)
…I-compatível

O front persiste 'custom_openai_compatible' para 'Custom (OpenAI-compatible)',
não 'custom'. Sem isso a coluna ATENDE marcaria a credencial como restrita a
Agentes de IA.

Refs EVO-2250 (story 1.1)
A credencial passa a declarar a que elo da cadeia pertence (installation ou
account). O core só armazena e filtra por escopo: a precedência é resolvida
pelo CRM, dono único da regra, para os dois lados não divergirem no primeiro
bugfix.

Scope ausente ou desconhecido cai em 'account', o elo mais restrito, para que
requisição malformada nunca promova credencial à instalação inteira. No update,
scope omitido preserva o armazenado.

O list aceita ?scope= e, sem filtro, devolve todos os escopos numa chamada só,
que é como a tela monta as duas seções.

Refs EVO-2250 (story 1.2)
Normalizar na serialização mascararia dado inválido como 'account' em vez de
deixá-lo aparecer. O CHECK da coluna já barra valor inválido na entrada, e a
normalização continua no create/update, onde o dado chega.

Refs EVO-2250 (story 1.2)
Acrescenta imported_from, que a migração da story 1.5 usa como chave de
idempotência. Não dá para usar o nome: o admin pode renomear, desativar ou
trocar a chave de uma credencial importada, e a reexecução tem que respeitar
isso em vez de recriar.

Índice UNIQUE parcial (apenas onde imported_from não é nulo) garante no banco
que a mesma origem não seja importada duas vezes.

Refs EVO-2250 (story 1.5)
…s de integração

Hoje todo segredo de ferramenta que o cliente cadastra fica em texto claro:
agent_bots.api_key, evo_core_agent_integrations.config, os headers de
custom_tools e custom_mcp_servers, e as env vars de MCP. O Fernet já existe
nos dois runtimes, mas só protege evo_core_api_keys. Este commit cria o
registro onde esses segredos passam a viver cifrados.

É tabela própria, não evo_core_api_keys: credencial de IA é o par
(provedor, chave) com 15 provedores conhecidos, enquanto credencial de
integração é forma livre e tem tipos com ciclos de vida diferentes. O que se
reusa é o princípio (cadastra uma vez, referencia por id) e a cadeia de
escopos, não a tabela.

Decisões que o schema trava, em vez de deixar por convenção:

- `kind` (static/oauth) nasce agora, mesmo com oauth recusado pela API. É o
  discriminador que impede o cofre de virar dono de refresh: um token cujo
  refresh rotaciona no store original ficaria stale na primeira renovação, e
  a integração morreria em silêncio. O CHECK de coerência garante no banco
  que uma linha oauth não carrega valor, e uma static não carrega dono.
- UNIQUE (scope, name), nunca só em name. As três tabelas irmãs do core usam
  unique global em name, o que colide entre tenants no enterprise; aqui o
  índice já nasce escopado e a gem só precisa acrescentar tenant_id.
- `value_format` (scalar/composite) porque nem todo segredo é um valor único:
  o basic auth do n8n é um par indivisível, serializado como envelope JSON
  antes de cifrar. O hint vem do componente sensível, nunca do envelope
  inteiro (mostraria sintaxe) nem do usuário (mascararia a metade errada).
- `imported_from` entra já na migration de criação. A story 1.5 precisou de
  uma migration inteira (000018) só para adicionar a coluna equivalente em
  evo_core_api_keys; a 2.6 não vai repetir isso.

O valor é cifrado no handler com a mesma chave Fernet do api_key
(ENCRYPTION_KEY, compartilhada com o processor), e o hint é derivado do texto
em claro antes de cifrar. A resposta carrega só o hint: nem o valor claro nem
o ciphertext saem da API, porque o ciphertext é decifrável por quem tem a
chave compartilhada.

As rotas ficam num grupo /integration-credentials de topo, e não sob /agents,
onde as rotas de api key precisam se registrar antes de /agents/:id para não
serem capturadas por ele.

A entrada na allowlist do tenantstamp só tem efeito sob -tags=enterprise,
onde o plugin é registrado, e anda em par com a migration da gem que cria
tenant_id + policy RLS para a tabela.

Testes incluem provas negativas verificadas removendo o comportamento:
reintroduzir value na resposta quebra 3 testes, remover o guard de oauth
quebra 1, e trocar o UNIQUE por name sozinho quebra o teste de schema.

Nota para quem for testar em runtime: a permissão ai_integration_credentials
está na branch feat/evo-2250-integration-credentials-permission do
evo-auth-service-community (860079d). Enquanto ela não for mergeada, as rotas
compilam mas respondem 403.

Refs EVO-2250 (story 2.1)
…o segredo

Agente externo (Dify, Flowise, n8n, OpenAI) passa a poder apontar para uma
credencial do cofre por `credential_id`, em vez de guardar a chave em claro no
config. Sem `credential_id` nada muda: o valor inline continua sendo o caminho,
que é o que faz esta story não bloquear nenhuma instalação antes da 2.6.

O `credential_id` entra na linha de `evo_core_agent_integrations`, que é o
único caminho que autentica na plataforma externa (o runtime carrega por
get_agent_integration_by_provider e dá raise sem ela). O `api_key` de
`evo_core_agents.config` NÃO recebe referência: ele é gerado pelo core
(generateAPIKey), é a chave inbound do próprio agente, e não é credencial
cadastrada pelo cliente.

A referência é validada na escrita, não só em runtime: um `credential_id` que
não é uuid, que não existe, que está inativo ou que aponta para uma credencial
`oauth` é recusado com mensagem explícita. Recusar oauth aqui não é
preciosismo: essa linha tem `value` nulo por CHECK de banco, porque o cofre
aponta para o store que renova o token em vez de copiá-lo, então um agente
apontado para ela autenticaria com nada.

Duas mudanças que precisam andar JUNTAS, e é o ponto delicado do commit:

1. `sanitizeConfig` passou a remover `apiKey`, `api_key`, `basicAuthUser`,
   `basicAuthPass` e `nexus_api_key`. Antes, quem tinha ai_agents:read lia o
   segredo de todo agente externo de volta em claro: a lista só conhecia nomes
   de campo de OAuth.
2. O upsert passou a preservar segredo omitido. As telas fazem round-trip do
   objeto que receberam (o ElevenLabsConfigDialog inicializa com
   `initialConfig?.apiKey || ''`), e o upsert sobrescreve o config inteiro
   (ON CONFLICT DO UPDATE SET config). Sanitizar a resposta SEM isso faria o
   próximo save gravar um config sem a chave e apagar o segredo em silêncio.
   Chave ausente = mantém a guardada; chave presente vence, mesmo vazia, porque
   mandar string vazia é como a tela diz "limpa isso".

O lookup de credencial consulta a tabela do cofre pelo mesmo handle de db que o
módulo já tinha, sem importar o pacote de credenciais: os dois stores seguem
independentes e só o id viaja entre eles.

Prova negativa verificada: removendo o merge, o teste do apagamento silencioso
(TestUpsertKeepsStoredSecretWhenSaveOmitsIt) falha.

Refs EVO-2250 (story 2.3)
…em tocar em token

O cofre passa a mostrar as conexões OAuth que os agentes já têm, ao lado das
credenciais estáticas. A regra que esta story existe para proteger: o cofre
guarda o VALOR de credencial sem ciclo de vida, e credencial COM ciclo de vida
entra por REFERÊNCIA, com o dono do refresh inalterado.

Copiar o token seria o erro natural: refresh não é leitura, é escrita com
rotação. Quem renova grava token novo no store dono, e um cofre que guardou
cópia serve o token velho a partir da primeira rotação, matando a integração em
silêncio. Por isso a linha oauth não tem valor por CHECK de banco, e o estado
exibido é lido do dono NO MOMENTO da listagem, nunca persistido.

Quem cria as linhas é um sync idempotente disparado na listagem, não o callback
OAuth: o callback vive no processor e continua intocado, então uma conexão feita
por qualquer caminho aparece na listagem seguinte, sem acoplamento. Chave
natural (owner_store, owner_ref), com índice único parcial na migration 000020.
Rodar duas vezes sem mudança não escreve nada, o que importa porque isso roda a
cada page load. Conexão que sumiu do dono é DESATIVADA, não deletada: a linha é
reversível se a conexão voltar, e nunca teve segredo para perder.

Os provedores entram por allowlist, não por "tudo que tem access_token": o
campo provider é VARCHAR livre e a mesma tabela guarda linhas satélite
(<provider>_credentials) que carregam o token de uma conexão já listada, o que
duplicaria a mesma conta Google. Credencial de canal (WhatsApp, Instagram,
Gmail, Outlook) é do domínio do EvoHub e não aparece nem como referência.

Contrato do front honrado em connection_status, connection_expires_at, agent_id
e agent_name; agent_id é obrigatório em toda linha porque é dele que o botão de
desconectar depende. O quarto estado (refresh_failed) foi REMOVIDO do front no
commit 88b60d0: não é derivável, o store dono não registra falha de renovação.

Prova negativa verificada: fazer o sync gravar um valor na linha oauth quebra
TestOAuthRowFromConnectionNeverCarriesASecret e TestSyncNeverPersistsAValue.

Refs EVO-2250 (story 2.5)
…e header

Custom tools e MCPs remotos passam a poder apontar seus headers de autenticação
para o cofre por `credential_refs`, e param de devolver o valor dos headers na
resposta. Os headers inline continuam intactos como fallback até a 2.7.

A referência é um MAPA (nome do header → id da credencial), não uma coluna
escalar. A regra de cardinalidade do épico é "uma credencial equivale a um
segredo", então uma tool com dois headers de auth referencia DUAS credenciais;
uma coluna escalar não diria qual header ela substitui e travaria esse caso.

Duas mudanças que andam JUNTAS, e é o ponto delicado (mesmo defeito que a 2.3
corrigiu em agent integrations, agora num segundo lugar):

1. `ToResponse` passou a redigir o VALOR dos headers, mantendo os NOMES. Antes,
   quem tinha `:read` recebia toda credencial guardada de volta, e read é uma
   barra mais baixa que o create/update que a gravou. A redação é por
   allowlist, não denylist: o mapa é livre, então `X-Tenant-Auth` e afins
   carregam segredo, e uma lista de nomes proibidos deixaria passar.
2. O update passou a preservar header omitido. As telas fazem round-trip do
   objeto que receberam e o update sobrescreve `headers` por inteiro: sanitizar
   a resposta SEM isso faria o próximo save apagar o segredo em silêncio.
   Chave ausente mantém a guardada; chave presente vence, mesmo vazia.

O `secretmerge` nasceu como pacote compartilhado porque esta é a segunda
ocorrência do mesmo par (redigir + preservar); uma terceira cópia seria a
divergência garantida.

O `discoverTools` passou a encaminhar `credential_refs` ao processor em vez de
depender só dos headers em claro: a credencial resolvida deixa de cruzar a
fronteira de serviço num corpo JSON.

Inclui também o `oauth_connection_repository` da 2.5 corrigido contra Postgres
real: `Omit("Value")` (GORM mandava value='' e violava o CHECK de coerência) e
`TargetWhere` repetindo o predicado do índice único PARCIAL da 000020 (sem ele
o Postgres recusa o ON CONFLICT e o sync falharia em toda listagem).

Prova negativa verificada: desligar a redação quebra
TestToResponseRedactsHeaderValues nos dois models e TestRedactValuesKeepsOnlySafeHeaders.

Pareia com o commit do processor (resolução no ponto de uso) e o do CRM (bot de
canal) da mesma story.

Refs EVO-2250 (story 2.4)
… de ativacao funcional

Tres defeitos achados na revisao adversarial, os dois primeiros provados
contra um Postgres real (nenhum teste exercitava o banco):

1. O upsert de linha oauth NUNCA funcionou: o struct tem Value string
   (nao ponteiro) e o GORM incluia value = '' no INSERT, violando o
   CHECK kind_content_check, que exige value IS NULL para oauth. O
   Create agora usa Omit("Value") e a coluna fica fora do INSERT.

2. Mesmo sem o item 1, o ON CONFLICT (owner_store, owner_ref) sem
   predicado nao casa com o indice UNIQUE PARCIAL da migration 000020
   (WHERE owner_store IS NOT NULL AND owner_ref IS NOT NULL): o
   Postgres rejeita a instrucao inteira com 'no unique or exclusion
   constraint matching'. TargetWhere repete o predicado do indice.
   Efeito combinado dos dois: o sync falhava em TODA listagem (so um
   log) e nenhuma conexao OAuth jamais apareceria no cofre.

3. O toggle ativar/desativar era no-op silencioso: o update request nao
   tinha is_active, e o Updates por struct do GORM descartaria false de
   qualquer jeito (zero value). is_active entra como *bool no request e
   desce por parametro explicito ate um Update de coluna dedicado.
   Junto, o List sem filtro active deixa de esconder as inativas: a
   tela mostra o estado e reativa; quem quer so ativas pede
   active=true, e o resolvedor do CRM ja filtra is_active na propria
   query. (O default anterior espelhava o api_key do Epico 1, que tem a
   mesma lacuna; corrigir la e trabalho do Epico 1, registrado.)

Regressao coberta: is_active=false atravessa o handler; is_active
omitido permanece nil (mantem o estado); forma SQL do upsert validada
manualmente contra Postgres 16 (insert + reexecucao idempotente).

Refs EVO-2250 (review adversarial das stories 2.1, 2.2 e 2.5)
GET /integration-credentials/migration-state devolve {retired: {consumidor:
bool}}, que é o guard da 2.7: enquanto um consumidor não estiver aposentado, a
tela mantém o campo de segredo inline editável e o runtime mantém o fallback.

A semântica espelha o Ai::MigrationState da 1.6: aposentado significa que a
migração rodou (existe credencial com imported_from) OU que não há nada inline
para migrar naquele store, que é o caso de instalação nova. A checagem é POR
consumidor: um store que ainda guarda segredo inline mantém seu fallback mesmo
quando outro já migrou.

Duas decisões de fail-closed, e são o ponto do endpoint:

1. Falha de leitura NUNCA lê como aposentado. A 2.7 remove o fallback atrás
   deste guard, então um "aposentado" falso numa instalação com banco quebrado
   desligaria integração em silêncio, exatamente o que o guard existe para
   evitar.
2. A falha responde 200 com nada aposentado, em vez de erro: as telas tratam
   resposta ausente como não-aposentado e mantêm o campo editável; falhar a
   request deixaria o formulário sem conseguir decidir.

`agent_bots` é reportado como pendente de propósito: aquela tabela vive no
schema do CRM, que este serviço não possui, e quem responde por ela é o
Ai::IntegrationMigrationState. Reportar zero aqui seria mentira.

As constantes de consumidor moram no pacote `model` porque o repositório (que
conta) e o service (que decide) precisam das mesmas chaves, e o repositório não
pode importar o service sem ciclo de import.

Contrato do front conferido: ele consome custom_tools, custom_mcp_servers e
knowledge_nexus, e o endpoint entrega esse conjunto mais agent_bots e
external_agents. Superset, então nada a corrigir no vendor/crm.

Provas negativas verificadas: uma leitura que falha não marca consumidor como
aposentado, e mover a rota literal para depois de /:id quebra o teste de ordem
(gin capturaria "migration-state" como id).

Refs EVO-2250 (story 2.7)
Achado em revisão adversarial do Épico 1 (nunca revisado por outro par de olhos).

A tela de credenciais de IA tem um botão Ativar/Desativar que manda
{name, provider, is_active} para PUT /agents/apikeys/:id
(AiCredentials.tsx:218-222). O `ApiKeyUpdateRequest` NÃO tinha o campo
`is_active`, então o valor era descartado no bind: a API respondia 200, a tela
mostrava "atualizado com sucesso" e a credencial continuava exatamente como
estava. Uma credencial que o admin achava que tinha desativado seguia elegível
para o resolvedor.

Duas causas, e as duas precisam ser corrigidas juntas:

1. O campo não existia no request. Agora existe como *bool, não bool: só um
   ponteiro distingue "ausente" de "false", e é isso que permite DESLIGAR.
2. Mesmo existindo, `Updates(struct)` do GORM pula zero-values, então `false`
   nunca chegaria à coluna. A desativação vai por update explícito de coluna.

Omitir o campo continua mantendo o valor guardado, para um rename não
reativar em silêncio uma credencial que o admin havia desligado.

Este é o mesmo defeito que o cofre da story 2.1 já tinha corrigido no seu
próprio repositório; o registro de IA do Épico 1 ficou para trás.

Prova negativa verificada: voltar o campo para bool simples (ou parar de
encaminhá-lo) quebra TestUpdateAppliesIsActive.

Refs EVO-2250 (revisão do Épico 1)
…o aposenta fail-open

Dois defeitos da segunda rodada da revisao adversarial:

1. KeepMissing tratava chave presente-vazia como 'limpar', mas a
   RedactValues devolve TODO header de segredo com nome e valor vazio
   em TODO GET, e as telas round-tripam o objeto recebido: qualquer
   save de uma tool ou MCP com header de auth apagava o segredo
   guardado (o caminho de perda de dado do modal da 1.6, de novo).
   Blank sob nome redigido agora significa 'manter o guardado'; blank
   sob nome da allowlist segura continua sendo intencao real de limpar
   (esses valores nunca sao redigidos). Teste da propriedade de
   composicao: salvar de volta exatamente o que o GET devolveu nao
   muda nada.

2. PendingInlineSecrets do consumidor agent_bots retornava 0 (= nada
   pendente = APOSENTADO) enquanto o proprio comentario dizia que devia
   reportar pendente: fail-open no guard da 2.7, que removeria o
   fallback inline do bot numa instalacao nunca migrada. Passa a
   reportar pendente constante ate existir sinal vindo do CRM, que e o
   dono da tabela.

Refs EVO-2250 (review adversarial das stories 2.4, 2.6 e 2.7)
Achado em revisão adversarial da story 2.4, no meu próprio código.

O `KeepMissing` restaurava incondicionalmente toda chave guardada que não
viesse no payload. Como a intenção era proteger o segredo omitido, o efeito
colateral passou despercebido: remover uma linha de header no editor de uma
custom tool ou de um MCP virava no-op. O payload legitimamente omitia a chave,
o backend a devolvia, e o header reaparecia no reload. Valia para header de auth
e para header comum, sem nenhuma affordance de UI capaz de removê-lo.

A correção usa a informação que a própria redação garante: `RedactValues` apaga
os VALORES mas sempre devolve os NOMES. Então:

- chave presente e vazia, sob nome redigido → é o eco da redação, mantém o
  guardado (é o que impede o apagamento silencioso que a 2.4 corrigiu)
- chave presente e vazia, sob nome seguro → é limpeza de verdade, honra
- chave AUSENTE → a linha foi deletada no editor, e continua deletada

Um teste meu codificava o contrato antigo (mandava os nomes ausentes para
simular a resposta sanitizada) e precisou ser reescrito: ele descrevia uma
redação que removia as chaves inteiras, não a que está no ar. O cenário que ele
protegia continua coberto, agora alimentado pelo próprio RedactValues.

Prova negativa verificada: restaurar o laço que reidratava chaves ausentes
quebra os dois testes de deleção.

Refs EVO-2250 (revisão da story 2.4)
… a chave

Corrige a regressão de capacidade achada na revisão adversarial (achado nº 7).

O DEFEITO: a descoberta de spaces do Nexus tinha a chave em claro como ÚNICA
entrada. A aposentadoria da 2.7 tira o campo da chave da tela, e os três
caminhos de descoberta dependem dela (o early-return do fetchSpaces, o botão
Recarregar desabilitado e o efeito de auto-fetch). Resultado: instalação
aposentada nunca popula o dropdown de spaces, e a mensagem de erro manda o
usuário digitar uma chave que não tem mais campo. Sobra digitar UUID na mão.

O ENDPOINT ganha um segundo modo, e o invariante de segurança é o desenho
inteiro: uma chave RESOLVIDA DO COFRE só pode ser enviada para a base URL
guardada na MESMA linha de integração.

- modo legado (config nova sendo digitada): o caller manda URL e chave, como
  hoje. Não há exposição nova: quem chama já tem os dois valores.
- modo salvo (aposentado): o caller manda só `agent_id`, e o servidor resolve
  a base URL E a credencial daquela mesma linha.

Misturar os dois é RECUSADO explicitamente, não resolvido em silêncio. Aceitar
credencial resolvida no servidor com URL escolhida pelo caller transformaria
`ai_agents:update` num primitivo de exfiltração: aponta a URL para o próprio
host e colhe a chave do Nexus guardada.

A chave sai do cofre decifrada server-side e nunca volta ao cliente; linha
`oauth` é recusada (valor nulo por CHECK), e o valor inline continua servindo
enquanto a 2.7 não aposenta o fallback.

⚠️ INERTE ATÉ O FRONT: o modo por referência só entra em uso quando a tela
passar `agent_id`, que é a metade do Reviewer. O modo legado segue 100%
funcional, então nada quebra nesse meio-tempo. Um teste verde aqui NÃO
significa que a descoberta voltou a funcionar em instalação aposentada.

Prova negativa verificada: remover a recusa de URL do caller junto com a
referência quebra TestSavedModeRejectsACallerSuppliedBaseURL.

Refs EVO-2250 (achado nº 7 da revisão)
…ores

Fecha a AC10 da story 2.4, que eu tinha registrado como não entregue.

A resposta da credencial (list E get) passa a carregar `referenced_by`, o
contrato `string[]` que a tela da 2.1 já lê com fallback. São cinco stores
agregados: integrações de agente por `credential_id`, custom tools e MCPs
remotos por `credential_refs`, MCP oficial pelas refs de env no config do
AGENTE, e bots de canal por `credential_id`.

DECISÃO DE FRONTEIRA, e ela REVERTE uma escrita minha anterior. O comentário do
`migrationStateRepository` (commit 46a9ba3) dizia que `agent_bots` vive no
schema do CRM e que este serviço não o lê. Aqui ele passa a ler, porque
`evo_community` é UM banco só e as alternativas são piores: Go chamando Rails
por HTTP reintroduz o problema de bearer que moldou a 1.2 (esta consulta roda
sem usuário logado), e Rails escrevendo coluna de agregação duplicaria estado.
A decisão anterior continua valendo para ESTADO DE MIGRAÇÃO, onde a resposta
depende de regra de negócio do CRM; aqui é leitura de fato consumado. O
comentário do outro site foi atualizado para não deixar duas versões da mesma
fronteira no código.

Três detalhes que o desenho trava:

- Os stores são lidos UMA vez por página e cruzados em memória. Por credencial
  seriam 5N idas ao banco numa tela que já pagina.
- Credencial sem consumidor devolve ARRAY VAZIO, nunca null: a tela distingue
  "ninguém usa" de "o servidor não sabe informar".
- O rótulo do bot NÃO usa o nome: `agent_bots` não tem coluna de tenant nem
  está na allowlist do tenantstamp, então ecoar nomes poderia vazar entre
  tenants no enterprise. Vai o provider.

Falha na agregação não derruba a listagem: as credenciais são o ponto do
endpoint, a lista de consumidores é decoração.

Nota: o MCP oficial não tem coluna própria de refs (a 000021 só criou
`credential_refs` em tools e MCPs custom), então a leitura é do config do
agente, que é onde a 2.4 decidiu plugar o cofre.

Prova negativa verificada: remover o attachReferences quebra dois testes.

Refs EVO-2250 (story 2.4, AC10)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @DavidsonGomes, your pull request is larger than the review limit of 150000 diff characters

@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements core parts of EVO-2250: introduces an encrypted integration credential vault with scopes, key hints and migration state; wires agent integrations, custom tools, MCP servers and Knowledge Nexus to reference vault entries instead of inlining secrets; and hardens API key handling and Nexus proxy semantics so secrets never round-trip to the browser and are validated/resolved server-side.

Sequence diagram for Knowledge Nexus list-spaces using vault-backed credentials

sequenceDiagram
    actor Frontend
    participant NexusHandler as AgentIntegrationHandler
    participant IntegrationService as AgentIntegrationService
    participant Repo as AgentIntegrationRepository
    participant VaultLookup as CredentialLookup

    Frontend->>NexusHandler: POST ListKnowledgeNexusSpaces
    NexusHandler->>NexusHandler: listKnowledgeNexusSpacesRequest.validate
    alt usesSavedCredential
        NexusHandler->>IntegrationService: ResolveNexusTarget(agent_id)
        IntegrationService->>IntegrationService: uuid.Parse(agent_id)
        IntegrationService->>Repo: GetByAgentAndProvider(agent_id, knowledge_nexus)
        Repo-->>IntegrationService: AgentIntegration
        IntegrationService->>IntegrationService: resolveNexusKey(config)
        alt config has credential_id
            IntegrationService->>VaultLookup: PlaintextOfActive(ctx, credential_id)
            VaultLookup-->>IntegrationService: plaintext key
        else inline key
            IntegrationService->>IntegrationService: [read nexus_api_key/apiKey]
        end
        IntegrationService-->>NexusHandler: baseURL, key
    else legacy inline
        NexusHandler->>NexusHandler: [use nexus_base_url and nexus_api_key]
    end
    NexusHandler->>NexusHandler: [proxy request to Knowledge Nexus]
    NexusHandler-->>Frontend: spaces list or error
Loading

Entity relationship diagram for new integration credential vault and consumers

erDiagram
    evo_core_integration_credentials {
        uuid id
        varchar name
        varchar provider
        varchar kind
        varchar scope
    }

    evo_core_agent_integrations {
        json config
    }

    evo_core_custom_tools {
        jsonb credential_refs
    }

    evo_core_custom_mcp_servers {
        jsonb credential_refs
    }

    evo_core_integration_credentials ||--o{ evo_core_agent_integrations : credential_id
    evo_core_integration_credentials ||--o{ evo_core_custom_tools : credential_refs
    evo_core_integration_credentials ||--o{ evo_core_custom_mcp_servers : credential_refs
Loading

File-Level Changes

Change Details Files
Agent integrations now validate and optionally resolve credentials via the new vault, merge stored secrets on upsert, and provide a secure Nexus target resolver used by the Knowledge Nexus space listing endpoint.
  • Extend AgentIntegrationService with ResolveNexusTarget and a CredentialLookup dependency, wired in module.InitModule with Fernet key
  • On Upsert, validate credential_id (UUID, active, non‑oauth) via CredentialLookup and merge incoming config with stored secrets for sensitive fields before persisting
  • Refactor sanitizeConfig to use a shared sensitiveFieldNames list that now includes platform secrets (apiKey/basicAuth*/nexus_api_key)
  • Add ResolveNexusTarget and resolveNexusKey helpers to read base URL and key from the same stored Nexus row, preferring vault references over inline keys
  • Update ListKnowledgeNexusSpaces handler to support two modes (legacy inline key vs saved agent reference), enforce mutual exclusivity of URL/key vs agent_id, and to resolve both base URL and key server-side when using saved credentials
pkg/agent_integration/service/agent_integration_service.go
pkg/agent_integration/service/agent_integration_service_test.go
pkg/agent_integration/model/agent_integration.go
pkg/agent_integration/model/agent_integration_test.go
pkg/agent_integration/handler/agent_integration_handler.go
pkg/agent_integration/handler/list_spaces_test.go
pkg/agent_integration/repository/credential_lookup.go
pkg/agent_integration/module.go
Custom tools and custom MCP servers now store per-header vault credential references, redact header values in responses, and preserve stored secrets on update despite redacted round‑tripping from the UI.
  • Extend CustomTool and CustomMcpServer models/DTOs with credential_refs JSONB and wire through create/update handlers and responses
  • Introduce secretmerge.RedactValues and KeepMissing to blank unsafe header values on read while keeping names, and to merge redacted/round‑tripped headers with stored ones without erasing secrets or blocking deletion
  • Update ToResponse for tools/MCP servers to apply RedactValues to headers and expose credential_refs untouched
  • In Update handlers, fetch stored entities and use KeepMissing to restore secret header values when the UI posts back redacted/empty maps
pkg/custom_tool/model/custom_tool.go
pkg/custom_tool/model/custom_tool_redaction_test.go
pkg/custom_tool/handler/custom_tool_handler.go
pkg/custom_tool/service/custom_tool_service.go
pkg/custom_mcp_server/model/custom_mcp_server.go
pkg/custom_mcp_server/model/custom_mcp_server_redaction_test.go
pkg/custom_mcp_server/handler/custom_mcp_server_handler.go
pkg/custom_mcp_server/service/custom_mcp_server_service.go
pkg/evoextensions/secretmerge/secretmerge.go
pkg/evoextensions/secretmerge/secretmerge_test.go
migrations/000021_add_credential_refs_to_tools_and_mcps.up.sql
migrations/000021_add_credential_refs_to_tools_and_mcps.down.sql
Introduce evo_core_integration_credentials vault with Fernet-encrypted static secrets, OAuth reference rows, scope, value formats, and a rich API surface including listing, migration-state reporting, and reference aggregation.
  • Add IntegrationCredential model with kind/static vs oauth, scalar/composite value_format, scope, owner_store/owner_ref, imported_from, is_active, plus helpers (Normalize*, DeriveValueHint, DeriveCompositeHint)
  • Create integration credential repository/service handling CRUD, filtered pagination (active/scope/kind/provider) and safe ToResponse mapping that never exposes value
  • Add handler with Fernet encryption using shared ENCRYPTION_KEY, create/update flows that derive hints from plaintext (including composite envelopes), forbid kind=oauth for now, handle is_active as pointer, and expose MigrationState and referenced_by aggregation
  • Implement OAuthSync (ConnectionReader+ReferenceStore) that reads live agent OAuth connections, upserts oauth reference rows without values, deactivates orphan rows, and decorates listing responses with connection status/expiry/agent identity
  • Implement MigrationState backed by store counts (ImportedCredentials + PendingInlineSecrets per consumer) to drive inline fallback retirement per consumer, failing closed on read errors
  • Add ReferenceRepository/ReferenceIndex to aggregate which agents, tools, MCP servers and agent_bots reference each credential, in one pass per listing
  • Add extensive unit tests for handler behavior (encryption, hinting, validation, migration-state semantics, referenced_by) and OAuth sync/connection status logic
  • Introduce migrations for vault table (kind/content coherence CHECKs, scope/name uniqueness, owner_store/owner_ref indices, enum checks) and OAuth owner partial unique index
pkg/integration_credential/model/integration_credential.go
pkg/integration_credential/model/integration_credential_test.go
pkg/integration_credential/model/oauth_connection.go
pkg/integration_credential/model/oauth_connection_test.go
pkg/integration_credential/model/credential_reference.go
pkg/integration_credential/model/migration_consumers.go
pkg/integration_credential/model/errors.go
pkg/integration_credential/model/migration_schema_test.go
pkg/integration_credential/repository/integration_credential_repository.go
pkg/integration_credential/repository/oauth_connection_repository.go
pkg/integration_credential/repository/reference_repository.go
pkg/integration_credential/repository/migration_state_repository.go
pkg/integration_credential/service/integration_credential_service.go
pkg/integration_credential/service/oauth_sync.go
pkg/integration_credential/service/oauth_sync_test.go
pkg/integration_credential/service/migration_state.go
pkg/integration_credential/service/migration_state_test.go
pkg/integration_credential/service/references.go
pkg/integration_credential/service/references_test.go
pkg/integration_credential/handler/integration_credential_handler.go
pkg/integration_credential/handler/integration_credential_handler_test.go
pkg/integration_credential/module.go
migrations/000019_create_integration_credentials_table.up.sql
migrations/000019_create_integration_credentials_table.down.sql
migrations/000020_unique_oauth_owner_reference.up.sql
migrations/000020_unique_oauth_owner_reference.down.sql
Strengthen API key model and handler so credentials carry scope, key hints, imported_from, OpenAI-compatibility flags, and proper is_active handling without ever returning keys to clients.
  • Extend ApiKey model with KeyHint, Scope, ImportedFrom and OpenAICompatible computed in ToResponse; add scope normalization helpers and OpenAI-compatible provider map
  • Update request/update DTOs to carry scope and pointer is_active, plus GetKey helper unchanged
  • Adjust handler.Create to normalize scope, derive key_hint from plaintext, and pass encrypted key; adjust Update to make scope optional, re-encrypt only when key present, refresh key_hint accordingly, and forward is_active pointer to service
  • Modify repository.List/Count to filter by scope, and Update to perform struct Updates plus explicit is_active column update and reload row
  • Add tests ensuring create/update/list all never expose key (plaintext or encrypted), hints and scopes are correct, OpenAI compatibility flag is set, and is_active propagates correctly
  • Wire scope query parameter through handler.List to service
pkg/api_key/model/api_key.go
pkg/api_key/model/api_key_test.go
pkg/api_key/handler/api_key_handler.go
pkg/api_key/handler/api_key_handler_test.go
pkg/api_key/handler/api_key_is_active_test.go
pkg/api_key/repository/api_key_repository.go
pkg/api_key/service/api_key_service.go
migrations/000016_add_key_hint_to_api_keys.up.sql
migrations/000016_add_key_hint_to_api_keys.down.sql
migrations/000017_add_scope_to_api_keys.up.sql
migrations/000017_add_scope_to_api_keys.down.sql
migrations/000018_add_imported_from_to_api_keys.up.sql
migrations/000018_add_imported_from_to_api_keys.down.sql
Bootstrap integration credential module and wiring in cmd/api, and adjust tenant stamping to treat the new vault as a tenant-scoped write table under enterprise builds.
  • Instantiate IntegrationCredentialModule in cmd/api with shared Fernet key, register its routes at /integration-credentials under v1, ordered above /agents to avoid path conflicts
  • Update agent integration module InitModule signature to accept encryptionKey and to create CredentialLookup with the same key
  • Add evo_core_integration_credentials to tenantScopedWriteTables in tenantstamp plugin so enterprise builds apply RLS and tenant stamping to vault writes consistently with other core tables
cmd/api/main.go
pkg/agent_integration/module.go
pkg/evoextensions/tenantstamp/plugin.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

DavidsonGomes and others added 5 commits July 30, 2026 10:16
ALTO 5 da reprovacao. As rotas exigiam so ai_api_keys.{create,update,
delete} (e as irmas do cofre), e o NormalizeScope aceitava
scope:'installation' de qualquer chamador: o gate installation_configs
.manage existia SO no browser. Um admin de conta criava, editava ou
apagava a credencial padrao da instalacao com um curl.

O gate nao pode viver na rota porque o escopo viaja no BODY: entra
dentro do handler, via middleware.RequireInstallationScope. Fecha as
tres portas em cada registro:
- criar com scope installation;
- promover uma credencial de conta para installation;
- editar ou apagar uma que JA e de instalacao (escopo omitido mantem o
  guardado, entao o gate le a linha alvo antes de decidir).

Falha de rede ou middleware nao inicializado sao NEGACAO, nunca
liberacao: credencial que toda conta herda nao se concede por erro.

Testes de CAMINHO (a classe de defeito que reprovou o card foi funcao
testada e nunca chamada): dirigem o handler real com middleware de
permissao stubado, provando 403 sem o grant e 2xx com ele, mais o
controle de que escopo account nem consulta o gate. git grep confirma 4
chamadores de producao.

Dois stubs de teste pre-existentes embutiam a interface sem implementar
GetByID e panicavam quando o gate passou a ler a linha alvo; ambos
implementados.

Refs EVO-2250
…de segredo

ALTO 7 — base_url. A tela renderizava e ENVIAVA base_url desde a 1.1,
mas o campo nao existia em ApiKeyRequest, ApiKeyResponse, model nem
migration: todo endpoint digitado era descartado em silencio, e com ele
caiam a 1.5 AC1 (OPENAI_API_URL preservada junto da credencial) e a
tarefa da 1.3 (credencial resolvida com URL propria vence).

DECISAO REGISTRADA: implementei a coluna em vez de tirar o campo do
formulario. Tirar fecharia o buraco silencioso mas mataria as duas ACs,
e o endpoint pertence a CREDENCIAL: um provedor OpenAI-compativel e
definido pelo PAR chave+endpoint, entao duas credenciais da mesma
instalacao podem apontar para hosts diferentes. NULL = default do
provedor, que e o que toda credencial anterior significa.

Armadilha fechada junto: limpar o endpoint seria no-op silencioso,
porque o GORM pula ponteiro nil em struct Updates — o mesmo defeito que
o toggle is_active teve. Resolvido com a flag BaseURLSet (nao e coluna)
e Update de coluna explicito, mesmo padrao do is_active.

MEDIO 9 — duas semanticas opostas para o mesmo round-trip.
MergePreservedSecrets tratava presente-e-vazio como 'limpar' enquanto
secretmerge.KeepMissing trata como 'manter'. A primeira so era segura
porque UM cliente (a tela) foi corrigido para omitir o campo; como o GET
e sanitizado, qualquer outro cliente que ecoasse apiKey vazio apagava o
segredo. Agora ausente OU em branco significa manter, nos dois lados.
Limpar se faz apontando para o cofre (2.4) ou aposentando o inline
(2.7). Teste da propriedade de composicao: salvar de volta exatamente o
que o GET sanitizado devolveu nao muda nada.

BAIXO 18 — bloco de comentario do reference_repository traduzido para
ingles, como o resto do Go. Os rotulos SQL em portugues ficam: sao
strings user-facing do painel, nao comentario.

Testes de caminho para o base_url (8) e para o merge (3), com git grep
provando os chamadores de producao.

Refs EVO-2250
CRITICO 2 da reprovacao, lado core. processMCPServers remontava cada
entrada a partir de uma allowlist de tres chaves (id, environments,
tools): credential_refs era descartado em silencio, entao a resolucao
que o processor implementou nunca receberia referencia nenhuma para
resolver — funcao escrita e jamais alcancada, a mesma classe de defeito
que reprovou o card.

A validacao de chaves obrigatorias passa a aceitar uma variavel
satisfeita POR REFERENCIA do cofre. Exigir valor inline manteria o
cofre inutil exatamente para os segredos que ele existe para guardar,
ja que apontar para uma credencial e o que dispensa digitar o valor. Ela
segue reprovando quando nao ha nem valor nem referencia, e ausencia de
referencia nao vira mapa vazio (nada a jusante precisa distinguir '{}'
de 'nao configurado').

4 testes de caminho, o primeiro deste pacote: dirigem processMCPServers
de verdade com o catalogo injetado.

Refs EVO-2250
…o alvo

authorizeScopeWrite tratava erro de leitura como "não toca instalação" e
seguia autorizando, nos dois handlers. Um gate que abre quando não enxerga o
que guarda não é gate. Agora falha exigindo o privilégio; o custo é 403 onde
uma linha ausente responderia 404.
Sai o que pertencia ao relatório de review e não ao fonte: reconstrução do
defeito, narrativa antes/depois, citação de card e severidade. Fica o porquê
não-óbvio — a armadilha do GORM com zero value, o Omit/TargetWhere do upsert
parcial, o allowlist de header, o par chave+endpoint.

Só comentário: nenhuma linha de código mudou (diff conferido). Build, vet e a
suíte inteira verdes.
@gomessguii
gomessguii merged commit 598ff6f into develop Jul 30, 2026
5 checks passed
@gomessguii
gomessguii deleted the feat/evo-2250-ai-credentials-key-hint branch July 30, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants