From c5cc7b0ff389ed8d3000c58195ea44ea7693f189 Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Thu, 20 Aug 2026 14:01:41 +0530 Subject: [PATCH 1/6] Refactor API key name handling and validation; enforce length constraints and improve error handling --- platform-api/internal/service/apikey.go | 17 ++++---- .../internal/service/apikey_authz_test.go | 40 +++++++++++++++++- .../service/artifact_dp_apikey_test.go | 38 +++++++++++++++++ platform-api/internal/service/llm_apikey.go | 4 ++ .../internal/service/llm_proxy_apikey.go | 8 ++++ platform-api/internal/utils/handle.go | 20 ++++----- .../appShellPages/proxies/LLMProxyNew.tsx | 18 ++++---- .../proxies/LLMProxyOverviewTab.tsx | 18 ++++---- .../ServiceProviderDeploymentsCard.tsx | 15 +++---- .../ServiceProviderOverviewTab.tsx | 18 ++++---- portals/ai-workspace/src/utils/apiKeyName.ts | 41 +++++++++++++++++++ 11 files changed, 180 insertions(+), 57 deletions(-) create mode 100644 portals/ai-workspace/src/utils/apiKeyName.ts diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index 47cf7fecdd..6ea835c1d3 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -37,8 +37,8 @@ import ( ) const ( - apiKeyNameMinLength = 3 - apiKeyNameMaxLength = 63 + apiKeyNameMinLength = utils.HandleMinLength + apiKeyNameMaxLength = utils.HandleMaxLength hashingAlgorithmSHA256 = "sha256" defaultHashingAlgorithm = hashingAlgorithmSHA256 ) @@ -253,14 +253,13 @@ func randomHexString(n int) (string, error) { return hex.EncodeToString(bytes)[:n], nil } -// generateAPIKeyName derives a URL-safe, slug-style name from a display name using the -// same algorithm as the gateway controller: +// generateAPIKeyName derives a URL-safe, slug-style name from a display name: // - Lowercase // - Spaces and underscores → hyphens // - Remove all non-[a-z0-9-] characters // - Collapse consecutive hyphens // - Trim leading/trailing hyphens -// - Enforce length [3, 63]; pad with random hex if too short +// - Enforce length [3, 40]; pad with random hex if too short func generateAPIKeyName(displayName string) (string, error) { name := strings.ToLower(strings.TrimSpace(displayName)) name = strings.ReplaceAll(name, " ", "-") @@ -290,8 +289,8 @@ func generateAPIKeyName(displayName string) (string, error) { } // resolveUniqueKeyName uses the caller-supplied name if present, otherwise derives one -// from the display name (or the API handle as a fallback) using the same slug algorithm -// as the gateway controller. Either way, it retries with a short random suffix on collision. +// from the display name (or the API handle as a fallback). Either way, the resolved name +// is validated and it retries with a short random suffix on collision. func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.CreateAPIKeyRequest, apiHandle string) (string, error) { var baseName string if req.Id != nil && strings.TrimSpace(*req.Id) != "" { @@ -317,6 +316,10 @@ func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.Creat } } + if err := utils.ValidateHandle(baseName); err != nil { + return "", err + } + // Check for collision and retry with a short suffix (up to 5 attempts) const maxRetries = 5 name := baseName diff --git a/platform-api/internal/service/apikey_authz_test.go b/platform-api/internal/service/apikey_authz_test.go index 62f1f31ccb..94ebd06ce3 100644 --- a/platform-api/internal/service/apikey_authz_test.go +++ b/platform-api/internal/service/apikey_authz_test.go @@ -17,7 +17,13 @@ package service -import "testing" +import ( + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) // TestCanManageAPIKey pins the ownership rule shared by every API key CRUD path: // the creator always passes, ap:api_key:all:manage (keyAdmin) passes for any key, @@ -49,3 +55,35 @@ func TestCanManageAPIKey(t *testing.T) { }) } } + +// noCollisionAPIKeyRepo reports no name collision, so resolveUniqueKeyName's retry +// loop is never exercised — only the validation gate is under test. +type noCollisionAPIKeyRepo struct { + repository.APIKeyRepository +} + +func (noCollisionAPIKeyRepo) GetByArtifactAndName(string, string) (*model.APIKey, error) { + return nil, nil +} + +// TestResolveUniqueKeyName_RejectsShortId pins issue #3163 for the REST API key path: +// a caller-supplied id under 3 characters must be rejected, while falling back to +// displayName-derived generation is unaffected. +func TestResolveUniqueKeyName_RejectsShortId(t *testing.T) { + svc := &APIKeyService{apiKeyRepo: noCollisionAPIKeyRepo{}} + + shortID := "ab" + _, err := svc.resolveUniqueKeyName("artifact-1", &api.CreateAPIKeyRequest{Id: &shortID}, "my-api") + if err == nil { + t.Fatal("resolveUniqueKeyName() = nil error, want rejection for a 2-char id") + } + assertBadRequest(t, err) + + got, err := svc.resolveUniqueKeyName("artifact-1", &api.CreateAPIKeyRequest{DisplayName: "My Key"}, "my-api") + if err != nil { + t.Fatalf("resolveUniqueKeyName() = %v, want success", err) + } + if got != "my-key" { + t.Fatalf("resolveUniqueKeyName() = %q, want %q", got, "my-key") + } +} diff --git a/platform-api/internal/service/artifact_dp_apikey_test.go b/platform-api/internal/service/artifact_dp_apikey_test.go index 02cda7f72d..d7bb0cc4b3 100644 --- a/platform-api/internal/service/artifact_dp_apikey_test.go +++ b/platform-api/internal/service/artifact_dp_apikey_test.go @@ -223,3 +223,41 @@ func TestCreateLLMProxyAPIKey_AssociationScoped(t *testing.T) { t.Fatalf("expected 0 broadcasts for an unassociated proxy, got %d", len(hub.published)) } } + +// TestCreateLLMProviderAPIKey_RejectsShortId pins issue #3163: a caller-supplied id +// under 3 characters must be rejected here, not silently accepted and left for the +// gateway to reject later. +func TestCreateLLMProviderAPIKey_RejectsShortId(t *testing.T) { + provider := &model.LLMProvider{UUID: "prov-uuid", ID: "prov", OrganizationUUID: "org-1", Name: "Prov", Version: "v1.0"} + providerRepo := &mockLLMProviderRepo{ + getByIDFunc: func(string, string) (*model.LLMProvider, error) { return provider, nil }, + } + svc := NewLLMProviderAPIKeyService(providerRepo, dpKeyAPIRepo{}, &dpCapturingAPIKeyRepo{}, + newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) + + shortID := "ab" + _, err := svc.CreateLLMProviderAPIKey(context.Background(), "prov", "org-1", "", + &api.CreateLLMProviderAPIKeyRequest{DisplayName: "x", Id: &shortID}) + if err == nil { + t.Fatal("CreateLLMProviderAPIKey() = nil error, want rejection for a 2-char id") + } + assertBadRequest(t, err) +} + +// TestCreateLLMProxyAPIKey_RejectsShortId is the LLM-proxy counterpart. +func TestCreateLLMProxyAPIKey_RejectsShortId(t *testing.T) { + proxy := &model.LLMProxy{UUID: "proxy-uuid", ID: "proxy", OrganizationUUID: "org-1", Name: "Proxy", Version: "v1.0"} + proxyRepo := &mockLLMProxyRepo{ + getByIDFunc: func(string, string) (*model.LLMProxy, error) { return proxy, nil }, + } + svc := NewLLMProxyAPIKeyService(proxyRepo, dpKeyAPIRepo{}, &dpCapturingAPIKeyRepo{}, + newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) + + shortID := "ab" + _, err := svc.CreateLLMProxyAPIKey(context.Background(), "proxy", "org-1", "", + &api.CreateLLMProxyAPIKeyRequest{DisplayName: "x", Id: &shortID}) + if err == nil { + t.Fatal("CreateLLMProxyAPIKey() = nil error, want rejection for a 2-char id") + } + assertBadRequest(t, err) +} diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index 618e9b20c4..f8acb57b5d 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -246,6 +246,10 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( return nil, fmt.Errorf("failed to generate API key name: %w", err) } } + if err := utils.ValidateHandle(name); err != nil { + s.slogger.Warn("Invalid API key id for LLM provider API key creation", "providerId", providerID) + return nil, err + } displayName := req.DisplayName if displayName == "" { diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index 0385364db4..40dcc4c141 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -205,12 +205,20 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( if req.Id != nil && *req.Id != "" { name = *req.Id } else { + if req.DisplayName == "" { + return nil, apperror.ValidationFailed.New("Either id or displayName is required."). + WithLogMessage(fmt.Sprintf("cannot generate API key name for proxy %s: both id and displayName are empty", proxyID)) + } name, err = utils.GenerateHandle(req.DisplayName, nil) if err != nil { s.slogger.Error("Failed to generate API key name", "proxyId", proxyID, "error", err) return nil, fmt.Errorf("failed to generate API key name: %w", err) } } + if err := utils.ValidateHandle(name); err != nil { + s.slogger.Warn("Invalid API key id for LLM proxy API key creation", "proxyId", proxyID) + return nil, err + } displayName := req.DisplayName if displayName == "" { diff --git a/platform-api/internal/utils/handle.go b/platform-api/internal/utils/handle.go index c3af27a364..896890e805 100644 --- a/platform-api/internal/utils/handle.go +++ b/platform-api/internal/utils/handle.go @@ -28,8 +28,8 @@ import ( ) const ( - handleMinLength = 3 - handleMaxLength = 40 + HandleMinLength = 3 + HandleMaxLength = 40 maxRetries = 5 suffixLength = 4 ) @@ -74,13 +74,13 @@ func ValidateHandle(handle string) error { if handle == "" { return apperror.ValidationFailed.New("The id cannot be empty.") } - if len(handle) < handleMinLength { + if len(handle) < HandleMinLength { return apperror.ValidationFailed.New( - fmt.Sprintf("The id must be at least %d characters.", handleMinLength)) + fmt.Sprintf("The id must be at least %d characters.", HandleMinLength)) } - if len(handle) > handleMaxLength { + if len(handle) > HandleMaxLength { return apperror.ValidationFailed.New( - fmt.Sprintf("The id must be at most %d characters.", handleMaxLength)) + fmt.Sprintf("The id must be at most %d characters.", HandleMaxLength)) } if !validHandleRegex.MatchString(handle) { return apperror.ValidationFailed.New("The id must be lowercase alphanumeric with hyphens only " + @@ -125,7 +125,7 @@ func GenerateHandle(source string, existsCheck func(string) bool) (string, error candidateHandle := handle // Ensure we don't exceed max length when adding suffix - maxBaseLength := handleMaxLength - suffixLength - 1 // -1 for the hyphen + maxBaseLength := HandleMaxLength - suffixLength - 1 // -1 for the hyphen if len(candidateHandle) > maxBaseLength { candidateHandle = candidateHandle[:maxBaseLength] @@ -163,14 +163,14 @@ func sanitizeToHandle(s string) string { handle = strings.Trim(handle, "-") // Enforce length limits - if len(handle) > handleMaxLength { - handle = handle[:handleMaxLength] + if len(handle) > HandleMaxLength { + handle = handle[:HandleMaxLength] // Trim trailing hyphen if truncation created one handle = strings.TrimRight(handle, "-") } // If handle is too short after sanitization, pad with random suffix - if len(handle) < handleMinLength { + if len(handle) < HandleMinLength { if handle == "" { handle = generateRandomSuffix() + generateRandomSuffix() } else { diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx index d04cff06a4..77f906fea8 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx @@ -72,6 +72,7 @@ import { logger } from '../../../../utils/logger'; import { getErrorMessage, getFieldErrors } from '../../../../utils/apiError'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; +import { buildApiKeyResourceName, validateApiKeyName } from '../../../../utils/apiKeyName'; type FormState = { name: string; @@ -100,15 +101,6 @@ const toProxyId = (name: string): string => .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); -const buildApiKeyResourceName = (displayName: string): string => { - const normalizedDisplayName = displayName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalizedDisplayName || 'api-key'; -}; - type LLMProxyNewContentProps = { selectedProviderId: string; onSelectedProviderIdChange: (providerId: string) => void; @@ -538,6 +530,12 @@ function LLMProxyNewContent({ setApiKeyError('Display name is required.'); return; } + const keyName = buildApiKeyResourceName(trimmedDisplayName); + const nameError = validateApiKeyName(keyName); + if (nameError) { + setApiKeyError(nameError); + return; + } try { setIsGeneratingApiKey(true); @@ -550,7 +548,7 @@ function LLMProxyNewContent({ formState.providerId, currentOrganization.uuid, { - id: buildApiKeyResourceName(trimmedDisplayName), + id: keyName, displayName: trimmedDisplayName, expiresAt: expiresAt.toISOString(), issuer: 'api-platform-ai-workspace', diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx index 95be214ebd..11d2e057b0 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx @@ -63,6 +63,7 @@ import { formatPrefixedKey, resolveApiKeyAuthDisplay, } from '../../../../utils/apiKeyAuthDisplay'; +import { buildApiKeyResourceName, validateApiKeyName } from '../../../../utils/apiKeyName'; type OpenApiSpec = Record; @@ -93,15 +94,6 @@ function formatDate(value?: string): string { return date.toLocaleDateString(); } -function buildApiKeyResourceName(displayName: string): string { - const normalizedDisplayName = displayName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalizedDisplayName || 'api-key'; -} - export default function LLMProxyOverviewTab() { const { currentOrganization } = useAppShell(); const { proxy, getProxyAPIKeys, createProxyAPIKey, deleteProxyAPIKey } = @@ -400,6 +392,12 @@ export default function LLMProxyOverviewTab() { setKeyError('Display name is required.'); return; } + const keyName = buildApiKeyResourceName(trimmedDisplayName); + const nameError = validateApiKeyName(keyName); + if (nameError) { + setKeyError(nameError); + return; + } try { setGeneratingKey(true); @@ -409,7 +407,7 @@ export default function LLMProxyOverviewTab() { expiresAt.setDate(expiresAt.getDate() + 90); const response = await createProxyAPIKey({ - id: buildApiKeyResourceName(trimmedDisplayName), + id: keyName, displayName: apiKeyDisplayName, expiresAt: expiresAt.toISOString(), issuer: 'api-platform-ai-workspace', diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx index 1c09416e2a..d12cca8667 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx @@ -61,6 +61,7 @@ import { DisabledActionTooltip, GATEWAY_MANAGED_ARTIFACT_TOOLTIP, } from '../../../../utils/readOnlyArtifacts'; +import { buildApiKeyResourceName, validateApiKeyName } from '../../../../utils/apiKeyName'; type ServiceProviderDeploymentsCardProps = { isGatewaysLoading: boolean; @@ -80,15 +81,6 @@ function formatDate(value?: string): string { return date.toLocaleDateString(); } -function buildApiKeyResourceName(displayName: string): string { - const normalizedDisplayName = displayName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalizedDisplayName || 'api-key'; -} - export default function ServiceProviderDeploymentsCard({ isGatewaysLoading, gateways, @@ -221,6 +213,11 @@ export default function ServiceProviderDeploymentsCard({ } const keyName = buildApiKeyResourceName(trimmedDisplayName); + const nameError = validateApiKeyName(keyName); + if (nameError) { + setKeyError(nameError); + return; + } try { setGeneratingKey(true); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx index 12809f9a0e..9dc3f683bb 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx @@ -57,6 +57,7 @@ import type { Gateway } from '../../../../apis/gatewayTypes'; import { PLATFORM_API_BASE_URL } from '../../../../paths'; import { logger } from '../../../../utils/logger'; import { getErrorMessage } from '../../../../utils/apiError'; +import { buildApiKeyResourceName, validateApiKeyName } from '../../../../utils/apiKeyName'; import type { DeploymentResponse, Proxy, @@ -116,15 +117,6 @@ function formatDate(value?: string): string { return date.toLocaleDateString(); } -function buildApiKeyResourceName(displayName: string): string { - const normalizedDisplayName = displayName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalizedDisplayName || 'api-key'; -} - type ServiceProviderOverviewTabProps = { onApiKeyCreated?: () => void; highlightApiKeySection?: boolean; @@ -513,6 +505,12 @@ export default function ServiceProviderOverviewTab({ setKeyError('Display name is required.'); return; } + const keyName = buildApiKeyResourceName(trimmedDisplayName); + const nameError = validateApiKeyName(keyName); + if (nameError) { + setKeyError(nameError); + return; + } try { setGeneratingKey(true); @@ -525,7 +523,7 @@ export default function ServiceProviderOverviewTab({ provider.id, currentOrganization.uuid, { - id: buildApiKeyResourceName(trimmedDisplayName), + id: keyName, displayName: apiKeyDisplayName, expiresAt: expiresAt.toISOString(), issuer: 'api-platform-ai-workspace', diff --git a/portals/ai-workspace/src/utils/apiKeyName.ts b/portals/ai-workspace/src/utils/apiKeyName.ts new file mode 100644 index 0000000000..a6c6dfddd8 --- /dev/null +++ b/portals/ai-workspace/src/utils/apiKeyName.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const API_KEY_NAME_MIN_LENGTH = 3; +export const API_KEY_NAME_MAX_LENGTH = 40; + +export function buildApiKeyResourceName(displayName: string): string { + const normalizedDisplayName = displayName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalizedDisplayName || 'api-key'; +} + +export function validateApiKeyName(name: string): string | null { + if (!name) return 'API key name is required.'; + if (name.length < API_KEY_NAME_MIN_LENGTH) { + return `Must be at least ${API_KEY_NAME_MIN_LENGTH} character(s).`; + } + if (name.length > API_KEY_NAME_MAX_LENGTH) { + return `Must be at most ${API_KEY_NAME_MAX_LENGTH} character(s).`; + } + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { + return 'API key name must be lowercase alphanumeric with hyphens only.'; + } + return null; +} From 0fb5e285e77d517d619eaf016a00b04ae06793ae Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Thu, 20 Aug 2026 17:05:26 +0530 Subject: [PATCH 2/6] Remove unnecessary validation for displayName in CreateLLMProxyAPIKey in platform-api; require either id or displayName --- platform-api/internal/service/llm_proxy_apikey.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index 40dcc4c141..3bd392bb2c 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -205,10 +205,6 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( if req.Id != nil && *req.Id != "" { name = *req.Id } else { - if req.DisplayName == "" { - return nil, apperror.ValidationFailed.New("Either id or displayName is required."). - WithLogMessage(fmt.Sprintf("cannot generate API key name for proxy %s: both id and displayName are empty", proxyID)) - } name, err = utils.GenerateHandle(req.DisplayName, nil) if err != nil { s.slogger.Error("Failed to generate API key name", "proxyId", proxyID, "error", err) From 495ba15778d2d3cb7550d50b545bd9e9180dd8cf Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Mon, 24 Aug 2026 10:36:43 +0530 Subject: [PATCH 3/6] Add handle length constants and refactor API key name generation in platform-api --- platform-api/internal/constants/constants.go | 7 +++ platform-api/internal/service/apikey.go | 51 ++------------------ platform-api/internal/utils/handle.go | 23 +++++---- 3 files changed, 21 insertions(+), 60 deletions(-) diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 00330f75ae..80409ae689 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -119,6 +119,13 @@ const ( AssociationTypeGateway = "gateway" ) +// Handle length constants — shared bounds for URL-safe handles/ids (projects, APIs, +// organizations, applications, gateways, API keys, etc.) +const ( + HandleMinLength = 3 + HandleMaxLength = 40 +) + // API Key allowed targets constants const APIKeyAllowedTargetsAll = "ALL" diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index 6ea835c1d3..477d803dd0 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -24,7 +24,6 @@ import ( "encoding/hex" "fmt" "log/slog" - "regexp" "strings" "time" @@ -37,19 +36,10 @@ import ( ) const ( - apiKeyNameMinLength = utils.HandleMinLength - apiKeyNameMaxLength = utils.HandleMaxLength hashingAlgorithmSHA256 = "sha256" defaultHashingAlgorithm = hashingAlgorithmSHA256 ) -var ( - // invalidAPIKeyNameCharsRegex removes any character that is not lowercase alphanumeric or hyphen - invalidAPIKeyNameCharsRegex = regexp.MustCompile(`[^a-z0-9\-]`) - // consecutiveHyphensRegex collapses runs of hyphens into a single hyphen - consecutiveHyphensRegex = regexp.MustCompile(`-+`) -) - // APIKeyService handles API key management operations for external API key injection type APIKeyService struct { apiRepo repository.APIRepository @@ -253,41 +243,6 @@ func randomHexString(n int) (string, error) { return hex.EncodeToString(bytes)[:n], nil } -// generateAPIKeyName derives a URL-safe, slug-style name from a display name: -// - Lowercase -// - Spaces and underscores → hyphens -// - Remove all non-[a-z0-9-] characters -// - Collapse consecutive hyphens -// - Trim leading/trailing hyphens -// - Enforce length [3, 40]; pad with random hex if too short -func generateAPIKeyName(displayName string) (string, error) { - name := strings.ToLower(strings.TrimSpace(displayName)) - name = strings.ReplaceAll(name, " ", "-") - name = strings.ReplaceAll(name, "_", "-") - name = invalidAPIKeyNameCharsRegex.ReplaceAllString(name, "") - name = consecutiveHyphensRegex.ReplaceAllString(name, "-") - name = strings.Trim(name, "-") - - if len(name) > apiKeyNameMaxLength { - name = strings.TrimRight(name[:apiKeyNameMaxLength], "-") - } - if len(name) < apiKeyNameMinLength { - padding, err := randomHexString(apiKeyNameMinLength - len(name)) - if err != nil { - return "", err - } - if name == "" { - name = padding - } else { - name = name + "-" + padding - } - if len(name) > apiKeyNameMaxLength { - name = strings.TrimRight(name[:apiKeyNameMaxLength], "-") - } - } - return name, nil -} - // resolveUniqueKeyName uses the caller-supplied name if present, otherwise derives one // from the display name (or the API handle as a fallback). Either way, the resolved name // is validated and it retries with a short random suffix on collision. @@ -310,7 +265,7 @@ func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.Creat } var err error - baseName, err = generateAPIKeyName(displayName) + baseName, err = utils.GenerateHandle(displayName, nil) if err != nil { return "", fmt.Errorf("failed to generate API key name: %w", err) } @@ -335,8 +290,8 @@ func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.Creat if err != nil { return "", err } - if len(baseName)+1+len(suffix) > apiKeyNameMaxLength { - name = strings.TrimRight(baseName[:apiKeyNameMaxLength-1-len(suffix)], "-") + "-" + suffix + if len(baseName)+1+len(suffix) > constants.HandleMaxLength { + name = strings.TrimRight(baseName[:constants.HandleMaxLength-1-len(suffix)], "-") + "-" + suffix } else { name = baseName + "-" + suffix } diff --git a/platform-api/internal/utils/handle.go b/platform-api/internal/utils/handle.go index 896890e805..fcc63865ae 100644 --- a/platform-api/internal/utils/handle.go +++ b/platform-api/internal/utils/handle.go @@ -23,15 +23,14 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/google/uuid" ) const ( - HandleMinLength = 3 - HandleMaxLength = 40 - maxRetries = 5 - suffixLength = 4 + maxRetries = 5 + suffixLength = 4 ) var ( @@ -74,13 +73,13 @@ func ValidateHandle(handle string) error { if handle == "" { return apperror.ValidationFailed.New("The id cannot be empty.") } - if len(handle) < HandleMinLength { + if len(handle) < constants.HandleMinLength { return apperror.ValidationFailed.New( - fmt.Sprintf("The id must be at least %d characters.", HandleMinLength)) + fmt.Sprintf("The id must be at least %d characters.", constants.HandleMinLength)) } - if len(handle) > HandleMaxLength { + if len(handle) > constants.HandleMaxLength { return apperror.ValidationFailed.New( - fmt.Sprintf("The id must be at most %d characters.", HandleMaxLength)) + fmt.Sprintf("The id must be at most %d characters.", constants.HandleMaxLength)) } if !validHandleRegex.MatchString(handle) { return apperror.ValidationFailed.New("The id must be lowercase alphanumeric with hyphens only " + @@ -125,7 +124,7 @@ func GenerateHandle(source string, existsCheck func(string) bool) (string, error candidateHandle := handle // Ensure we don't exceed max length when adding suffix - maxBaseLength := HandleMaxLength - suffixLength - 1 // -1 for the hyphen + maxBaseLength := constants.HandleMaxLength - suffixLength - 1 // -1 for the hyphen if len(candidateHandle) > maxBaseLength { candidateHandle = candidateHandle[:maxBaseLength] @@ -163,14 +162,14 @@ func sanitizeToHandle(s string) string { handle = strings.Trim(handle, "-") // Enforce length limits - if len(handle) > HandleMaxLength { - handle = handle[:HandleMaxLength] + if len(handle) > constants.HandleMaxLength { + handle = handle[:constants.HandleMaxLength] // Trim trailing hyphen if truncation created one handle = strings.TrimRight(handle, "-") } // If handle is too short after sanitization, pad with random suffix - if len(handle) < HandleMinLength { + if len(handle) < constants.HandleMinLength { if handle == "" { handle = generateRandomSuffix() + generateRandomSuffix() } else { From 4832645b5664b9d3274d3732978276a844b7ef3f Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Mon, 24 Aug 2026 15:33:07 +0530 Subject: [PATCH 4/6] feat(api-portal): Enhance API key validation and naming conventions; enforce length constraints for id and displayName --- .../docs/api-portal-openapi-spec-v0.9.yaml | 25 +++--- .../pages/api-keys/partials/api-key-list.hbs | 2 +- .../api-portal/src/scripts/api-keys-page.js | 10 +-- .../api-portal/src/services/apiKeyService.js | 80 ++++++++++++++----- 4 files changed, 80 insertions(+), 37 deletions(-) diff --git a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml index 3dfb31fbe1..4d958b2b30 100644 --- a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml +++ b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml @@ -2899,10 +2899,10 @@ components: ApiKeyBody: required: true description: >- - API key payload. `id` must be lowercase and may contain numbers, underscores, and hyphens. `displayName` is - an optional human-readable label that defaults to `id` when omitted. `expiresAt` can be an ISO-8601 datetime - with timezone, epoch seconds, or epoch milliseconds. The parent resource (API or MCP server, depending on the - path) is identified by the corresponding path parameter. + API key payload. `id` must be 3-40 lowercase alphanumeric characters, hyphen-separated. `displayName` is + an optional human-readable label (1-128 characters, any characters allowed) that defaults to `id` when + omitted. `expiresAt` can be an ISO-8601 datetime with timezone, epoch seconds, or epoch milliseconds. The + parent resource (API or MCP server, depending on the path) is identified by the corresponding path parameter. content: application/json: schema: @@ -2911,12 +2911,12 @@ components: expiringKey: summary: Generate an expiring key value: - id: weather_prod_key + id: weather-prod-key expiresAt: "2026-12-31T23:59:59Z" nonExpiringKey: summary: Generate a key without expiry value: - id: weather_sandbox_key + id: weather-sandbox-key ApiKeyRegenerateBody: required: true description: >- @@ -4989,13 +4989,18 @@ components: properties: id: type: string - pattern: ^[a-z0-9][a-z0-9_-]{0,127}$ + minLength: 3 + maxLength: 40 + pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ description: >- - Optional handle for the key. When provided it must match the pattern and be unique - for this API; when omitted, the server generates a UUID handle. - example: weather_prod_key + Optional handle for the key. When provided it must be 3-40 characters matching the + pattern and be unique for this API; when omitted, derived from displayName (or a + random handle if displayName is also omitted). + example: weather-prod-key displayName: type: string + minLength: 1 + maxLength: 128 description: Optional human-readable name for the key. Defaults to `id` when omitted. example: Weather Prod Key subscriptionId: diff --git a/portals/api-portal/src/pages/api-keys/partials/api-key-list.hbs b/portals/api-portal/src/pages/api-keys/partials/api-key-list.hbs index bab12be351..40c07d134f 100644 --- a/portals/api-portal/src/pages/api-keys/partials/api-key-list.hbs +++ b/portals/api-portal/src/pages/api-keys/partials/api-key-list.hbs @@ -94,7 +94,7 @@
-
Lowercase letters, numbers, hyphens and underscores (URL-safe).
+
Up to 128 characters. The key's internal id will be derived from this name.
diff --git a/portals/api-portal/src/scripts/api-keys-page.js b/portals/api-portal/src/scripts/api-keys-page.js index 1ff67c8d9d..5704eca7c3 100644 --- a/portals/api-portal/src/scripts/api-keys-page.js +++ b/portals/api-portal/src/scripts/api-keys-page.js @@ -197,7 +197,7 @@ /* ── API requests ─────────────────────────────────────────── */ - const namePattern = /^[a-z0-9][a-z0-9_-]{0,127}$/; + const DISPLAY_NAME_MAX_LENGTH = 128; async function postGenerate(body) { const response = await fetch(apiPortalApi.root('/apis/' + encodeURIComponent(apiId) + '/api-keys/generate'), { @@ -289,11 +289,11 @@ const nameInput = document.getElementById('api-key-name'); const expInput = document.getElementById('api-key-expires'); const name = (nameInput && nameInput.value) ? nameInput.value.trim() : ''; - if (!namePattern.test(name)) { - if (typeof showAlert === 'function') await showAlert('Enter a valid name: start with a letter or number, then up to 128 URL-safe characters.', 'error'); + if (!name || name.length > DISPLAY_NAME_MAX_LENGTH) { + if (typeof showAlert === 'function') await showAlert(`Enter a name, up to ${DISPLAY_NAME_MAX_LENGTH} characters.`, 'error'); return; } - const body = { id: name }; + const body = { displayName: name }; const iso = expInput ? expiresToIso(expInput.value) : null; if (iso) body.expiresAt = iso; submitGenBtn.dataset.loading = 'true'; @@ -311,7 +311,7 @@ delete submitGenBtn.dataset.loading; } if (data && data.key) { - showSecretModal(data.key, true, name); + showSecretModal(data.key, true, data.displayName || data.id || name); } else if (data) { window.location.reload(); } diff --git a/portals/api-portal/src/services/apiKeyService.js b/portals/api-portal/src/services/apiKeyService.js index 34940d988e..6349348a06 100644 --- a/portals/api-portal/src/services/apiKeyService.js +++ b/portals/api-portal/src/services/apiKeyService.js @@ -26,7 +26,11 @@ const subDao = require('../dao/subscriptionDao'); const logger = require('../config/logger'); const constants = require('../utils/constants'); -const KEY_HANDLE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,127}$/; +const KEY_HANDLE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const KEY_HANDLE_MIN_LENGTH = 3; +const KEY_HANDLE_MAX_LENGTH = 40; +const DISPLAY_NAME_MAX_LENGTH = 128; +const HANDLE_COLLISION_MAX_RETRIES = 5; const EXPIRES_AT_HAS_TZ = /(?:Z|[+-]\d{2}:\d{2})$/; const MIN_EXPIRY_MS = Date.UTC(1970, 0, 1); const MAX_EXPIRY_MS = Date.UTC(2100, 11, 31, 23, 59, 59, 999); @@ -35,10 +39,51 @@ function generateSecret() { return crypto.randomBytes(32).toString('base64url'); } -function parseAndValidateHandle(raw) { - if (typeof raw !== 'string') return null; - const n = raw.trim(); - return KEY_HANDLE_PATTERN.test(n) ? n : null; +function generateRandomSuffix() { + return crypto.randomBytes(2).toString('hex'); +} + +function sanitizeToHandle(s) { + let handle = s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (handle.length > KEY_HANDLE_MAX_LENGTH) { + handle = handle.slice(0, KEY_HANDLE_MAX_LENGTH).replace(/-+$/, ''); + } + if (handle.length < KEY_HANDLE_MIN_LENGTH) { + const suffix = generateRandomSuffix().slice(0, KEY_HANDLE_MIN_LENGTH - handle.length); + handle = handle ? `${handle}-${suffix}` : suffix; + } + return handle; +} + +function validateHandle(handle) { + if (!handle || handle.length < KEY_HANDLE_MIN_LENGTH || handle.length > KEY_HANDLE_MAX_LENGTH) return false; + return KEY_HANDLE_PATTERN.test(handle); +} + +async function resolveUniqueKeyName(orgId, apiUuid, handle, displayName) { + let baseName; + if (typeof handle === 'string' && handle.trim()) { + baseName = handle.trim(); + if (!validateHandle(baseName)) { + throw Object.assign(new Error( + `id must be ${KEY_HANDLE_MIN_LENGTH}-${KEY_HANDLE_MAX_LENGTH} characters matching ^[a-z0-9]+(-[a-z0-9]+)*$` + ), { status: 400 }); + } + } else if (typeof displayName === 'string' && displayName.trim()) { + baseName = sanitizeToHandle(displayName.trim()); + } else { + baseName = crypto.randomUUID(); + } + + let name = baseName; + for (let i = 0; i < HANDLE_COLLISION_MAX_RETRIES; i++) { + if (!(await apiKeyDao.getIdByHandle(orgId, apiUuid, name))) return name; + const suffix = generateRandomSuffix(); + name = baseName.length + 1 + suffix.length > KEY_HANDLE_MAX_LENGTH + ? `${baseName.slice(0, KEY_HANDLE_MAX_LENGTH - 1 - suffix.length).replace(/-+$/, '')}-${suffix}` + : `${baseName}-${suffix}`; + } + throw Object.assign(new Error(`An API key with id "${baseName}" already exists for this API.`), { status: 409 }); } function parseExpiresAt(raw) { @@ -138,16 +183,14 @@ async function publishKeyApplicationUpdated(orgId, keyId, handle, displayName, a */ async function generate({ orgId, apiId, subscriptionId, appId, handle, displayName, expiresAt, actor }) { - // Handle rule: use the caller-supplied `id` when present (validated); otherwise a - // UUID. A UUID satisfies KEY_HANDLE_PATTERN, so it needs no extra validation. - let normalizedHandle; - if (typeof handle === 'string' && handle.trim()) { - normalizedHandle = parseAndValidateHandle(handle); - if (!normalizedHandle) throw Object.assign(new Error('id must match ^[a-z0-9][a-z0-9_-]{0,127}$'), { status: 400 }); - } else { - normalizedHandle = crypto.randomUUID(); + let normalizedDisplayName = null; + if (typeof displayName === 'string' && displayName.trim()) { + const trimmed = displayName.trim(); + if (trimmed.length > DISPLAY_NAME_MAX_LENGTH) { + throw Object.assign(new Error(`displayName must be at most ${DISPLAY_NAME_MAX_LENGTH} characters`), { status: 400 }); + } + normalizedDisplayName = trimmed; } - const normalizedDisplayName = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : normalizedHandle; const expiry = parseExpiresAt(expiresAt); if (!expiry.ok) throw Object.assign(new Error(expiry.description), { status: 400 }); @@ -155,13 +198,8 @@ async function generate({ orgId, apiId, subscriptionId, appId, handle, displayNa const api = await resolveApi(orgId, apiId); if (api.error) throw Object.assign(new Error(api.error.message), { status: api.error.status }); - // The handle is the caller-facing id used to resolve a key within an API, so reject - // a duplicate. This is a friendly pre-check; the (org_uuid, api_uuid, handle) unique - // index is the authoritative guard, enforced atomically by the duplicate-key catch - // around apiKeyDao.create below (which also covers a create that races past this). - if (await apiKeyDao.getIdByHandle(orgId, api.id, normalizedHandle)) { - throw Object.assign(new Error(`An API key with id "${normalizedHandle}" already exists for this API.`), { status: 409 }); - } + const normalizedHandle = await resolveUniqueKeyName(orgId, api.id, handle, normalizedDisplayName); + if (!normalizedDisplayName) normalizedDisplayName = normalizedHandle; const application = await resolveApp(orgId, appId, actor); From 426aeb244916a7bc9884d8b3c82a30bafc703a8e Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Mon, 24 Aug 2026 16:00:53 +0530 Subject: [PATCH 5/6] refactor(api-portal): Update API key generation logic and improve documentation for id and displayName fix(api-portal): Clarify API key uniqueness requirement in documentation --- .../docs/api-portal-openapi-spec-v0.9.yaml | 15 +++++++++++---- .../it/rest-api/mcp-servers/mcp-servers.spec.js | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml index 4d958b2b30..9c6a2dcce8 100644 --- a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml +++ b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml @@ -2899,10 +2899,13 @@ components: ApiKeyBody: required: true description: >- - API key payload. `id` must be 3-40 lowercase alphanumeric characters, hyphen-separated. `displayName` is - an optional human-readable label (1-128 characters, any characters allowed) that defaults to `id` when - omitted. `expiresAt` can be an ISO-8601 datetime with timezone, epoch seconds, or epoch milliseconds. The - parent resource (API or MCP server, depending on the path) is identified by the corresponding path parameter. + API key payload. `id` is optional; when provided it must be 3-40 lowercase alphanumeric + characters, hyphen-separated, and unique within the parent API or MCP server. When omitted, + it's derived from `displayName` (or randomly generated if `displayName` is also omitted). + `displayName` is an optional human-readable label (1-128 characters, any characters + allowed) that defaults to `id` when omitted. `expiresAt` can be an ISO-8601 datetime with + timezone, epoch seconds, or epoch milliseconds. The parent resource (API or MCP server, + depending on the path) is identified by the corresponding path parameter. content: application/json: schema: @@ -2917,6 +2920,10 @@ components: summary: Generate a key without expiry value: id: weather-sandbox-key + derivedFromDisplayName: + summary: Generate a key without specifying an id + value: + displayName: Weather Prod Key ApiKeyRegenerateBody: required: true description: >- diff --git a/portals/api-portal/it/rest-api/mcp-servers/mcp-servers.spec.js b/portals/api-portal/it/rest-api/mcp-servers/mcp-servers.spec.js index f0c6deaed1..72fe7f84de 100644 --- a/portals/api-portal/it/rest-api/mcp-servers/mcp-servers.spec.js +++ b/portals/api-portal/it/rest-api/mcp-servers/mcp-servers.spec.js @@ -192,7 +192,7 @@ describe('MCP servers', () => { it('generates an API key scoped to an MCP server', async () => { const mcp = await createMcpServer(); - const keyId = uniqueHandle('mcp-key').toLowerCase(); + const keyId = uniqueHandle('key').toLowerCase(); const res = await client.as('publisher').post(`/mcp-servers/${mcp.id}/api-keys/generate`, { id: keyId }); expect(res.status).toBe(201); expect(res.body.id).toBe(keyId); From 800ad1fa0186bdd89d7d4fe8c8d4e25b3ff6c02d Mon Sep 17 00:00:00 2001 From: Isuranga-2001 Date: Mon, 31 Aug 2026 09:50:02 +0530 Subject: [PATCH 6/6] fix(platform-api): Improve error messages for API key name generation failures --- platform-api/internal/handler/api_key.go | 2 +- platform-api/internal/service/apikey.go | 2 +- platform-api/internal/service/llm_apikey.go | 4 ++-- platform-api/internal/service/llm_proxy_apikey.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/platform-api/internal/handler/api_key.go b/platform-api/internal/handler/api_key.go index b96d75cbf9..11d1a8314a 100644 --- a/platform-api/internal/handler/api_key.go +++ b/platform-api/internal/handler/api_key.go @@ -98,7 +98,7 @@ func (h *APIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) err } else { generatedName, err := utils.GenerateHandle(req.DisplayName, nil) if err != nil { - return apperror.ValidationFailed.Wrap(err, "Failed to generate API key name") + return apperror.ValidationFailed.Wrap(err, "Invalid API key name: display name cannot be converted to a valid identifier") } name = generatedName req.Id = &name diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index 477d803dd0..7e2dad84d4 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -267,7 +267,7 @@ func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.Creat var err error baseName, err = utils.GenerateHandle(displayName, nil) if err != nil { - return "", fmt.Errorf("failed to generate API key name: %w", err) + return "", fmt.Errorf("invalid API key name: display name cannot be converted to a valid identifier: %w", err) } } diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index f8acb57b5d..b8421521fe 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -243,11 +243,11 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( name, err = utils.GenerateHandle(req.DisplayName, nil) if err != nil { s.slogger.Error("Failed to generate API key name", "providerId", providerID, "error", err) - return nil, fmt.Errorf("failed to generate API key name: %w", err) + return nil, fmt.Errorf("invalid API key name: display name cannot be converted to a valid identifier: %w", err) } } if err := utils.ValidateHandle(name); err != nil { - s.slogger.Warn("Invalid API key id for LLM provider API key creation", "providerId", providerID) + s.slogger.Error("Invalid API key id for LLM provider API key creation", "providerId", providerID) return nil, err } diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index 3bd392bb2c..e1e0296515 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -208,7 +208,7 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( name, err = utils.GenerateHandle(req.DisplayName, nil) if err != nil { s.slogger.Error("Failed to generate API key name", "proxyId", proxyID, "error", err) - return nil, fmt.Errorf("failed to generate API key name: %w", err) + return nil, fmt.Errorf("invalid API key name: display name cannot be converted to a valid identifier: %w", err) } } if err := utils.ValidateHandle(name); err != nil {