WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package dto
|
||||
|
||||
import "github.com/Tencent/WeKnora/internal/types"
|
||||
|
||||
// AuthLoginResponse is the HTTP-safe login / switch-tenant response shape.
|
||||
type AuthLoginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
User *types.User `json:"user,omitempty"`
|
||||
ActiveTenant *TenantResponse `json:"active_tenant,omitempty"`
|
||||
Memberships []types.Membership `json:"memberships"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// AuthOIDCCallbackResponse is the HTTP-safe OIDC callback payload shape.
|
||||
type AuthOIDCCallbackResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
User *types.User `json:"user,omitempty"`
|
||||
Tenant *TenantResponse `json:"tenant,omitempty"`
|
||||
Memberships []types.Membership `json:"memberships"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IsNewUser bool `json:"is_new_user,omitempty"`
|
||||
}
|
||||
|
||||
// NewAuthLoginResponse converts a service-layer login response for HTTP output.
|
||||
func NewAuthLoginResponse(resp *types.LoginResponse) *AuthLoginResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
var role types.TenantRole
|
||||
if resp.ActiveTenant != nil {
|
||||
role = membershipRoleForTenant(resp.Memberships, resp.ActiveTenant.ID)
|
||||
}
|
||||
return &AuthLoginResponse{
|
||||
Success: resp.Success,
|
||||
Message: resp.Message,
|
||||
User: resp.User,
|
||||
ActiveTenant: NewTenantResponseWithRole(resp.ActiveTenant, role),
|
||||
Memberships: resp.Memberships,
|
||||
Token: resp.Token,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthOIDCCallbackResponse converts an OIDC callback response for HTTP output.
|
||||
func NewAuthOIDCCallbackResponse(resp *types.OIDCCallbackResponse) *AuthOIDCCallbackResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
var role types.TenantRole
|
||||
if resp.Tenant != nil {
|
||||
role = membershipRoleForTenant(resp.Memberships, resp.Tenant.ID)
|
||||
}
|
||||
return &AuthOIDCCallbackResponse{
|
||||
Success: resp.Success,
|
||||
Message: resp.Message,
|
||||
User: resp.User,
|
||||
Tenant: NewTenantResponseWithRole(resp.Tenant, role),
|
||||
Memberships: resp.Memberships,
|
||||
Token: resp.Token,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
IsNewUser: resp.IsNewUser,
|
||||
}
|
||||
}
|
||||
|
||||
func membershipRoleForTenant(memberships []types.Membership, tenantID uint64) types.TenantRole {
|
||||
for _, m := range memberships {
|
||||
if m.TenantID == tenantID && m.Role.IsValid() {
|
||||
return m.Role
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthLoginResponse_ViewerOmitsActiveTenantSecrets(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
resp := NewAuthLoginResponse(&types.LoginResponse{
|
||||
Success: true,
|
||||
ActiveTenant: tenant,
|
||||
Memberships: []types.Membership{{
|
||||
TenantID: tenant.ID,
|
||||
Role: types.TenantRoleViewer,
|
||||
}},
|
||||
})
|
||||
body, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "tenant-api-key-123")
|
||||
assert.NotContains(t, s, "parser-secret-123")
|
||||
}
|
||||
|
||||
func TestAuthLoginResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
resp := NewAuthLoginResponse(&types.LoginResponse{
|
||||
Success: true,
|
||||
ActiveTenant: tenant,
|
||||
Memberships: []types.Membership{{
|
||||
TenantID: tenant.ID,
|
||||
Role: types.TenantRoleOwner,
|
||||
}},
|
||||
})
|
||||
body, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, `"api_key"`)
|
||||
assert.NotContains(t, s, "legacy-search-secret-999")
|
||||
assert.Contains(t, s, "web_search_config")
|
||||
}
|
||||
|
||||
func TestAuthOIDCCallbackResponse_ViewerOmitsTenantSecrets(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
resp := NewAuthOIDCCallbackResponse(&types.OIDCCallbackResponse{
|
||||
Success: true,
|
||||
Tenant: tenant,
|
||||
Memberships: []types.Membership{{
|
||||
TenantID: tenant.ID,
|
||||
Role: types.TenantRoleViewer,
|
||||
}},
|
||||
})
|
||||
body, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(body), "tenant-api-key-123")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// DataSourceResponse mirrors types.DataSource for response bodies, with the
|
||||
// connector Credentials map stripped from the Config jsonb. Credential
|
||||
// presence is exposed via the dedicated /credentials subresource.
|
||||
//
|
||||
// Unlike MCP / Model / WebSearch (which have a flat set of named credential
|
||||
// fields), DataSource credentials are a per-connector atomic map — an OAuth
|
||||
// token pair, a Confluence email+token bundle, etc. Splitting them at the
|
||||
// field level would leave half-configured states that can't actually
|
||||
// authenticate. The subresource therefore exposes only one logical field,
|
||||
// "credentials", with PUT replacing the whole map and DELETE wiping it.
|
||||
type DataSourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config *DataSourceConfigDTO `json:"config,omitempty"`
|
||||
SyncSchedule string `json:"sync_schedule"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Status string `json:"status"`
|
||||
ConflictStrategy string `json:"conflict_strategy"`
|
||||
SyncDeletions bool `json:"sync_deletions"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
LastSyncCursor json.RawMessage `json:"last_sync_cursor,omitempty"`
|
||||
LastSyncResult json.RawMessage `json:"last_sync_result,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
SyncLogRetentionDays int `json:"sync_log_retention_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TotalItemsSynced int64 `json:"total_items_synced"`
|
||||
LatestSyncLog *types.SyncLog `json:"latest_sync_log,omitempty"`
|
||||
// Single logical credential field — DataSource credentials are a
|
||||
// per-connector atomic map, so "configured?" applies to the whole set.
|
||||
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
|
||||
}
|
||||
|
||||
// DataSourceConfigDTO is types.DataSourceConfig with the Credentials map
|
||||
// removed by construction. Type, ResourceIDs and Settings remain visible.
|
||||
type DataSourceConfigDTO struct {
|
||||
Type string `json:"type"`
|
||||
ResourceIDs []string `json:"resource_ids,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// NewDataSourceResponse converts a stored entity into its response shape.
|
||||
// Returns nil for nil input.
|
||||
func NewDataSourceResponse(ds *types.DataSource) *DataSourceResponse {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
var cfgDTO *DataSourceConfigDTO
|
||||
configured := false
|
||||
if parsed, err := ds.ParseConfig(); err == nil && parsed != nil {
|
||||
cfgDTO = &DataSourceConfigDTO{
|
||||
Type: parsed.Type,
|
||||
ResourceIDs: parsed.ResourceIDs,
|
||||
Settings: parsed.Settings,
|
||||
}
|
||||
enrichRSSFeedURLsInSettings(ds.Type, parsed, cfgDTO)
|
||||
configured = parsed.HasConfiguredCredentials(ds.Type)
|
||||
}
|
||||
return &DataSourceResponse{
|
||||
ID: ds.ID,
|
||||
TenantID: ds.TenantID,
|
||||
KnowledgeBaseID: ds.KnowledgeBaseID,
|
||||
Name: ds.Name,
|
||||
Type: ds.Type,
|
||||
Config: cfgDTO,
|
||||
SyncSchedule: ds.SyncSchedule,
|
||||
SyncMode: ds.SyncMode,
|
||||
Status: ds.Status,
|
||||
ConflictStrategy: ds.ConflictStrategy,
|
||||
SyncDeletions: ds.SyncDeletions,
|
||||
LastSyncAt: ds.LastSyncAt,
|
||||
LastSyncCursor: json.RawMessage(ds.LastSyncCursor),
|
||||
LastSyncResult: json.RawMessage(ds.LastSyncResult),
|
||||
ErrorMessage: ds.ErrorMessage,
|
||||
SyncLogRetentionDays: ds.SyncLogRetentionDays,
|
||||
CreatedAt: ds.CreatedAt,
|
||||
UpdatedAt: ds.UpdatedAt,
|
||||
TotalItemsSynced: ds.TotalItemsSynced,
|
||||
LatestSyncLog: ds.LatestSyncLog,
|
||||
Credentials: map[string]CredentialFieldMetadata{
|
||||
"credentials": {Configured: configured},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichRSSFeedURLsInSettings copies feed_urls from credentials into settings
|
||||
// for API responses. Feed URLs are not secrets but may still live in the
|
||||
// encrypted credentials blob on rows created before they moved to settings.
|
||||
func enrichRSSFeedURLsInSettings(dsType string, parsed *types.DataSourceConfig, cfgDTO *DataSourceConfigDTO) {
|
||||
if dsType != types.ConnectorTypeRSS || parsed == nil || cfgDTO == nil {
|
||||
return
|
||||
}
|
||||
if cfgDTO.Settings != nil {
|
||||
if v, ok := cfgDTO.Settings["feed_urls"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
raw, ok := parsed.Credentials["feed_urls"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
feedURLs, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(feedURLs) == "" {
|
||||
return
|
||||
}
|
||||
if cfgDTO.Settings == nil {
|
||||
cfgDTO.Settings = make(map[string]interface{})
|
||||
}
|
||||
cfgDTO.Settings["feed_urls"] = feedURLs
|
||||
}
|
||||
|
||||
func NewDataSourceResponses(dss []*types.DataSource) []*DataSourceResponse {
|
||||
out := make([]*DataSourceResponse, 0, len(dss))
|
||||
for _, d := range dss {
|
||||
out = append(out, NewDataSourceResponse(d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDataSourceResponse_OmitsCredentials(t *testing.T) {
|
||||
cfg := types.DataSourceConfig{
|
||||
Type: "github",
|
||||
Credentials: map[string]interface{}{
|
||||
"token": "ghp-secret-do-not-leak",
|
||||
},
|
||||
ResourceIDs: []string{"repo-1"},
|
||||
Settings: map[string]interface{}{"branch": "main"},
|
||||
}
|
||||
blob, _ := cfg.ToJSON()
|
||||
ds := &types.DataSource{
|
||||
ID: "ds-1",
|
||||
Name: "github-prod",
|
||||
Type: "github",
|
||||
Config: blob,
|
||||
}
|
||||
body, err := json.Marshal(NewDataSourceResponse(ds))
|
||||
assert.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "ghp-secret-do-not-leak")
|
||||
// The inner config object must not carry the credentials map (the
|
||||
// DataSourceConfigDTO type omits it structurally).
|
||||
var raw map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(body, &raw))
|
||||
if cfgRaw, ok := raw["config"]; ok {
|
||||
var inner map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(cfgRaw, &inner))
|
||||
_, hasCredsInConfig := inner["credentials"]
|
||||
assert.False(t, hasCredsInConfig,
|
||||
"credentials map must not appear inside the config DTO")
|
||||
}
|
||||
// Top-level credentials map is just the "configured?" indicator,
|
||||
// replaces the removed GET /credentials endpoint.
|
||||
assert.Contains(t, s, `"credentials":{"credentials":{"configured":true}}`)
|
||||
// Non-secret config fields pass through.
|
||||
assert.Contains(t, s, "repo-1")
|
||||
assert.Contains(t, s, "branch")
|
||||
assert.Contains(t, s, "main")
|
||||
}
|
||||
|
||||
func TestDataSourceResponse_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, NewDataSourceResponse(nil))
|
||||
assert.Equal(t, []*DataSourceResponse{}, NewDataSourceResponses(nil))
|
||||
}
|
||||
|
||||
func TestDataSourceResponse_RSSFeedURLsFromCredentials(t *testing.T) {
|
||||
cfg := types.DataSourceConfig{
|
||||
Type: types.ConnectorTypeRSS,
|
||||
Credentials: map[string]interface{}{
|
||||
"feed_urls": "https://example.com/a.xml\nhttps://example.com/b.xml",
|
||||
},
|
||||
ResourceIDs: []string{"https://example.com/a.xml"},
|
||||
}
|
||||
blob, _ := cfg.ToJSON()
|
||||
ds := &types.DataSource{
|
||||
ID: "ds-rss",
|
||||
Name: "my-rss",
|
||||
Type: types.ConnectorTypeRSS,
|
||||
Config: blob,
|
||||
}
|
||||
resp := NewDataSourceResponse(ds)
|
||||
assert.NotNil(t, resp.Config)
|
||||
assert.Equal(t, "https://example.com/a.xml\nhttps://example.com/b.xml", resp.Config.Settings["feed_urls"])
|
||||
assert.False(t, resp.Credentials["credentials"].Configured)
|
||||
body, err := json.Marshal(resp)
|
||||
assert.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.Contains(t, s, "https://example.com/a.xml")
|
||||
assert.NotContains(t, s, "Bearer secret")
|
||||
}
|
||||
|
||||
func TestDataSourceResponse_RSSAuthHeadersConfigured(t *testing.T) {
|
||||
cfg := types.DataSourceConfig{
|
||||
Type: types.ConnectorTypeRSS,
|
||||
Credentials: map[string]interface{}{
|
||||
"auth_headers": "Authorization: Bearer secret",
|
||||
},
|
||||
Settings: map[string]interface{}{
|
||||
"feed_urls": "https://example.com/feed.xml",
|
||||
},
|
||||
}
|
||||
blob, _ := cfg.ToJSON()
|
||||
ds := &types.DataSource{
|
||||
ID: "ds-rss-auth",
|
||||
Name: "my-rss",
|
||||
Type: types.ConnectorTypeRSS,
|
||||
Config: blob,
|
||||
}
|
||||
resp := NewDataSourceResponse(ds)
|
||||
assert.True(t, resp.Credentials["credentials"].Configured)
|
||||
body, err := json.Marshal(resp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, string(body), "Bearer secret")
|
||||
}
|
||||
|
||||
func TestDataSourceResponse_NoConfig(t *testing.T) {
|
||||
ds := &types.DataSource{ID: "x", Name: "x"}
|
||||
body, err := json.Marshal(NewDataSourceResponse(ds))
|
||||
assert.NoError(t, err)
|
||||
// No config jsonb stored → no config object in the response.
|
||||
assert.NotContains(t, string(body), `"config":`)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package dto holds response shapes for handler responses that need to differ
|
||||
// from the persisted GORM model — most notably, response types that must NOT
|
||||
// carry secret fields.
|
||||
//
|
||||
// Why a separate package: response DTOs are deliberately distinct from the
|
||||
// internal model so the "no secret in responses" guarantee is a compile-time
|
||||
// invariant rather than a runtime redaction step. If a future contributor
|
||||
// wants to expose a credential in a response, they must add it to the DTO
|
||||
// explicitly, which makes the leak surface review-able in a single diff.
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// MCPServiceResponse mirrors types.MCPService for response bodies, omitting
|
||||
// every secret field (api_key, token). Credential presence/absence is exposed
|
||||
// separately via the /credentials subresource endpoint; this shape carries
|
||||
// only a boolean per credential field so the frontend can render a
|
||||
// "configured / not configured" badge without an additional round-trip.
|
||||
type MCPServiceResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TransportType types.MCPTransportType `json:"transport_type"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Headers types.MCPHeaders `json:"headers,omitempty"`
|
||||
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
|
||||
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
|
||||
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
|
||||
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Credentials is the per-field "configured?" map. Embedded on the main
|
||||
// response so the credential UI doesn't need a follow-up GET. The
|
||||
// frontend never sees the actual secret value — only whether one is
|
||||
// stored. Omitted entirely for builtin services (they can't have
|
||||
// per-tenant credentials).
|
||||
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
|
||||
}
|
||||
|
||||
// MCPAuthConfigResponse intentionally has no APIKey or Token fields. Their
|
||||
// presence is signalled via MCPServiceResponse.Credentials. AuthType, Scopes
|
||||
// and AuthServerMetadataURL are non-secret OAuth configuration and are safe to
|
||||
// echo back so the UI can render the current strategy.
|
||||
type MCPAuthConfigResponse struct {
|
||||
AuthType types.MCPAuthType `json:"auth_type,omitempty"`
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialFieldMetadata reports whether a credential field has a value
|
||||
// stored server-side, without exposing the value itself.
|
||||
type CredentialFieldMetadata struct {
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
// NewMCPServiceResponse converts a stored MCPService into its response shape.
|
||||
//
|
||||
// Builtin MCP services have their tenant-specific transport details (URL,
|
||||
// Headers, EnvVars, StdioConfig) stripped — these reveal how the tenant
|
||||
// configured an upstream provider and must not be visible to other tenants
|
||||
// that see the same builtin row via the cross-tenant list.
|
||||
func NewMCPServiceResponse(ctx context.Context, svc *types.MCPService) *MCPServiceResponse {
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
includeDetail := CanViewIntegrationSecrets(ctx)
|
||||
resp := &MCPServiceResponse{
|
||||
ID: svc.ID,
|
||||
TenantID: svc.TenantID,
|
||||
Name: svc.Name,
|
||||
Description: svc.Description,
|
||||
Enabled: svc.Enabled,
|
||||
TransportType: svc.TransportType,
|
||||
URL: svc.URL,
|
||||
Headers: svc.Headers,
|
||||
AdvancedConfig: svc.AdvancedConfig,
|
||||
StdioConfig: svc.StdioConfig,
|
||||
EnvVars: svc.EnvVars,
|
||||
IsBuiltin: svc.IsBuiltin,
|
||||
CreatedAt: svc.CreatedAt,
|
||||
UpdatedAt: svc.UpdatedAt,
|
||||
}
|
||||
if !includeDetail {
|
||||
resp.Headers = nil
|
||||
resp.EnvVars = nil
|
||||
resp.URL = nil
|
||||
resp.StdioConfig = nil
|
||||
resp.AdvancedConfig = nil
|
||||
}
|
||||
if svc.AuthConfig != nil {
|
||||
auth := &MCPAuthConfigResponse{
|
||||
AuthType: svc.AuthConfig.AuthType,
|
||||
APIKeyHeader: svc.AuthConfig.APIKeyHeader,
|
||||
CustomHeaders: svc.AuthConfig.CustomHeaders,
|
||||
Scopes: svc.AuthConfig.Scopes,
|
||||
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
|
||||
}
|
||||
if !includeDetail {
|
||||
auth.CustomHeaders = nil
|
||||
}
|
||||
resp.AuthConfig = auth
|
||||
}
|
||||
if svc.IsBuiltin {
|
||||
// Builtin services are shared across tenants — strip everything that
|
||||
// could leak how this tenant configured the underlying provider.
|
||||
resp.URL = nil
|
||||
resp.Headers = nil
|
||||
resp.EnvVars = nil
|
||||
resp.StdioConfig = nil
|
||||
resp.AuthConfig = nil
|
||||
} else {
|
||||
resp.Credentials = map[string]CredentialFieldMetadata{
|
||||
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
|
||||
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// NewMCPServiceResponses is the slice convenience wrapper used by ListMCPServices.
|
||||
func NewMCPServiceResponses(ctx context.Context, svcs []*types.MCPService) []*MCPServiceResponse {
|
||||
out := make([]*MCPServiceResponse, 0, len(svcs))
|
||||
for _, s := range svcs {
|
||||
out = append(out, NewMCPServiceResponse(ctx, s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CredentialsResponse is the shared shape returned by PUT
|
||||
// /{resource}/{id}/credentials. Keyed by field name (e.g. "api_key",
|
||||
// "token"). The frontend uses this to update its in-memory metadata after a
|
||||
// successful save without needing to re-fetch the whole resource.
|
||||
type CredentialsResponse struct {
|
||||
Fields map[string]CredentialFieldMetadata `json:"fields"`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The most important guarantee of the DTO layer is structural: the serialized
|
||||
// response body must NEVER contain api_key or token under any condition,
|
||||
// regardless of the underlying entity's state. We assert this via the
|
||||
// serialized JSON (not just struct shape) because reflection-based
|
||||
// custom-marshalers anywhere downstream could otherwise reintroduce a leak.
|
||||
|
||||
func TestMCPServiceResponse_OmitsSecrets(t *testing.T) {
|
||||
svc := &types.MCPService{
|
||||
ID: "svc-1",
|
||||
Name: "svc",
|
||||
AuthConfig: &types.MCPAuthConfig{
|
||||
APIKey: "sk-real-api-key-do-not-leak",
|
||||
Token: "tok-real-bearer-token-do-not-leak",
|
||||
CustomHeaders: map[string]string{"X-Trace": "abc"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(NewMCPServiceResponse(adminContext(), svc))
|
||||
assert.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "sk-real-api-key-do-not-leak",
|
||||
"raw api_key must never appear in MCPServiceResponse")
|
||||
assert.NotContains(t, s, "tok-real-bearer-token-do-not-leak",
|
||||
"raw token must never appear in MCPServiceResponse")
|
||||
// auth_config no longer has api_key/token fields (only custom_headers
|
||||
// survived). Verify the auth_config sub-object contains no secret keys.
|
||||
var raw map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(body, &raw))
|
||||
if ac, ok := raw["auth_config"]; ok {
|
||||
acStr := string(ac)
|
||||
assert.NotContains(t, acStr, `"api_key"`)
|
||||
assert.NotContains(t, acStr, `"token"`)
|
||||
}
|
||||
// The new credentials map exposes "configured?" booleans by design
|
||||
// (replaces the standalone GET /credentials endpoint). Verify the
|
||||
// values are booleans, not strings.
|
||||
assert.Contains(t, s, `"credentials"`)
|
||||
assert.Contains(t, s, `"api_key":{"configured":true}`)
|
||||
assert.Contains(t, s, `"token":{"configured":true}`)
|
||||
// CustomHeaders is structural metadata and SHOULD pass through.
|
||||
assert.Contains(t, s, `"custom_headers"`)
|
||||
assert.Contains(t, s, `"X-Trace"`)
|
||||
}
|
||||
|
||||
func TestMCPServiceResponse_BuiltinStripsTenantConfig(t *testing.T) {
|
||||
url := "https://tenant-private.example.com"
|
||||
svc := &types.MCPService{
|
||||
ID: "builtin-1",
|
||||
IsBuiltin: true,
|
||||
URL: &url,
|
||||
Headers: types.MCPHeaders{"X-Tenant-Secret": "shhh"},
|
||||
AuthConfig: &types.MCPAuthConfig{
|
||||
APIKey: "should-not-leak-via-builtin",
|
||||
},
|
||||
}
|
||||
resp := NewMCPServiceResponse(adminContext(), svc)
|
||||
assert.Nil(t, resp.URL, "builtin must not leak per-tenant URL")
|
||||
assert.Nil(t, resp.Headers, "builtin must not leak per-tenant headers")
|
||||
assert.Nil(t, resp.AuthConfig, "builtin must not leak auth config")
|
||||
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.False(t, strings.Contains(string(body), "should-not-leak-via-builtin"))
|
||||
assert.False(t, strings.Contains(string(body), "X-Tenant-Secret"))
|
||||
}
|
||||
|
||||
func TestMCPServiceResponse_ViewerStripsIntegrationDetail(t *testing.T) {
|
||||
url := "https://tenant-private.example.com"
|
||||
svc := &types.MCPService{
|
||||
ID: "svc-2",
|
||||
URL: &url,
|
||||
Headers: types.MCPHeaders{"Authorization": "Bearer secret"},
|
||||
EnvVars: types.MCPEnvVars{"TOKEN": "secret"},
|
||||
StdioConfig: &types.MCPStdioConfig{
|
||||
Command: "npx",
|
||||
Args: []string{"-y", "mcp-server"},
|
||||
},
|
||||
AdvancedConfig: &types.MCPAdvancedConfig{},
|
||||
AuthConfig: &types.MCPAuthConfig{
|
||||
CustomHeaders: map[string]string{"X-Auth": "secret"},
|
||||
},
|
||||
}
|
||||
resp := NewMCPServiceResponse(viewerContext(), svc)
|
||||
assert.Nil(t, resp.URL)
|
||||
assert.Nil(t, resp.Headers)
|
||||
assert.Nil(t, resp.EnvVars)
|
||||
assert.Nil(t, resp.StdioConfig)
|
||||
assert.Nil(t, resp.AdvancedConfig)
|
||||
assert.NotNil(t, resp.AuthConfig)
|
||||
assert.Nil(t, resp.AuthConfig.CustomHeaders)
|
||||
}
|
||||
|
||||
func TestMCPServiceResponse_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, NewMCPServiceResponse(adminContext(), nil))
|
||||
assert.Equal(t, []*MCPServiceResponse{}, NewMCPServiceResponses(adminContext(), nil))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// ModelResponse mirrors types.Model for response bodies, with all secret
|
||||
// fields (APIKey, AppSecret) removed by construction. Credential presence
|
||||
// metadata lives behind the /credentials subresource, not inlined here.
|
||||
//
|
||||
// BaseURL is preserved for tenant-owned models (the frontend needs it to
|
||||
// render which endpoint a custom model points at). For builtin models it is
|
||||
// stripped along with every other field that could leak how a particular
|
||||
// tenant configured the upstream provider.
|
||||
type ModelResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Type types.ModelType `json:"type"`
|
||||
Source types.ModelSource `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Parameters ModelParametersDTO `json:"parameters"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
Status types.ModelStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Per-field "configured?" map. Omitted for builtin models unless the
|
||||
// caller is a system administrator. See MCPServiceResponse.Credentials.
|
||||
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
|
||||
}
|
||||
|
||||
// ModelParametersDTO carries every parameter field EXCEPT the two secret
|
||||
// ones (APIKey, AppSecret). AppID is non-secret and stays — it's an account
|
||||
// identifier the WeKnora Cloud frontend renders. CustomHeaders is also kept
|
||||
// (structural metadata, not a credential).
|
||||
type ModelParametersDTO struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
InterfaceType string `json:"interface_type"`
|
||||
EmbeddingParameters types.EmbeddingParameters `json:"embedding_parameters"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
Provider string `json:"provider"`
|
||||
ExtraConfig map[string]string `json:"extra_config,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
SupportsVision bool `json:"supports_vision"`
|
||||
ContextWindow int `json:"context_window,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
MaxConcurrency int `json:"max_concurrency,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
}
|
||||
|
||||
// NewModelResponse converts a stored Model into its response shape.
|
||||
//
|
||||
// Builtin models are shared across tenants — strip BaseURL (which can leak
|
||||
// the tenant's private endpoint) and any non-shared parameters.
|
||||
func NewModelResponse(ctx context.Context, m *types.Model) *ModelResponse {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
params := ModelParametersDTO{
|
||||
BaseURL: m.Parameters.BaseURL,
|
||||
InterfaceType: m.Parameters.InterfaceType,
|
||||
EmbeddingParameters: m.Parameters.EmbeddingParameters,
|
||||
ParameterSize: m.Parameters.ParameterSize,
|
||||
Provider: m.Parameters.Provider,
|
||||
ExtraConfig: m.Parameters.ExtraConfig,
|
||||
CustomHeaders: m.Parameters.CustomHeaders,
|
||||
SupportsVision: m.Parameters.SupportsVision,
|
||||
ContextWindow: m.Parameters.ContextWindow,
|
||||
MaxOutputTokens: m.Parameters.MaxOutputTokens,
|
||||
MaxConcurrency: m.Parameters.MaxConcurrency,
|
||||
AppID: m.Parameters.AppID,
|
||||
}
|
||||
canManageBuiltin := m.IsBuiltin && types.IsSystemAdminFromContext(ctx)
|
||||
if !CanViewIntegrationSecrets(ctx) && !canManageBuiltin {
|
||||
params.ExtraConfig = nil
|
||||
params.CustomHeaders = nil
|
||||
params.BaseURL = ""
|
||||
}
|
||||
if m.IsBuiltin && !canManageBuiltin {
|
||||
// Builtin: strip everything that could reveal per-tenant config.
|
||||
// EmbeddingParameters and ParameterSize / Provider / InterfaceType /
|
||||
// SupportsVision / ContextWindow / MaxOutputTokens are intentionally
|
||||
// preserved (they describe the capability surface, not the configured
|
||||
// endpoint).
|
||||
params.BaseURL = ""
|
||||
params.ExtraConfig = nil
|
||||
params.CustomHeaders = nil
|
||||
params.AppID = ""
|
||||
}
|
||||
var creds map[string]CredentialFieldMetadata
|
||||
if !m.IsBuiltin || canManageBuiltin {
|
||||
creds = map[string]CredentialFieldMetadata{
|
||||
"api_key": {Configured: m.Parameters.APIKey != ""},
|
||||
"app_secret": {Configured: m.Parameters.AppSecret != ""},
|
||||
}
|
||||
}
|
||||
return &ModelResponse{
|
||||
ID: m.ID,
|
||||
TenantID: m.TenantID,
|
||||
Name: m.Name,
|
||||
DisplayName: m.DisplayName,
|
||||
Type: m.Type,
|
||||
Source: m.Source,
|
||||
Description: m.Description,
|
||||
Parameters: params,
|
||||
IsDefault: m.IsDefault,
|
||||
IsBuiltin: m.IsBuiltin,
|
||||
Status: m.Status,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
Credentials: creds,
|
||||
}
|
||||
}
|
||||
|
||||
// NewModelResponses is the slice convenience wrapper.
|
||||
func NewModelResponses(ctx context.Context, models []*types.Model) []*ModelResponse {
|
||||
out := make([]*ModelResponse, 0, len(models))
|
||||
for _, m := range models {
|
||||
out = append(out, NewModelResponse(ctx, m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestModelResponse_OmitsSecrets(t *testing.T) {
|
||||
m := &types.Model{
|
||||
ID: "m-1",
|
||||
Name: "gpt-x",
|
||||
DisplayName: "Support QA",
|
||||
Parameters: types.ModelParameters{
|
||||
APIKey: "sk-real-api-key-do-not-leak",
|
||||
AppSecret: "app-real-secret-do-not-leak",
|
||||
AppID: "appid-public-ok-to-show",
|
||||
BaseURL: "https://api.example.com",
|
||||
Provider: "openai",
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(NewModelResponse(adminContext(), m))
|
||||
assert.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "sk-real-api-key-do-not-leak")
|
||||
assert.NotContains(t, s, "app-real-secret-do-not-leak")
|
||||
// Parameters sub-object must contain no secret keys.
|
||||
var raw map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(body, &raw))
|
||||
params := string(raw["parameters"])
|
||||
assert.NotContains(t, params, `"api_key"`)
|
||||
assert.NotContains(t, params, `"app_secret"`)
|
||||
// Credential metadata map exposes booleans only.
|
||||
assert.Contains(t, s, `"credentials"`)
|
||||
assert.Contains(t, s, `"api_key":{"configured":true}`)
|
||||
assert.Contains(t, s, `"app_secret":{"configured":true}`)
|
||||
// Non-secret fields pass through.
|
||||
assert.Contains(t, s, "appid-public-ok-to-show")
|
||||
assert.Contains(t, s, "api.example.com")
|
||||
assert.Contains(t, s, `"display_name":"Support QA"`)
|
||||
}
|
||||
|
||||
func TestModelResponse_BuiltinStripsTenantConfig(t *testing.T) {
|
||||
m := &types.Model{
|
||||
ID: "builtin-1",
|
||||
IsBuiltin: true,
|
||||
Parameters: types.ModelParameters{
|
||||
BaseURL: "https://tenant-private.example.com",
|
||||
APIKey: "should-not-leak",
|
||||
AppID: "tenant-app-id",
|
||||
SupportsVision: true,
|
||||
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
|
||||
},
|
||||
}
|
||||
resp := NewModelResponse(adminContext(), m)
|
||||
assert.Empty(t, resp.Parameters.BaseURL,
|
||||
"builtin must not leak per-tenant base URL")
|
||||
assert.Empty(t, resp.Parameters.AppID,
|
||||
"builtin must not leak per-tenant app_id")
|
||||
assert.Nil(t, resp.Parameters.ExtraConfig,
|
||||
"builtin must not leak per-tenant extra_config")
|
||||
assert.True(t, resp.Parameters.SupportsVision,
|
||||
"capability metadata must survive (not per-tenant)")
|
||||
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.False(t, strings.Contains(string(body), "should-not-leak"))
|
||||
assert.False(t, strings.Contains(string(body), "tenant-private.example.com"))
|
||||
}
|
||||
|
||||
func TestModelResponse_SystemAdminCanManageBuiltinConfig(t *testing.T) {
|
||||
ctx := context.WithValue(viewerContext(), types.SystemAdminContextKey, true)
|
||||
m := &types.Model{
|
||||
ID: "builtin-1",
|
||||
IsBuiltin: true,
|
||||
Parameters: types.ModelParameters{
|
||||
BaseURL: "https://global-provider.example.com",
|
||||
APIKey: "should-never-be-returned",
|
||||
AppSecret: "also-never-returned",
|
||||
AppID: "global-app-id",
|
||||
ExtraConfig: map[string]string{"region": "ap-guangzhou"},
|
||||
CustomHeaders: map[string]string{"X-Route": "global"},
|
||||
},
|
||||
}
|
||||
|
||||
resp := NewModelResponse(ctx, m)
|
||||
assert.Equal(t, "https://global-provider.example.com", resp.Parameters.BaseURL)
|
||||
assert.Equal(t, "global-app-id", resp.Parameters.AppID)
|
||||
assert.Equal(t, map[string]string{"region": "ap-guangzhou"}, resp.Parameters.ExtraConfig)
|
||||
assert.Equal(t, map[string]string{"X-Route": "global"}, resp.Parameters.CustomHeaders)
|
||||
assert.True(t, resp.Credentials["api_key"].Configured)
|
||||
assert.True(t, resp.Credentials["app_secret"].Configured)
|
||||
|
||||
body, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(body), "should-never-be-returned")
|
||||
assert.NotContains(t, string(body), "also-never-returned")
|
||||
}
|
||||
|
||||
func TestModelResponse_ViewerStripsIntegrationDetail(t *testing.T) {
|
||||
m := &types.Model{
|
||||
ID: "m-2",
|
||||
Parameters: types.ModelParameters{
|
||||
BaseURL: "https://tenant-private.example.com",
|
||||
CustomHeaders: map[string]string{"Authorization": "Bearer secret"},
|
||||
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
|
||||
},
|
||||
}
|
||||
resp := NewModelResponse(viewerContext(), m)
|
||||
assert.Empty(t, resp.Parameters.BaseURL)
|
||||
assert.Nil(t, resp.Parameters.CustomHeaders)
|
||||
assert.Nil(t, resp.Parameters.ExtraConfig)
|
||||
}
|
||||
|
||||
func TestModelResponse_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, NewModelResponse(adminContext(), nil))
|
||||
assert.Equal(t, []*ModelResponse{}, NewModelResponses(adminContext(), nil))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// RoleFromContext returns the caller's tenant role from ctx.
|
||||
func RoleFromContext(ctx context.Context) types.TenantRole {
|
||||
return types.TenantRoleFromContext(ctx)
|
||||
}
|
||||
|
||||
// CanViewIntegrationSecrets is true for Admin+ tenant members and for API keys
|
||||
// with full tenant access or the manage_tenant_settings capability.
|
||||
func CanViewIntegrationSecrets(ctx context.Context) bool {
|
||||
if RoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
|
||||
return true
|
||||
}
|
||||
return apiKeyCanManageIntegrationSecrets(ctx)
|
||||
}
|
||||
|
||||
func apiKeyCanManageIntegrationSecrets(ctx context.Context) bool {
|
||||
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if scope.FullAccess {
|
||||
return true
|
||||
}
|
||||
return scope.HasCapability(types.APIKeyCapabilityManageTenantSettings)
|
||||
}
|
||||
|
||||
// RoleCanViewTenantAPIKey is true for Owner+ only.
|
||||
func RoleCanViewTenantAPIKey(role types.TenantRole) bool {
|
||||
return role.HasPermission(types.TenantRoleOwner)
|
||||
}
|
||||
|
||||
// CanViewTenantAPIKey is true for Owner+ only.
|
||||
func CanViewTenantAPIKey(ctx context.Context) bool {
|
||||
return RoleCanViewTenantAPIKey(RoleFromContext(ctx))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestCanViewIntegrationSecretsAdminRole(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
if !CanViewIntegrationSecrets(ctx) {
|
||||
t.Fatal("admin should view integration secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanViewIntegrationSecretsViewerDenied(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
if CanViewIntegrationSecrets(ctx) {
|
||||
t.Fatal("viewer should not view integration secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanViewIntegrationSecretsScopedAPIKeyWithManageTenantSettings(t *testing.T) {
|
||||
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
||||
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageTenantSettings)},
|
||||
})
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
if !CanViewIntegrationSecrets(ctx) {
|
||||
t.Fatal("manage_tenant_settings API key should view integration secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanViewIntegrationSecretsScopedAPIKeyWithoutCapabilityDenied(t *testing.T) {
|
||||
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
||||
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
|
||||
})
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
if CanViewIntegrationSecrets(ctx) {
|
||||
t.Fatal("chat-only API key should not view integration secrets")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantResponse is the viewer-safe tenant profile shape. Secret-bearing
|
||||
// columns are omitted or redacted unless the caller has Admin+.
|
||||
type TenantResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
RetrieverEngines types.RetrieverEngines `json:"retriever_engines"`
|
||||
Business string `json:"business"`
|
||||
StorageQuota int64 `json:"storage_quota"`
|
||||
StorageUsed int64 `json:"storage_used"`
|
||||
ContextConfig *types.ContextConfig `json:"context_config,omitempty"`
|
||||
WebSearchConfig *types.WebSearchConfig `json:"web_search_config,omitempty"`
|
||||
ParserEngineConfig *types.ParserEngineConfig `json:"parser_engine_config,omitempty"`
|
||||
Credentials *types.CredentialsConfig `json:"credentials,omitempty"`
|
||||
StorageEngineConfig *types.StorageEngineConfig `json:"storage_engine_config,omitempty"`
|
||||
ChatHistoryConfig *types.ChatHistoryConfig `json:"chat_history_config,omitempty"`
|
||||
RetrievalConfig *types.RetrievalConfig `json:"retrieval_config,omitempty"`
|
||||
MemoryConfig *types.MemoryConfig `json:"memory_config,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// NewTenantResponse converts a stored tenant into its HTTP response shape.
|
||||
func NewTenantResponse(ctx context.Context, tenant *types.Tenant) *TenantResponse {
|
||||
return NewTenantResponseWithRole(tenant, RoleFromContext(ctx))
|
||||
}
|
||||
|
||||
// NewTenantResponseWithRole converts a stored tenant using an explicit role
|
||||
// (for auth flows where tenant role is not yet in request context).
|
||||
func NewTenantResponseWithRole(tenant *types.Tenant, role types.TenantRole) *TenantResponse {
|
||||
if tenant == nil {
|
||||
return nil
|
||||
}
|
||||
includeSecrets := role.HasPermission(types.TenantRoleAdmin)
|
||||
resp := &TenantResponse{
|
||||
ID: tenant.ID,
|
||||
Name: tenant.Name,
|
||||
Description: tenant.Description,
|
||||
Status: tenant.Status,
|
||||
RetrieverEngines: tenant.RetrieverEngines,
|
||||
Business: tenant.Business,
|
||||
StorageQuota: tenant.StorageQuota,
|
||||
StorageUsed: tenant.StorageUsed,
|
||||
ContextConfig: tenant.ContextConfig,
|
||||
ChatHistoryConfig: tenant.ChatHistoryConfig,
|
||||
RetrievalConfig: tenant.RetrievalConfig,
|
||||
MemoryConfig: tenant.MemoryConfig,
|
||||
CreatedAt: tenant.CreatedAt,
|
||||
UpdatedAt: tenant.UpdatedAt,
|
||||
DeletedAt: tenant.DeletedAt,
|
||||
}
|
||||
if includeSecrets {
|
||||
resp.WebSearchConfig = types.WebSearchConfigForResponse(tenant.WebSearchConfig, true)
|
||||
resp.ParserEngineConfig = types.ParserEngineConfigForResponse(tenant.ParserEngineConfig, true)
|
||||
resp.Credentials = types.CredentialsConfigForResponse(tenant.Credentials, true)
|
||||
resp.StorageEngineConfig = types.StorageEngineConfigForResponse(tenant.StorageEngineConfig, true)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// NewTenantResponses is the slice convenience wrapper.
|
||||
func NewTenantResponses(ctx context.Context, tenants []*types.Tenant) []*TenantResponse {
|
||||
out := make([]*TenantResponse, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
out = append(out, NewTenantResponse(ctx, t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewTenantResponsesCrossTenant redacts every tenant as Viewer regardless of
|
||||
// the caller's active-tenant role. Used by cross-tenant list/search endpoints
|
||||
// where the caller's home-tenant role must not unlock other tenants' secrets.
|
||||
func NewTenantResponsesCrossTenant(tenants []*types.Tenant) []*TenantResponse {
|
||||
out := make([]*TenantResponse, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
out = append(out, NewTenantResponseWithRole(t, types.TenantRoleViewer))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTenantResponse_ViewerOmitsSecrets(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
body, err := json.Marshal(NewTenantResponse(viewerContext(), tenant))
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "tenant-api-key-123")
|
||||
assert.NotContains(t, s, "legacy-search-secret-999")
|
||||
assert.NotContains(t, s, "wk-app-secret-def")
|
||||
assert.NotContains(t, s, "parser-secret-123")
|
||||
assert.NotContains(t, s, "minio-secret-789")
|
||||
assert.NotContains(t, s, "web_search_config")
|
||||
assert.NotContains(t, s, "parser_engine_config")
|
||||
assert.NotContains(t, s, "storage_engine_config")
|
||||
assert.NotContains(t, s, "credentials")
|
||||
}
|
||||
|
||||
func TestTenantResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
body, err := json.Marshal(NewTenantResponse(ownerContext(), tenant))
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, `"api_key"`)
|
||||
assert.NotContains(t, s, "legacy-search-secret-999")
|
||||
assert.NotContains(t, s, "parser-secret-123")
|
||||
assert.Contains(t, s, "web_search_config")
|
||||
}
|
||||
|
||||
func TestTenantResponse_AdminGetsRedactedIntegrationConfigs(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
resp := NewTenantResponse(adminContext(), tenant)
|
||||
require.NotNil(t, resp.WebSearchConfig)
|
||||
assert.Equal(t, types.RedactedSecretPlaceholder, resp.WebSearchConfig.ProxyURL)
|
||||
assert.Empty(t, resp.WebSearchConfig.APIKey)
|
||||
require.NotNil(t, resp.ParserEngineConfig)
|
||||
assert.Equal(t, types.RedactedSecretPlaceholder, resp.ParserEngineConfig.MinerUAPIKey)
|
||||
require.NotNil(t, resp.StorageEngineConfig.MinIO)
|
||||
assert.Equal(t, types.RedactedSecretPlaceholder, resp.StorageEngineConfig.MinIO.SecretAccessKey)
|
||||
}
|
||||
|
||||
func TestTenantResponsesCrossTenant_RedactsEvenForOwnerContext(t *testing.T) {
|
||||
tenant := sampleSecretTenant()
|
||||
body, err := json.Marshal(NewTenantResponsesCrossTenant([]*types.Tenant{tenant}))
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "tenant-api-key-123")
|
||||
assert.NotContains(t, s, "parser-secret-123")
|
||||
}
|
||||
|
||||
func sampleSecretTenant() *types.Tenant {
|
||||
return &types.Tenant{
|
||||
ID: 42,
|
||||
Name: "tenant",
|
||||
WebSearchConfig: &types.WebSearchConfig{
|
||||
APIKey: "legacy-search-secret-999",
|
||||
ProxyURL: "http://proxy.internal:8080",
|
||||
},
|
||||
Credentials: &types.CredentialsConfig{
|
||||
WeKnoraCloud: &types.WeKnoraCloudCredentials{
|
||||
AppID: "wk-app-id-abc",
|
||||
AppSecret: "wk-app-secret-def",
|
||||
},
|
||||
},
|
||||
ParserEngineConfig: &types.ParserEngineConfig{
|
||||
MinerUAPIKey: "parser-secret-123",
|
||||
PaddleOCRVLCloudToken: "paddle-secret-456",
|
||||
},
|
||||
StorageEngineConfig: &types.StorageEngineConfig{
|
||||
DefaultProvider: "minio",
|
||||
MinIO: &types.MinIOEngineConfig{
|
||||
AccessKeyID: "minio-access-id",
|
||||
SecretAccessKey: "minio-secret-789",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func adminContext() context.Context {
|
||||
ctx := context.Background()
|
||||
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
}
|
||||
|
||||
func viewerContext() context.Context {
|
||||
ctx := context.Background()
|
||||
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
}
|
||||
|
||||
func ownerContext() context.Context {
|
||||
ctx := context.Background()
|
||||
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleOwner)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// WebSearchProviderResponse mirrors types.WebSearchProviderEntity for
|
||||
// response bodies, with the APIKey field removed by construction. Credential
|
||||
// presence is exposed via the /credentials subresource.
|
||||
type WebSearchProviderResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Provider types.WebSearchProviderType `json:"provider"`
|
||||
Description string `json:"description"`
|
||||
Parameters WebSearchProviderParametersDTO `json:"parameters"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Per-field "configured?" map. See MCPServiceResponse.Credentials.
|
||||
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchProviderParametersDTO holds every parameter field except APIKey.
|
||||
// EngineID, BaseURL, ProxyURL and ExtraConfig are not secrets (they describe
|
||||
// where to send the request, not how to authenticate) and remain visible.
|
||||
type WebSearchProviderParametersDTO struct {
|
||||
EngineID string `json:"engine_id,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
ExtraConfig map[string]string `json:"extra_config,omitempty"`
|
||||
}
|
||||
|
||||
// NewWebSearchProviderResponse converts a stored entity into its response shape.
|
||||
func NewWebSearchProviderResponse(ctx context.Context, e *types.WebSearchProviderEntity) *WebSearchProviderResponse {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
params := WebSearchProviderParametersDTO{
|
||||
EngineID: e.Parameters.EngineID,
|
||||
BaseURL: e.Parameters.BaseURL,
|
||||
ProxyURL: e.Parameters.ProxyURL,
|
||||
ExtraConfig: e.Parameters.ExtraConfig,
|
||||
}
|
||||
if !CanViewIntegrationSecrets(ctx) {
|
||||
params.ProxyURL = ""
|
||||
params.ExtraConfig = nil
|
||||
}
|
||||
return &WebSearchProviderResponse{
|
||||
ID: e.ID,
|
||||
TenantID: e.TenantID,
|
||||
Name: e.Name,
|
||||
Provider: e.Provider,
|
||||
Description: e.Description,
|
||||
Parameters: params,
|
||||
IsDefault: e.IsDefault,
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
Credentials: map[string]CredentialFieldMetadata{
|
||||
"api_key": {Configured: e.Parameters.APIKey != ""},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebSearchProviderResponses(ctx context.Context, es []*types.WebSearchProviderEntity) []*WebSearchProviderResponse {
|
||||
out := make([]*WebSearchProviderResponse, 0, len(es))
|
||||
for _, e := range es {
|
||||
out = append(out, NewWebSearchProviderResponse(ctx, e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWebSearchProviderResponse_OmitsSecrets(t *testing.T) {
|
||||
e := &types.WebSearchProviderEntity{
|
||||
ID: "wsp-1",
|
||||
Name: "bing prod",
|
||||
Provider: types.WebSearchProviderTypeBing,
|
||||
Parameters: types.WebSearchProviderParameters{
|
||||
APIKey: "bing-secret-do-not-leak",
|
||||
EngineID: "engine-public-id",
|
||||
BaseURL: "https://example.com",
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(NewWebSearchProviderResponse(adminContext(), e))
|
||||
assert.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "bing-secret-do-not-leak")
|
||||
// Parameters sub-object must contain no secret keys.
|
||||
var raw map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(body, &raw))
|
||||
assert.NotContains(t, string(raw["parameters"]), `"api_key"`)
|
||||
// Credential metadata map exposes booleans only.
|
||||
assert.Contains(t, s, `"credentials"`)
|
||||
assert.Contains(t, s, `"api_key":{"configured":true}`)
|
||||
// Non-secret fields pass through.
|
||||
assert.Contains(t, s, "engine-public-id")
|
||||
assert.Contains(t, s, "example.com")
|
||||
}
|
||||
|
||||
func TestWebSearchProviderResponse_ViewerStripsIntegrationDetail(t *testing.T) {
|
||||
e := &types.WebSearchProviderEntity{
|
||||
ID: "wsp-2",
|
||||
Parameters: types.WebSearchProviderParameters{
|
||||
ProxyURL: "http://proxy:8080",
|
||||
ExtraConfig: map[string]string{"token": "secret"},
|
||||
},
|
||||
}
|
||||
resp := NewWebSearchProviderResponse(viewerContext(), e)
|
||||
assert.Empty(t, resp.Parameters.ProxyURL)
|
||||
assert.Nil(t, resp.Parameters.ExtraConfig)
|
||||
}
|
||||
|
||||
func TestWebSearchProviderResponse_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, NewWebSearchProviderResponse(adminContext(), nil))
|
||||
assert.Equal(t, []*WebSearchProviderResponse{}, NewWebSearchProviderResponses(adminContext(), nil))
|
||||
}
|
||||
Reference in New Issue
Block a user