Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4f8678d
feat(api-keys): mascara a chave na resposta e expõe compatibilidade d…
DavidsonGomes Jul 29, 2026
6fcf038
fix(api-keys): reconhece custom_openai_compatible como provedor OpenA…
DavidsonGomes Jul 29, 2026
2f3fc95
feat(api-keys): escopo de instalação e conta no registro
DavidsonGomes Jul 29, 2026
e826172
refactor(api-keys): normaliza scope só na escrita
DavidsonGomes Jul 29, 2026
d3d2862
feat(api-keys): marca a origem da credencial importada
DavidsonGomes Jul 29, 2026
b888002
feat(integration-credentials): cria o registro do cofre de credenciai…
DavidsonGomes Jul 29, 2026
eb0ebb7
feat(agent-integrations): aceita credencial do cofre e para de ecoar …
DavidsonGomes Jul 29, 2026
c92a4d7
feat(integration-credentials): lista conexões OAuth por referência, s…
DavidsonGomes Jul 29, 2026
7cc6efe
feat(tools-mcps): referência ao cofre em tools e MCPs, e fim do eco d…
DavidsonGomes Jul 29, 2026
7ec99a9
fix(integration-credentials): sync OAuth gravavel de verdade e toggle…
DavidsonGomes Jul 29, 2026
46a9ba3
feat(integration-credentials): expõe o estado de migração por consumidor
DavidsonGomes Jul 29, 2026
d01d19e
fix(api-keys): desativar credencial de IA era um no-op silencioso
DavidsonGomes Jul 29, 2026
a1a47d6
fix(integration-credentials): eco redigido nao apaga segredo e bot na…
DavidsonGomes Jul 29, 2026
904ba6d
fix(secretmerge): header nunca podia ser deletado
DavidsonGomes Jul 29, 2026
1e32712
feat(knowledge-nexus): descoberta de spaces por referência, sem expor…
DavidsonGomes Jul 29, 2026
62df909
feat(integration-credentials): referenced_by agrega os cinco consumid…
DavidsonGomes Jul 29, 2026
2994665
fix(auth): fecha o escopo installation no servidor, nos dois registros
DavidsonGomes Jul 30, 2026
ae28fcd
feat(api-keys): base_url deixa de ser campo morto, e unifica o merge …
DavidsonGomes Jul 30, 2026
62830b7
feat(mcp): credential_refs de env var atravessa o processamento (AC7)
DavidsonGomes Jul 30, 2026
825ee98
fix(auth): o gate de escopo deixa de liberar quando não consegue ler …
gomessguii Jul 30, 2026
ac0f9cc
style(comments): enxuga os comentários da branch para o critério do time
gomessguii Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
customToolModule "evo-ai-core-service/pkg/custom_tool"
folderModule "evo-ai-core-service/pkg/folder"
folderShareModule "evo-ai-core-service/pkg/folder_share"
integrationCredentialModule "evo-ai-core-service/pkg/integration_credential"
mcpServerModule "evo-ai-core-service/pkg/mcp_server"
"flag"
"fmt"
Expand All @@ -26,7 +27,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"

)

