feat(credentials): registro de IA com scope/hint + cofre de integrações (EVO-2250) - #23
Merged
Merged
Conversation
…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)
There was a problem hiding this comment.
Sorry @DavidsonGomes, your pull request is larger than the review limit of 150000 diff characters
Reviewer's GuideImplements 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 credentialssequenceDiagram
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
Entity relationship diagram for new integration credential vault and consumerserDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
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
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
É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:
Enhancements:
Build:
Tests: