Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions platform-api/internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion platform-api/internal/handler/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 10 additions & 52 deletions platform-api/internal/service/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"encoding/hex"
"fmt"
"log/slog"
"regexp"
"strings"
"time"

Expand All @@ -37,19 +36,10 @@ import (
)

const (
apiKeyNameMinLength = 3
apiKeyNameMaxLength = 63
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
Expand Down Expand Up @@ -253,45 +243,9 @@ 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:
// - 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
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) 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) != "" {
Expand All @@ -311,12 +265,16 @@ 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)
return "", fmt.Errorf("invalid API key name: display name cannot be converted to a valid identifier: %w", err)
}
}

if err := utils.ValidateHandle(baseName); err != nil {
return "", err

@thivindu thivindu Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall warp the error with a meaningful error message like "Invalid API key id" rather than returning the raw error like in return "", fmt.Errorf("failed to generate API key name: %w", err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 800ad1f

}

// Check for collision and retry with a short suffix (up to 5 attempts)
const maxRetries = 5
name := baseName
Expand All @@ -332,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
}
Expand Down
40 changes: 39 additions & 1 deletion platform-api/internal/service/apikey_authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
}
}
38 changes: 38 additions & 0 deletions platform-api/internal/service/artifact_dp_apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 5 additions & 1 deletion platform-api/internal/service/llm_apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,13 @@ 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.Error("Invalid API key id for LLM provider API key creation", "providerId", providerID)
return nil, err
}

displayName := req.DisplayName
if displayName == "" {
Expand Down
6 changes: 5 additions & 1 deletion platform-api/internal/service/llm_proxy_apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,13 @@ 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 {
s.slogger.Warn("Invalid API key id for LLM proxy API key creation", "proxyId", proxyID)
return nil, err
}

displayName := req.DisplayName
if displayName == "" {
Expand Down
23 changes: 11 additions & 12 deletions platform-api/internal/utils/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand Down
Loading
Loading