func main() {
Expand Down Expand Up @@ -81,6 +81,9 @@ func main() {
customToolModule := customToolModule.New(db)
customMcpServerModule := customMcpServerModule.New(db, &cfg.AIProcessorService)
apiKeyModule := apiKeyModule.New(db, cfg.Core.EncryptionKey)
// Same Fernet key as the AI credential registry: the processor decrypts both
// with the ENCRYPTION_KEY it already shares with this service.
integrationCredentialModule := integrationCredentialModule.New(db, cfg.Core.EncryptionKey)
folderModule := folderModule.New(db)
folderShareModule := folderShareModule.New(db, folderModule.Service)
agentModule := agentModule.New(
Expand Down Expand Up @@ -146,9 +149,12 @@ func main() {
folderModule.Handler.RegisterRoutesMiddleware(v1)
// Register API keys before agents to ensure /agents/apikeys is captured first
apiKeyModule.Handler.RegisterRoutesMiddleware(v1)
// Top-level group, so it carries none of the /agents/:id ordering
// hazard the api keys routes have to work around.
integrationCredentialModule.Handler.RegisterRoutesMiddleware(v1)
agentModule.Handler.RegisterRoutesMiddleware(v1)
// Register agent integrations routes
agentIntegrationModule.InitModule(db, v1)
agentIntegrationModule.InitModule(db, v1, cfg.Core.EncryptionKey)
folderShareModule.Handler.RegisterRoutesMiddleware(v1)
}

Expand Down
2 changes: 2 additions & 0 deletions migrations/000016_add_key_hint_to_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE evo_core_api_keys
DROP COLUMN IF EXISTS key_hint;
4 changes: 4 additions & 0 deletions migrations/000016_add_key_hint_to_api_keys.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Store a masked hint (last 4 characters of the plaintext key) so the UI can
-- render a mask without the API ever returning the key itself.
ALTER TABLE evo_core_api_keys
ADD COLUMN IF NOT EXISTS key_hint VARCHAR(8) NOT NULL DEFAULT '';
7 changes: 7 additions & 0 deletions migrations/000017_add_scope_to_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_evo_core_api_keys_scope_active;

ALTER TABLE evo_core_api_keys
DROP CONSTRAINT IF EXISTS evo_core_api_keys_scope_check;

ALTER TABLE evo_core_api_keys
DROP COLUMN IF EXISTS scope;
29 changes: 29 additions & 0 deletions migrations/000017_add_scope_to_api_keys.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- Credentials resolve through an ordered scope chain (installation -> account),
-- where the most specific link wins. Existing rows are account-level by default;
-- promoting values between scopes is a separate data migration.
ALTER TABLE evo_core_api_keys
ADD COLUMN IF NOT EXISTS scope VARCHAR(32) NOT NULL DEFAULT 'account';

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'evo_core_api_keys_scope_check'
) THEN
ALTER TABLE evo_core_api_keys
ADD CONSTRAINT evo_core_api_keys_scope_check
CHECK (scope IN ('installation', 'account'));
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'evo_core_api_keys'
AND indexname = 'idx_evo_core_api_keys_scope_active'
) THEN
CREATE INDEX idx_evo_core_api_keys_scope_active
ON evo_core_api_keys (scope, is_active);
END IF;
END
$$;
4 changes: 4 additions & 0 deletions migrations/000018_add_imported_from_to_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_evo_core_api_keys_imported_from;

ALTER TABLE evo_core_api_keys
DROP COLUMN IF EXISTS imported_from;
20 changes: 20 additions & 0 deletions migrations/000018_add_imported_from_to_api_keys.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Records which legacy source a credential was imported from, so the migration
-- is idempotent without using the name as its key: a human may rename, disable
-- or replace an imported credential, and a re-run must respect that.
ALTER TABLE evo_core_api_keys
ADD COLUMN IF NOT EXISTS imported_from VARCHAR(64);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'evo_core_api_keys'
AND indexname = 'idx_evo_core_api_keys_imported_from'
) THEN
CREATE UNIQUE INDEX idx_evo_core_api_keys_imported_from
ON evo_core_api_keys (imported_from)
WHERE imported_from IS NOT NULL;
END IF;
END
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS evo_core_integration_credentials;
70 changes: 70 additions & 0 deletions migrations/000019_create_integration_credentials_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- The integration credential vault: the secret a tool or integration needs to
-- act (Dify key, n8n basic auth, MCP header, Knowledge Nexus key). Deliberately
-- NOT evo_core_api_keys, which holds model-provider keys as a simple pair.
--
-- `kind` is the discriminator that keeps the vault from becoming a refresh
-- subsystem: a `static` row owns its (encrypted) value, while an `oauth` row
-- owns nothing and points at the store that already refreshes the token.
-- Story 2.5 opens the oauth path; 2.1 only stores static secrets.
CREATE TABLE IF NOT EXISTS evo_core_integration_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
provider VARCHAR(100) NOT NULL,
kind VARCHAR(16) NOT NULL DEFAULT 'static',
value TEXT,
value_format VARCHAR(16) NOT NULL DEFAULT 'scalar',
value_hint VARCHAR(8) NOT NULL DEFAULT '',
scope VARCHAR(32) NOT NULL DEFAULT 'account',
owner_store VARCHAR(64),
owner_ref VARCHAR(128),
imported_from VARCHAR(128),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT evo_core_integration_credentials_kind_check
CHECK (kind IN ('static', 'oauth')),
CONSTRAINT evo_core_integration_credentials_value_format_check
CHECK (value_format IN ('scalar', 'composite')),
CONSTRAINT evo_core_integration_credentials_scope_check
CHECK (scope IN ('installation', 'account')),
-- Coherence between kind and content, enforced by the database rather than
-- by convention: a convention breaks in a distracted pull request, and the
-- whole point of the oauth kind is that no token value ever lands here.
CONSTRAINT evo_core_integration_credentials_kind_content_check
CHECK (
(kind = 'static' AND value IS NOT NULL AND owner_store IS NULL AND owner_ref IS NULL)
OR
(kind = 'oauth' AND value IS NULL AND owner_store IS NOT NULL AND owner_ref IS NOT NULL)
),
-- Uniqueness is per scope, NEVER on name alone. The three sibling tables
-- (custom_tools, custom_mcp_servers, mcp_servers) unique on name alone, and
-- in the enterprise build two tenants naming a credential "Producao"
-- collide in the database. Adding tenant_id to this index is then a gem
-- migration, with no need to drop a live unique constraint.
CONSTRAINT evo_core_integration_credentials_scope_name_unique
UNIQUE (scope, name)
);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'evo_core_integration_credentials'
AND indexname = 'idx_evo_core_integration_credentials_scope_active'
) THEN
CREATE INDEX idx_evo_core_integration_credentials_scope_active
ON evo_core_integration_credentials (scope, is_active);
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'evo_core_integration_credentials'
AND indexname = 'idx_evo_core_integration_credentials_kind_provider'
) THEN
CREATE INDEX idx_evo_core_integration_credentials_kind_provider
ON evo_core_integration_credentials (kind, provider);
END IF;
END
$$;
1 change: 1 addition & 0 deletions migrations/000020_unique_oauth_owner_reference.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_evo_core_integration_credentials_owner_unique;
21 changes: 21 additions & 0 deletions migrations/000020_unique_oauth_owner_reference.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- The natural key of an oauth reference row is the store that owns the token
-- plus the row in it. The sync (story 2.5) upserts on this key, so a connection
-- that disappears and comes back reactivates its row instead of producing a
-- second one.
--
-- Partial index: only oauth rows have an owner, and static rows keep both
-- columns NULL by the coherence CHECK of migration 000019.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'evo_core_integration_credentials'
AND indexname = 'idx_evo_core_integration_credentials_owner_unique'
) THEN
CREATE UNIQUE INDEX idx_evo_core_integration_credentials_owner_unique
ON evo_core_integration_credentials (owner_store, owner_ref)
WHERE owner_store IS NOT NULL AND owner_ref IS NOT NULL;
END IF;
END
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE evo_core_custom_tools DROP COLUMN IF EXISTS credential_refs;
ALTER TABLE evo_core_custom_mcp_servers DROP COLUMN IF EXISTS credential_refs;
13 changes: 13 additions & 0 deletions migrations/000021_add_credential_refs_to_tools_and_mcps.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Vault references for tool and MCP secrets (EVO-2250, story 2.4).
--
-- It is a MAP (header or env var name -> credential id), not a scalar column:
-- one credential equals one secret, so a tool with two auth headers references
-- TWO credentials. A scalar column could not say WHICH header it replaces.
--
-- The inline `headers` stay untouched: they are the fallback until story 2.7
-- retires them, so nothing breaks before the 2.6 migration runs.
ALTER TABLE evo_core_custom_tools
ADD COLUMN IF NOT EXISTS credential_refs JSONB NOT NULL DEFAULT '{}';

