Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 9 additions & 3 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 @@ -137,7 +140,7 @@ func main() {
// installGuardian is a build-tagged hook: no-op in the community build
// (cmd/api/wire_guardian_community.go), boots the license guardian when the
// binary is built with `-tags=guardian` (cmd/api/wire_guardian.go). The
// guardian runs for the life of the process on context.Background().
// guardian runs for the life of the process on context.Background.
installGuardian(context.Background(), db)
{
customToolModule.Handler.RegisterRoutesMiddleware(v1)
Expand All @@ -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
71 changes: 71 additions & 0 deletions internal/middleware/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ func InitializePermissionMiddleware(evoAuthBaseURL string) {
globalPermissionMiddleware = NewPermissionMiddleware(evoAuthBaseURL)
}

// SetGlobalPermissionMiddleware replaces the global middleware and returns a
// restore func. Tests use it to reach the gates that live inside handlers,
// which no route-level stub can exercise.
func SetGlobalPermissionMiddleware(m PermissionMiddleware) func() {
previous := globalPermissionMiddleware
globalPermissionMiddleware = m
return func() { globalPermissionMiddleware = previous }
}

// GetGlobalPermissionMiddleware retorna o middleware global
func GetGlobalPermissionMiddleware() PermissionMiddleware {
if globalPermissionMiddleware == nil {
Expand All @@ -33,6 +42,53 @@ type PermissionMiddleware interface {
RequirePermission(resource, action string) gin.HandlerFunc
CheckPermission(authToken, permissionKey string) (bool, error)
CheckPermissionWithType(authToken, permissionKey, tokenType string) (bool, error)
// HasPermission checks a permission from INSIDE a handler, for gates that
// depend on the request body and therefore cannot live on the route.
HasPermission(c *gin.Context, resource, action string) (bool, error)
}

// The permission governing writes at the INSTALLATION level: a credential every
// account inherits.
// // Checked inside the handler because the scope arrives in the body, which a
// route-level middleware cannot see.
const (
InstallationScopeResource = "installation_configs"
InstallationScopeAction = "manage"
)

// RequireInstallationScope answers whether the caller may write at the
// installation level, writing the 401/403 itself when it may not.
// // Any failure to reach the auth service is a denial: a credential every account
// inherits is not something to grant on a network error.
func RequireInstallationScope(c *gin.Context) bool {
// No middleware means no way to authorize: deny rather than panic, and
// never let an uninitialized gate become an open door.
if globalPermissionMiddleware == nil {
response.ErrorResponse(c, "ERR_INTERNAL_SERVER", "Unable to validate user permissions", nil, http.StatusInternalServerError)
c.Abort()
return false
}

allowed, err := globalPermissionMiddleware.HasPermission(c, InstallationScopeResource, InstallationScopeAction)
if err != nil {
response.ErrorResponse(c, "ERR_INTERNAL_SERVER", "Unable to validate user permissions", nil, http.StatusInternalServerError)
c.Abort()
return false
}

if !allowed {
response.ErrorResponse(
c,
"ERR_FORBIDDEN",
"installation_configs.manage is required to write a credential at the installation scope",
nil,
http.StatusForbidden,
)
c.Abort()
return false
}

return true
}

type permissionMiddleware struct {
Expand Down Expand Up @@ -84,6 +140,21 @@ func (p *permissionMiddleware) RequirePermission(resource, action string) gin.Ha
}
}

// HasPermission checks a permission for the caller of the current request.
// Handlers use it for gates that depend on the request body, which a
// route-level middleware cannot see.
func (p *permissionMiddleware) HasPermission(c *gin.Context, resource, action string) (bool, error) {
permissionKey := fmt.Sprintf("%s.%s", resource, action)

tokenType, _ := contextutils.GetTokenType(c.Request.Context())
authToken, _ := contextutils.GetToken(c.Request.Context())
if authToken == "" {
return false, nil
}

return p.CheckPermissionWithType(authToken, permissionKey, tokenType)
}

// CheckPermission delegates to EvoAuthService for unified permission handling
func (p *permissionMiddleware) CheckPermission(authToken, permissionKey string) (bool, error) {
return p.CheckPermissionWithType(authToken, permissionKey, "bearer")
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;
64 changes: 64 additions & 0 deletions migrations/000019_create_integration_credentials_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- The integration credential vault: the secret a tool or integration needs to
-- act (Dify key, n8n basic auth, MCP header, Knowledge Nexus key).
---- `kind` keeps the vault from becoming a refresh subsystem: a `static` row owns
-- its encrypted value, an `oauth` row owns nothing and points at the store that
-- already refreshes the token.
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')),
-- Enforced by the database, not by convention: 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)
),
-- Per scope, NEVER on name alone: the sibling tables do that, and in the
-- enterprise build two tenants naming a credential "Producao" collide.
-- Adding tenant_id here is then a gem migration, not a live constraint drop.
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;
20 changes: 20 additions & 0 deletions migrations/000020_unique_oauth_owner_reference.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- 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;
10 changes: 10 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,10 @@
-- Vault references for tool and MCP secrets.
---- A MAP (header or env var name -> credential id), not a scalar: one credential
-- is one secret, so a tool with two auth headers references two, and a scalar
-- could not say which header it replaces.
---- The inline `headers` stay as the fallback until retirement.
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 '{}';
1 change: 1 addition & 0 deletions migrations/000022_add_base_url_to_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE evo_core_api_keys DROP COLUMN IF EXISTS base_url;
7 changes: 7 additions & 0 deletions migrations/000022_add_base_url_to_api_keys.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- The endpoint a credential talks to, stored next to the credential.
---- It belongs here rather than in installation config: an OpenAI-compatible
-- provider is defined by the pair key+endpoint, so two credentials on the same
-- installation can legitimately point at different hosts. NULL means "the
-- provider default".
ALTER TABLE evo_core_api_keys
ADD COLUMN IF NOT EXISTS base_url VARCHAR(512);
29 changes: 25 additions & 4 deletions pkg/agent/service/processor/config_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,18 +268,39 @@ func (p ConfigProcessor) processMCPServers(ctx context.Context, servers interfac
return nil, fmt.Errorf("server environments must be a dictionary")
}

// An env var whose value lives in the vault is referenced by name here
// (credential_refs: {ENV_NAME: credential id}). The runtime resolves it;
// this service only carries the reference.
credentialRefs, _ := serverMap["credential_refs"].(map[string]interface{})

mcpServerResponse := mcpServer.ToResponse()
for envKey := range mcpServerResponse.Environments {
if _, exists := environments[envKey]; !exists {
return nil, fmt.Errorf("environment variable '%s' not provided for MCP server %s", envKey, mcpServer.Name)
if _, exists := environments[envKey]; exists {
continue
}
// A required key satisfied by a vault reference is provided, just
// not inline: demanding a plaintext value here would make the vault
// unusable for exactly the secrets it exists to hold.
if _, referenced := credentialRefs[envKey]; referenced {
continue
}
return nil, fmt.Errorf("environment variable '%s' not provided for MCP server %s", envKey, mcpServer.Name)
}

processedServers = append(processedServers, map[string]interface{}{
processed := map[string]interface{}{
"id": serverID,
"environments": serverMap["environments"],
"tools": serverMap["tools"],
})
}

// Carried ONLY when present: this allowlist is what reaches the agent
// config, so a field left out here is silently dropped and the runtime
// resolution downstream never sees a reference to resolve.
if len(credentialRefs) > 0 {
processed["credential_refs"] = credentialRefs
}

processedServers = append(processedServers, processed)
}

return processedServers, nil
Expand Down
Loading
Loading