ALTER TABLE evo_core_custom_mcp_servers
ADD COLUMN IF NOT EXISTS credential_refs JSONB NOT NULL DEFAULT '{}';
66 changes: 63 additions & 3 deletions pkg/agent_integration/handler/agent_integration_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
Expand Down Expand Up @@ -185,8 +186,49 @@ func (h *agentIntegrationHandler) Delete(c *gin.Context) {
// listKnowledgeNexusSpacesRequest is the body schema for the
// POST /integrations/knowledge-nexus/list-spaces proxy endpoint.
type listKnowledgeNexusSpacesRequest struct {
NexusBaseURL string `json:"nexus_base_url" binding:"required"`
NexusAPIKey string `json:"nexus_api_key" binding:"required"`
// Legacy mode: the user is typing a NEW configuration, so the caller holds
// both values and forwarding them adds no exposure.
NexusBaseURL string `json:"nexus_base_url"`
NexusAPIKey string `json:"nexus_api_key"`
// Saved mode: the key was retired from the screen (story 2.7), so the
// server resolves BOTH the base URL and the credential from the agent's
// stored integration row.
AgentID string `json:"agent_id"`
}

// usesSavedCredential reports whether the server must resolve the target from
// the stored integration instead of trusting the request.
func (r listKnowledgeNexusSpacesRequest) usesSavedCredential() bool {
return strings.TrimSpace(r.AgentID) != "" && strings.TrimSpace(r.NexusAPIKey) == ""
}

// validate enforces the security invariant of this endpoint.
//
// ⚠️ A VAULT-RESOLVED key may only ever be sent to the base URL stored in the
// SAME integration row. Accepting a server-resolved credential together with a
// caller-supplied URL would turn `ai_agents:update` into a credential
// exfiltration primitive: point the URL at your own host and harvest the
// stored Nexus key. The two modes are therefore mutually exclusive, and mixing
// them is REJECTED rather than silently resolved one way or the other.
func (r listKnowledgeNexusSpacesRequest) validate() error {
hasReference := strings.TrimSpace(r.AgentID) != ""
hasKey := strings.TrimSpace(r.NexusAPIKey) != ""
hasURL := strings.TrimSpace(r.NexusBaseURL) != ""

if hasReference && hasKey {
return fmt.Errorf("send either agent_id or nexus_api_key, never both")
}
if hasReference && hasURL {
return fmt.Errorf("nexus_base_url cannot be supplied with agent_id: the saved base URL is used")
}
if hasReference {
return nil
}
if !hasKey || !hasURL {
return fmt.Errorf("nexus_base_url and nexus_api_key are required, or agent_id to use the saved credential")
}

return nil
}

// ListKnowledgeNexusSpaces proxies a GET to the user's EvoNexus instance to
Expand All @@ -201,13 +243,31 @@ func (h *agentIntegrationHandler) ListKnowledgeNexusSpaces(c *gin.Context) {
return
}

if err := req.validate(); err != nil {
response.ErrorResponse(c, "validation_error", err.Error(), nil, http.StatusBadRequest)
return
}

baseURL := strings.TrimRight(strings.TrimSpace(req.NexusBaseURL), "/")
apiKey := strings.TrimSpace(req.NexusAPIKey)

if req.usesSavedCredential() {
// Both values come from the SAME stored row, which is what keeps a
// resolved credential from ever reaching a caller-chosen host.
savedURL, savedKey, err := h.service.ResolveNexusTarget(c.Request.Context(), req.AgentID)
if err != nil {
response.ErrorResponse(c, "not_found", err.Error(), nil, http.StatusNotFound)
return
}
baseURL = strings.TrimRight(strings.TrimSpace(savedURL), "/")
apiKey = strings.TrimSpace(savedKey)
}

if baseURL == "" || apiKey == "" {
response.ErrorResponse(
c,
"validation_error",
"nexus_base_url and nexus_api_key are required",
"the saved Knowledge Nexus integration has no usable base URL and credential",
nil,
http.StatusBadRequest,
)
Expand Down
Loading
Loading