mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: restore AI Provider key checks (#29677)
Adds key validation to the AI Providers API that was present in env variables path. * Reject duplicate API keys on create and update, including duplicates between new keys and retained keys. * Enforce a maximum of five keys per provider, counting retained and new keys on update. * Require Bedrock access keys and secrets to be both set or both absent/empty. Validate merged settings on update to allow partial rotations but reject unpaired credentials and partial clears. Extends and consolidates SDK/API tests covering these checks and unchanged state after rejected updates. > Generated with Coder Agents.
This commit is contained in:
+36
-5
@@ -657,12 +657,28 @@ func (api *API) aiProviderStatusFromDB(ctx context.Context, provider database.AI
|
||||
return api.aiProviderStatus(provider, buildHostnameCollisionMap(rows))
|
||||
}
|
||||
|
||||
type aiProviderValidationError struct {
|
||||
Validations []codersdk.ValidationError
|
||||
}
|
||||
|
||||
func (*aiProviderValidationError) Error() string {
|
||||
return "invalid AI provider request"
|
||||
}
|
||||
|
||||
// writeAIProviderError translates an error from the AI provider
|
||||
// lookup/update/delete paths into the right HTTP status code. logMsg
|
||||
// labels the log line for operator debugging, and userMsg is the
|
||||
// internal-error response message shown to the API consumer when no
|
||||
// more specific branch fires.
|
||||
func writeAIProviderError(ctx context.Context, logger slog.Logger, rw http.ResponseWriter, err error, logMsg, userMsg string) {
|
||||
var validationErr *aiProviderValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid AI provider request.",
|
||||
Validations: validationErr.Validations,
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errAIProviderInvalidName) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Invalid provider id or name: must be a UUID or match %s.", codersdk.AIProviderNameRegex),
|
||||
@@ -810,23 +826,33 @@ func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uu
|
||||
if err != nil {
|
||||
return nil, ops, changes, xerrors.Errorf("load existing ai provider keys: %w", err)
|
||||
}
|
||||
existingByID := make(map[uuid.UUID]struct{}, len(existing))
|
||||
existingByID := make(map[uuid.UUID]string, len(existing))
|
||||
for _, k := range existing {
|
||||
existingByID[k.ID] = struct{}{}
|
||||
existingByID[k.ID] = k.APIKey
|
||||
}
|
||||
|
||||
keep := make(map[uuid.UUID]struct{}, len(muts))
|
||||
seenKeys := make(map[string]int, len(muts))
|
||||
var inserts []string
|
||||
for _, m := range muts {
|
||||
for i, m := range muts {
|
||||
var key string
|
||||
switch {
|
||||
case m.ID != nil:
|
||||
if _, ok := existingByID[*m.ID]; !ok {
|
||||
var ok bool
|
||||
key, ok = existingByID[*m.ID]
|
||||
if !ok {
|
||||
return nil, ops, changes, xerrors.Errorf("%w: %s", errAIProviderKeyUnknown, *m.ID)
|
||||
}
|
||||
keep[*m.ID] = struct{}{}
|
||||
case m.APIKey != nil:
|
||||
inserts = append(inserts, *m.APIKey)
|
||||
key = *m.APIKey
|
||||
inserts = append(inserts, key)
|
||||
}
|
||||
// Compare the final key set, not keys being removed by this patch.
|
||||
if validations := codersdk.ValidateAIProviderKeyUniqueness(key, "api_keys", seenKeys); len(validations) > 0 {
|
||||
return nil, ops, changes, &aiProviderValidationError{Validations: validations}
|
||||
}
|
||||
seenKeys[key] = i
|
||||
}
|
||||
|
||||
for _, k := range existing {
|
||||
@@ -892,6 +918,11 @@ func lookupAndMergeSettings(ctx context.Context, db database.Store, idOrName str
|
||||
}
|
||||
if patch != nil {
|
||||
settings = mergeAIProviderSettings(settings, *patch)
|
||||
if settings.Bedrock != nil {
|
||||
if validations := settings.Bedrock.ValidateCredentials(); len(validations) > 0 {
|
||||
return database.AIProvider{}, codersdk.AIProviderSettings{}, &aiProviderValidationError{Validations: validations}
|
||||
}
|
||||
}
|
||||
}
|
||||
return old, settings, nil
|
||||
}
|
||||
|
||||
+570
-291
@@ -673,6 +673,69 @@ func TestAIProvidersCRUD(t *testing.T) {
|
||||
require.Equal(t, "anthropic.claude-3-5-haiku", updated.Settings.Bedrock.SmallFastModel)
|
||||
})
|
||||
|
||||
t.Run("CredentialsMustBePairedOnCreate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
accessKey *string
|
||||
accessKeySecret *string
|
||||
missingField string
|
||||
detail string
|
||||
secretNotInError string
|
||||
}{
|
||||
{
|
||||
name: "MissingAccessKey",
|
||||
provider: "bedrock-create-missing-access-key",
|
||||
accessKeySecret: new("create-secret-only"),
|
||||
missingField: "settings.access_key",
|
||||
detail: "access_key_secret is set, but access_key is missing or empty",
|
||||
secretNotInError: "create-secret-only",
|
||||
},
|
||||
{
|
||||
name: "MissingAccessKeySecret",
|
||||
provider: "bedrock-create-missing-access-key-secret",
|
||||
accessKey: new("AKIA-create-key-only"), //nolint:gosec // test fixture
|
||||
missingField: "settings.access_key_secret",
|
||||
detail: "access_key is set, but access_key_secret is missing or empty",
|
||||
secretNotInError: "AKIA-create-key-only",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: tt.provider,
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: tt.accessKey,
|
||||
AccessKeySecret: tt.accessKeySecret,
|
||||
},
|
||||
},
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Invalid AI provider request.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Validations, codersdk.ValidationError{
|
||||
Field: tt.missingField,
|
||||
Detail: tt.detail,
|
||||
})
|
||||
body, marshalErr := json.Marshal(sdkErr)
|
||||
require.NoError(t, marshalErr)
|
||||
require.NotContains(t, string(body), tt.secretNotInError)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BedrockSecretsHidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
@@ -734,10 +797,15 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
Name: "keys-openai",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{primary, secondary},
|
||||
APIKeys: []string{
|
||||
primary, secondary,
|
||||
"sk-openai-third-fixture-cccccc", //nolint:gosec // test fixture
|
||||
"sk-openai-fourth-fixture-dddddd", //nolint:gosec // test fixture
|
||||
"sk-openai-fifth-fixture-eeeeee", //nolint:gosec // test fixture
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, provider.APIKeys, 2)
|
||||
require.Len(t, provider.APIKeys, 5)
|
||||
// Masked form preserves prefix and suffix while hiding the
|
||||
// middle, so it's enough for an operator to recognize the key
|
||||
// without recovering the plaintext.
|
||||
@@ -745,8 +813,9 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
require.True(t, strings.HasSuffix(provider.APIKeys[0].Masked, "aaaa"))
|
||||
require.NotContains(t, provider.APIKeys[0].Masked, primary)
|
||||
require.NotContains(t, provider.APIKeys[1].Masked, secondary)
|
||||
require.NotEqual(t, uuid.Nil, provider.APIKeys[0].ID)
|
||||
require.NotEqual(t, uuid.Nil, provider.APIKeys[1].ID)
|
||||
for _, key := range provider.APIKeys {
|
||||
require.NotEqual(t, uuid.Nil, key.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResponseHidesPlaintext", func(t *testing.T) {
|
||||
@@ -782,33 +851,52 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-replace",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{"sk-openai-original-ddddddddddddddd"}, //nolint:gosec // test fixture, not a real credential
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, provider.APIKeys, 1)
|
||||
originalID := provider.APIKeys[0].ID
|
||||
const original = "sk-openai-original-ddddddddddddddd" //nolint:gosec // test fixture
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
replacement []codersdk.AIProviderKeyMutation
|
||||
}{
|
||||
{
|
||||
name: "DifferentPlaintext",
|
||||
replacement: []codersdk.AIProviderKeyMutation{
|
||||
{APIKey: new("sk-openai-rotated-eeeeeeeeeeeeeeeeeee")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-rotated-second-ffffffffffffffff")}, //nolint:gosec // test fixture
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SamePlaintext",
|
||||
replacement: []codersdk.AIProviderKeyMutation{{APIKey: new(original)}},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Omitting the original ID from the mutation list deletes it;
|
||||
// the two APIKey-bearing entries add fresh rows.
|
||||
replacement := []codersdk.AIProviderKeyMutation{
|
||||
{APIKey: new("sk-openai-rotated-eeeeeeeeeeeeeeeeeee")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-rotated-second-ffffffffffffffff")}, //nolint:gosec // test fixture
|
||||
}
|
||||
updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &replacement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.APIKeys, 2)
|
||||
for _, k := range updated.APIKeys {
|
||||
require.NotEqual(t, originalID, k.ID)
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-replace-" + strings.ToLower(tt.name),
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{original},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, provider.APIKeys, 1)
|
||||
originalID := provider.APIKeys[0].ID
|
||||
|
||||
// Omitting the original ID deletes it; plaintext entries create
|
||||
// fresh rows even when the secret is unchanged.
|
||||
updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &tt.replacement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.APIKeys, len(tt.replacement))
|
||||
for _, k := range updated.APIKeys {
|
||||
require.NotEqual(t, uuid.Nil, k.ID)
|
||||
require.NotEqual(t, originalID, k.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -835,17 +923,20 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
keepMasked := provider.APIKeys[0].Masked
|
||||
evictID := provider.APIKeys[1].ID
|
||||
|
||||
// Reference only keepID and add one new plaintext: evictID is
|
||||
// implicitly removed.
|
||||
// Reference only keepID and add four new keys, reaching the five-key
|
||||
// limit. evictID is implicitly removed.
|
||||
patch := []codersdk.AIProviderKeyMutation{
|
||||
{ID: &keepID},
|
||||
{APIKey: new("sk-openai-added-cccccccccccccccccccccc")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-added-2-dddddddddddddddddddd")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-added-3-eeeeeeeeeeeeeeeeeeee")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-added-4-ffffffffffffffffffff")}, //nolint:gosec // test fixture
|
||||
}
|
||||
updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &patch,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.APIKeys, 2)
|
||||
require.Len(t, updated.APIKeys, 5)
|
||||
ids := keyIDs(updated.APIKeys)
|
||||
require.Contains(t, ids, keepID)
|
||||
require.NotContains(t, ids, evictID)
|
||||
@@ -1082,68 +1173,77 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
require.Contains(t, sdkErr.Message, "Copilot providers do not accept api_keys")
|
||||
})
|
||||
|
||||
t.Run("EmptyKeyRejected", func(t *testing.T) {
|
||||
t.Run("CreateRejectsInvalidKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-empty-element",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{""},
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "Invalid AI provider request")
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "must not be empty")
|
||||
})
|
||||
|
||||
t.Run("WhitespaceKeyRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Surrounding whitespace would silently break upstream auth,
|
||||
// since the server stores credentials verbatim. Reject up-front
|
||||
// so the operator gets a clear signal instead of a 401 later.
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-whitespace-create",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{" sk-openai-padded-nnnnnnnnnnnnnnnnnnnn "}, //nolint:gosec // test fixture
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-whitespace-update",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{"sk-openai-clean-oooooooooooooooooooo"}, //nolint:gosec // test fixture
|
||||
})
|
||||
require.NoError(t, err)
|
||||
padded := " sk-openai-padded-pppppppppppppppppppp "
|
||||
muts := []codersdk.AIProviderKeyMutation{{APIKey: &padded}}
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &muts,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
const duplicateKey = "sk-openai-create-duplicate-vvvvvvvvvvvvvvvv" //nolint:gosec // test fixture
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
keys []string
|
||||
field string
|
||||
detail string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
keys: []string{""},
|
||||
field: "api_keys[0]",
|
||||
detail: "must not be empty",
|
||||
},
|
||||
{
|
||||
// Surrounding whitespace would silently break upstream auth,
|
||||
// since credentials are stored verbatim.
|
||||
name: "Whitespace",
|
||||
keys: []string{" sk-openai-padded-nnnnnnnnnnnnnnnnnnnn "}, //nolint:gosec // test fixture
|
||||
field: "api_keys[0]",
|
||||
detail: "must not contain leading or trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "Duplicate",
|
||||
keys: []string{duplicateKey, duplicateKey},
|
||||
field: "api_keys[1]",
|
||||
detail: "duplicate key already provided at api_keys[0]",
|
||||
notContain: duplicateKey,
|
||||
},
|
||||
{
|
||||
name: "TooMany",
|
||||
keys: []string{
|
||||
"sk-openai-create-max-1-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
"sk-openai-create-max-2-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
"sk-openai-create-max-3-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
"sk-openai-create-max-4-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
"sk-openai-create-max-5-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
"sk-openai-create-max-6-vvvvvvvvvvvvvvvv", //nolint:gosec // test fixture
|
||||
},
|
||||
field: "api_keys",
|
||||
detail: "api_keys must contain at most 5 keys",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-create-invalid-" + strings.ToLower(tt.name),
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: tt.keys,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Invalid AI provider request.", sdkErr.Message)
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, tt.field, sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, tt.detail)
|
||||
if tt.notContain != "" {
|
||||
body, marshalErr := json.Marshal(sdkErr)
|
||||
require.NoError(t, marshalErr)
|
||||
require.NotContains(t, string(body), tt.notContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NonOwnerForbidden", func(t *testing.T) {
|
||||
@@ -1175,102 +1275,6 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("MutationBothFieldsRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-mut-both",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{"sk-openai-existing-kkkkkkkkkkkkkkkk"}, //nolint:gosec // test fixture
|
||||
})
|
||||
require.NoError(t, err)
|
||||
existingID := provider.APIKeys[0].ID
|
||||
|
||||
muts := []codersdk.AIProviderKeyMutation{
|
||||
{ID: &existingID, APIKey: new("sk-conflict")}, //nolint:gosec // test fixture
|
||||
}
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &muts,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "Invalid AI provider request")
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set")
|
||||
})
|
||||
|
||||
t.Run("MutationNeitherFieldRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-mut-empty",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
muts := []codersdk.AIProviderKeyMutation{{}}
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &muts,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "Invalid AI provider request")
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set")
|
||||
})
|
||||
|
||||
t.Run("MutationDuplicateIDRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-mut-dup",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{"sk-openai-dup-llllllllllllllllllll"}, //nolint:gosec // test fixture
|
||||
})
|
||||
require.NoError(t, err)
|
||||
id := provider.APIKeys[0].ID
|
||||
|
||||
muts := []codersdk.AIProviderKeyMutation{
|
||||
{ID: &id},
|
||||
{ID: &id},
|
||||
}
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &muts,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "Invalid AI provider request")
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "api_keys[1].id", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "already referenced")
|
||||
})
|
||||
|
||||
t.Run("PATCHPropertiesAudited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
@@ -1403,38 +1407,161 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
require.True(t, sawDelete, "expected delete audit for removed key")
|
||||
})
|
||||
|
||||
t.Run("MutationUnknownIDRejected", func(t *testing.T) {
|
||||
t.Run("UpdateRejectsInvalidKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
const (
|
||||
retainedKey = "sk-openai-update-retained-xxxxxxxxxxxxxxxx" //nolint:gosec // test fixture
|
||||
otherKey = "sk-openai-update-other-yyyyyyyyyyyyyyyyyyy" //nolint:gosec // test fixture
|
||||
duplicate = "sk-openai-update-duplicate-zzzzzzzzzzzzzzz" //nolint:gosec // test fixture
|
||||
)
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-mut-unknown",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{"sk-openai-real-mmmmmmmmmmmmmmmmmmmm"}, //nolint:gosec // test fixture
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys-update-validation",
|
||||
DisplayName: "Original key provider",
|
||||
Enabled: true,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: []string{retainedKey, otherKey},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, provider.APIKeys, 2)
|
||||
originalIDs := keyIDs(provider.APIKeys)
|
||||
retainedID := provider.APIKeys[0].ID
|
||||
|
||||
bogus := uuid.New()
|
||||
muts := []codersdk.AIProviderKeyMutation{{ID: &bogus}}
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
APIKeys: &muts,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "api_keys references an unknown id for this provider")
|
||||
tests := []struct {
|
||||
name string
|
||||
mutations []codersdk.AIProviderKeyMutation
|
||||
message string
|
||||
field string
|
||||
detail string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "BothFields",
|
||||
mutations: []codersdk.AIProviderKeyMutation{{ID: &retainedID, APIKey: new(duplicate)}},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[0]",
|
||||
detail: "exactly one of id or api_key must be set",
|
||||
},
|
||||
{
|
||||
name: "NeitherField",
|
||||
mutations: []codersdk.AIProviderKeyMutation{{}},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[0]",
|
||||
detail: "exactly one of id or api_key must be set",
|
||||
},
|
||||
{
|
||||
name: "DuplicateID",
|
||||
mutations: []codersdk.AIProviderKeyMutation{{ID: &retainedID}, {ID: &retainedID}},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[1].id",
|
||||
detail: "already referenced",
|
||||
},
|
||||
{
|
||||
name: "Whitespace",
|
||||
mutations: []codersdk.AIProviderKeyMutation{{APIKey: new(" sk-openai-padded-pppppppppppppppppppp ")}}, //nolint:gosec // test fixture
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[0].api_key",
|
||||
detail: "must not contain leading or trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "UnknownID",
|
||||
mutations: []codersdk.AIProviderKeyMutation{{ID: new(uuid.New())}},
|
||||
message: "api_keys references an unknown id for this provider",
|
||||
},
|
||||
{
|
||||
name: "DuplicateKey",
|
||||
mutations: []codersdk.AIProviderKeyMutation{
|
||||
{APIKey: new(duplicate)},
|
||||
{APIKey: new(duplicate)},
|
||||
},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[1].api_key",
|
||||
detail: "duplicate key already provided at api_keys[0]",
|
||||
notContain: duplicate,
|
||||
},
|
||||
{
|
||||
name: "DuplicateKeyWithRetainedIDs",
|
||||
mutations: []codersdk.AIProviderKeyMutation{
|
||||
{ID: &retainedID},
|
||||
{APIKey: new(duplicate)},
|
||||
{ID: &provider.APIKeys[1].ID},
|
||||
{APIKey: new(duplicate)},
|
||||
},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys[3].api_key",
|
||||
detail: "duplicate key already provided at api_keys[1]",
|
||||
notContain: duplicate,
|
||||
},
|
||||
{
|
||||
name: "RetainedThenDuplicatePlaintext",
|
||||
mutations: []codersdk.AIProviderKeyMutation{
|
||||
{ID: &retainedID},
|
||||
{APIKey: new(retainedKey)},
|
||||
},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys",
|
||||
detail: "duplicate key already provided at api_keys[0]",
|
||||
notContain: retainedKey,
|
||||
},
|
||||
{
|
||||
name: "DuplicatePlaintextThenRetained",
|
||||
mutations: []codersdk.AIProviderKeyMutation{
|
||||
{APIKey: new(retainedKey)},
|
||||
{ID: &retainedID},
|
||||
},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys",
|
||||
detail: "duplicate key already provided at api_keys[0]",
|
||||
notContain: retainedKey,
|
||||
},
|
||||
{
|
||||
name: "TooManyFinalKeys",
|
||||
mutations: []codersdk.AIProviderKeyMutation{
|
||||
{ID: &provider.APIKeys[0].ID},
|
||||
{ID: &provider.APIKeys[1].ID},
|
||||
{APIKey: new("sk-openai-update-max-3-aaaaaaaaaaaaaaaa")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-update-max-4-bbbbbbbbbbbbbbbb")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-update-max-5-cccccccccccccccc")}, //nolint:gosec // test fixture
|
||||
{APIKey: new("sk-openai-update-max-6-dddddddddddddddd")}, //nolint:gosec // test fixture
|
||||
},
|
||||
message: "Invalid AI provider request.",
|
||||
field: "api_keys",
|
||||
detail: "api_keys must contain at most 5 keys",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
changedDisplay := "Must not be persisted by " + tt.name
|
||||
_, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
DisplayName: &changedDisplay,
|
||||
APIKeys: &tt.mutations,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, tt.message, sdkErr.Message)
|
||||
if tt.field != "" {
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, tt.field, sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, tt.detail)
|
||||
} else {
|
||||
require.Empty(t, sdkErr.Validations)
|
||||
}
|
||||
if tt.notContain != "" {
|
||||
body, marshalErr := json.Marshal(sdkErr)
|
||||
require.NoError(t, marshalErr)
|
||||
require.NotContains(t, string(body), tt.notContain)
|
||||
}
|
||||
|
||||
// Provider's real key is left untouched.
|
||||
reread, err := client.AIProvider(ctx, provider.Name)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reread.APIKeys, 1)
|
||||
require.Equal(t, provider.APIKeys[0].ID, reread.APIKeys[0].ID)
|
||||
reread, getErr := client.AIProvider(ctx, provider.Name)
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, provider.DisplayName, reread.DisplayName)
|
||||
require.ElementsMatch(t, originalIDs, keyIDs(reread.APIKeys))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1446,6 +1573,87 @@ func TestAIProvidersKeyManagement(t *testing.T) {
|
||||
func TestAIProviderSettingsMerge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RejectsUnpairedCredentialsWithoutStoredCredentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
accessKey *string
|
||||
accessKeySecret *string
|
||||
missingField string
|
||||
detail string
|
||||
secretNotInError string
|
||||
}{
|
||||
{
|
||||
name: "AccessKeyOnly",
|
||||
accessKey: new("AKIA-introduced"), //nolint:gosec // test fixture
|
||||
missingField: "settings.access_key_secret",
|
||||
detail: "access_key is set, but access_key_secret is missing or empty",
|
||||
secretNotInError: "AKIA-introduced",
|
||||
},
|
||||
{
|
||||
name: "AccessKeySecretOnly",
|
||||
accessKeySecret: new("introduced-secret"),
|
||||
missingField: "settings.access_key",
|
||||
detail: "access_key_secret is set, but access_key is missing or empty",
|
||||
secretNotInError: "introduced-secret",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: "bedrock-introduce-" + strings.ToLower(tt.name),
|
||||
DisplayName: "Original Bedrock provider",
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
|
||||
DisplayName: new("Must not be persisted"),
|
||||
Settings: &codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-west-2",
|
||||
Model: "anthropic.claude-3-7-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: tt.accessKey,
|
||||
AccessKeySecret: tt.accessKeySecret,
|
||||
},
|
||||
},
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Invalid AI provider request.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Validations, codersdk.ValidationError{
|
||||
Field: tt.missingField,
|
||||
Detail: tt.detail,
|
||||
})
|
||||
body, err := json.Marshal(sdkErr)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(body), tt.secretNotInError)
|
||||
|
||||
reread, err := client.AIProvider(ctx, provider.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, provider, reread)
|
||||
//nolint:gocritic // Test reads the row to verify write-only fields.
|
||||
row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), provider.ID)
|
||||
require.NoError(t, err)
|
||||
persisted, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, provider.Settings, persisted)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OmittedSecretsPreserveExisting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A PATCH that only rotates non-secret fields must keep the
|
||||
@@ -1498,107 +1706,178 @@ func TestAIProviderSettingsMerge(t *testing.T) {
|
||||
require.Equal(t, "secret-old", *persisted.Bedrock.AccessKeySecret)
|
||||
})
|
||||
|
||||
t.Run("ExplicitEmptyClearsSecrets", func(t *testing.T) {
|
||||
t.Run("ClearSecrets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// An admin migrating from static AWS credentials to IAM
|
||||
// role-based auth needs to clear AccessKey and AccessKeySecret
|
||||
// in a single PATCH. Sending the field with an empty string is
|
||||
// the explicit clear signal; the *string field distinguishes
|
||||
// "omitted" (nil) from "set to empty" (pointer to "").
|
||||
// Empty strings explicitly clear credentials; omitted fields retain
|
||||
// their stored values. Clearing just one half must be rejected.
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: "merge-clear",
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new("AKIA-old"), //nolint:gosec // test fixture, not a real credential
|
||||
AccessKeySecret: new("secret-old"),
|
||||
},
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
accessKey *string
|
||||
accessKeySecret *string
|
||||
missingField string
|
||||
detail string
|
||||
}{
|
||||
{
|
||||
name: "Both",
|
||||
accessKey: new(""),
|
||||
accessKeySecret: new(""),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{
|
||||
Settings: &codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new(""),
|
||||
AccessKeySecret: new(""),
|
||||
},
|
||||
{
|
||||
name: "AccessKeyOnly",
|
||||
accessKey: new(""),
|
||||
missingField: "settings.access_key",
|
||||
detail: "access_key_secret is set, but access_key is missing or empty",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "AccessKeySecretOnly",
|
||||
accessKeySecret: new(""),
|
||||
missingField: "settings.access_key_secret",
|
||||
detail: "access_key is set, but access_key_secret is missing or empty",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
req := codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: "merge-clear-" + strings.ToLower(tt.name),
|
||||
DisplayName: "Original Bedrock provider",
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new("AKIA-old"), //nolint:gosec // test fixture
|
||||
AccessKeySecret: new("secret-old"),
|
||||
},
|
||||
},
|
||||
}
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
created, err := client.CreateAIProvider(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gocritic // Test reads the row to verify write-only fields.
|
||||
row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID)
|
||||
require.NoError(t, err)
|
||||
persisted, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, persisted.Bedrock)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKey)
|
||||
require.Equal(t, "", *persisted.Bedrock.AccessKey)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKeySecret)
|
||||
require.Equal(t, "", *persisted.Bedrock.AccessKeySecret)
|
||||
patch := codersdk.UpdateAIProviderRequest{
|
||||
DisplayName: new("Updated Bedrock provider"),
|
||||
Settings: &codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-west-2",
|
||||
Model: "anthropic.claude-3-7-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: tt.accessKey,
|
||||
AccessKeySecret: tt.accessKeySecret,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.UpdateAIProvider(ctx, created.Name, patch)
|
||||
expected := req.Settings
|
||||
if tt.missingField != "" {
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Invalid AI provider request.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Validations, codersdk.ValidationError{
|
||||
Field: tt.missingField,
|
||||
Detail: tt.detail,
|
||||
})
|
||||
reread, err := client.AIProvider(ctx, created.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created, reread)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
expected = *patch.Settings
|
||||
}
|
||||
|
||||
//nolint:gocritic // Test reads the row to verify write-only fields.
|
||||
row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID)
|
||||
require.NoError(t, err)
|
||||
persisted, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, persisted)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExplicitRotatesSecrets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: "merge-rotate",
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new("AKIA-old"), //nolint:gosec // test fixture, not a real credential
|
||||
AccessKeySecret: new("secret-old"),
|
||||
},
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
accessKey *string
|
||||
accessKeySecret *string
|
||||
expectedAccessKey string
|
||||
expectedAccessKeySecret string
|
||||
}{
|
||||
{
|
||||
name: "Both",
|
||||
accessKey: new("AKIA-new"), //nolint:gosec // test fixture
|
||||
accessKeySecret: new("secret-new"),
|
||||
expectedAccessKey: "AKIA-new",
|
||||
expectedAccessKeySecret: "secret-new",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{
|
||||
Settings: &codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new("AKIA-new"), //nolint:gosec // test fixture, not a real credential
|
||||
AccessKeySecret: new("secret-new"),
|
||||
},
|
||||
{
|
||||
name: "AccessKeyOnly",
|
||||
accessKey: new("AKIA-new"), //nolint:gosec // test fixture
|
||||
expectedAccessKey: "AKIA-new",
|
||||
expectedAccessKeySecret: "secret-old",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "AccessKeySecretOnly",
|
||||
accessKeySecret: new("secret-new"),
|
||||
expectedAccessKey: "AKIA-old",
|
||||
expectedAccessKeySecret: "secret-new",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is the audience for this endpoint.
|
||||
created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeAnthropic,
|
||||
Name: "merge-rotate-" + strings.ToLower(tt.name),
|
||||
Enabled: true,
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: new("AKIA-old"), //nolint:gosec // test fixture, not a real credential
|
||||
AccessKeySecret: new("secret-old"),
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gocritic // Test reads the row to verify write-only fields.
|
||||
row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID)
|
||||
require.NoError(t, err)
|
||||
persisted, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, persisted.Bedrock)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKey)
|
||||
require.Equal(t, "AKIA-new", *persisted.Bedrock.AccessKey)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKeySecret)
|
||||
require.Equal(t, "secret-new", *persisted.Bedrock.AccessKeySecret)
|
||||
_, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{
|
||||
Settings: &codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "anthropic.claude-3-5-sonnet",
|
||||
SmallFastModel: "anthropic.claude-3-5-haiku",
|
||||
AccessKey: tt.accessKey,
|
||||
AccessKeySecret: tt.accessKeySecret,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gocritic // Test reads the row to verify write-only fields.
|
||||
row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID)
|
||||
require.NoError(t, err)
|
||||
persisted, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, persisted.Bedrock)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKey)
|
||||
require.Equal(t, tt.expectedAccessKey, *persisted.Bedrock.AccessKey)
|
||||
require.NotNil(t, persisted.Bedrock.AccessKeySecret)
|
||||
require.Equal(t, tt.expectedAccessKeySecret, *persisted.Bedrock.AccessKeySecret)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateStaticToRole", func(t *testing.T) {
|
||||
|
||||
+50
-7
@@ -16,6 +16,9 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// MaxAIProviderAPIKeys is the maximum number of API keys per AI provider.
|
||||
const MaxAIProviderAPIKeys = 5
|
||||
|
||||
// AIProviderNameRegex mirrors the CHECK constraint on ai_providers.name.
|
||||
// Provider names are lowercase alphanumeric with hyphen separators so
|
||||
// they are safe in URLs.
|
||||
@@ -285,6 +288,7 @@ func (req CreateAIProviderRequest) Validate() []ValidationError {
|
||||
})
|
||||
}
|
||||
if req.Settings.Bedrock != nil {
|
||||
validations = append(validations, req.Settings.Bedrock.ValidateCredentials()...)
|
||||
validations = append(validations, validateAIProviderRoleARN(req.Settings.Bedrock.RoleARN)...)
|
||||
validations = append(validations, validateAIProviderBedrockProtocol(req.Settings.Bedrock.Protocol)...)
|
||||
if req.Settings.Bedrock.ExternalID != "" {
|
||||
@@ -352,6 +356,9 @@ func (req UpdateAIProviderRequest) Validate() []ValidationError {
|
||||
// omitted. Omitting any other field clears it, so the checks below apply
|
||||
// to the patch exactly as they would to what gets stored.
|
||||
if req.Settings != nil && req.Settings.Bedrock != nil {
|
||||
if req.Settings.Bedrock.AccessKey != nil && req.Settings.Bedrock.AccessKeySecret != nil {
|
||||
validations = append(validations, req.Settings.Bedrock.ValidateCredentials()...)
|
||||
}
|
||||
validations = append(validations, validateAIProviderRoleARN(req.Settings.Bedrock.RoleARN)...)
|
||||
validations = append(validations, validateAIProviderBedrockProtocol(req.Settings.Bedrock.Protocol)...)
|
||||
validations = append(validations, validateAIProviderBedrockMantleRegion(*req.Settings.Bedrock)...)
|
||||
@@ -475,14 +482,37 @@ func validateAIProviderBaseURL(raw string) []ValidationError {
|
||||
return validations
|
||||
}
|
||||
|
||||
// validateAIProviderAPIKeys checks that each supplied key is non-empty
|
||||
// and free of leading/trailing whitespace. An empty slice itself is
|
||||
func validateAIProviderKeyCount(count int) []ValidationError {
|
||||
if count > MaxAIProviderAPIKeys {
|
||||
return []ValidationError{{
|
||||
Field: "api_keys",
|
||||
Detail: fmt.Sprintf("api_keys must contain at most %d keys", MaxAIProviderAPIKeys),
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAIProviderKeyUniqueness checks whether key is already in seen.
|
||||
// The field argument is used for error formatting.
|
||||
func ValidateAIProviderKeyUniqueness(key, field string, seen map[string]int) []ValidationError {
|
||||
if prev, ok := seen[key]; ok {
|
||||
return []ValidationError{{
|
||||
Field: field,
|
||||
Detail: fmt.Sprintf("duplicate key already provided at api_keys[%d]", prev),
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAIProviderAPIKeys checks that at most five distinct keys are supplied,
|
||||
// each non-empty and free of leading/trailing whitespace. An empty slice is
|
||||
// permitted: on create it means "no keys yet"; on update it means
|
||||
// "clear all keys". Keys are stored verbatim; surrounding whitespace
|
||||
// would silently corrupt the credential, so callers must trim before
|
||||
// sending.
|
||||
func validateAIProviderAPIKeys(keys []string) []ValidationError {
|
||||
var validations []ValidationError
|
||||
validations := validateAIProviderKeyCount(len(keys))
|
||||
seenKeys := make(map[string]int, len(keys))
|
||||
for i, key := range keys {
|
||||
switch {
|
||||
case key == "":
|
||||
@@ -496,17 +526,23 @@ func validateAIProviderAPIKeys(keys []string) []ValidationError {
|
||||
Detail: "api_keys entries must not contain leading or trailing whitespace",
|
||||
})
|
||||
}
|
||||
if duplicate := ValidateAIProviderKeyUniqueness(key, fmt.Sprintf("api_keys[%d]", i), seenKeys); len(duplicate) > 0 {
|
||||
validations = append(validations, duplicate...)
|
||||
} else {
|
||||
seenKeys[key] = i
|
||||
}
|
||||
}
|
||||
return validations
|
||||
}
|
||||
|
||||
// validateAIProviderKeyMutations checks each entry has exactly one of
|
||||
// ID or APIKey set, that plaintexts are non-empty after trimming, and
|
||||
// that no ID is referenced twice in the same request. An empty slice
|
||||
// itself is permitted (it clears all keys).
|
||||
// ID or APIKey set, that plaintexts are non-empty without surrounding whitespace,
|
||||
// and that no ID or plaintext is repeated. At most five entries are permitted.
|
||||
// An empty slice is permitted (it clears all keys).
|
||||
func validateAIProviderKeyMutations(muts []AIProviderKeyMutation) []ValidationError {
|
||||
var validations []ValidationError
|
||||
validations := validateAIProviderKeyCount(len(muts))
|
||||
seen := make(map[uuid.UUID]int, len(muts))
|
||||
seenKeys := make(map[string]int, len(muts))
|
||||
for i, m := range muts {
|
||||
hasID := m.ID != nil
|
||||
hasKey := m.APIKey != nil
|
||||
@@ -527,6 +563,13 @@ func validateAIProviderKeyMutations(muts []AIProviderKeyMutation) []ValidationEr
|
||||
Detail: "api_key must not contain leading or trailing whitespace",
|
||||
})
|
||||
}
|
||||
if hasKey && !hasID {
|
||||
if duplicate := ValidateAIProviderKeyUniqueness(*m.APIKey, fmt.Sprintf("api_keys[%d].api_key", i), seenKeys); len(duplicate) > 0 {
|
||||
validations = append(validations, duplicate...)
|
||||
} else {
|
||||
seenKeys[*m.APIKey] = i
|
||||
}
|
||||
}
|
||||
if hasID && !hasKey {
|
||||
if prev, ok := seen[*m.ID]; ok {
|
||||
validations = append(validations, ValidationError{
|
||||
|
||||
@@ -78,6 +78,26 @@ func (b AIProviderBedrockSettings) ResolvedProtocol() AIProviderBedrockProtocol
|
||||
return b.Protocol
|
||||
}
|
||||
|
||||
// ValidateCredentials checks that static credentials are paired. Callers must
|
||||
// merge omitted credential fields from storage before validating a patch.
|
||||
func (b AIProviderBedrockSettings) ValidateCredentials() []ValidationError {
|
||||
hasKey := b.AccessKey != nil && *b.AccessKey != ""
|
||||
hasSecret := b.AccessKeySecret != nil && *b.AccessKeySecret != ""
|
||||
if hasKey == hasSecret {
|
||||
return nil
|
||||
}
|
||||
field := "settings.access_key"
|
||||
detail := "access_key_secret is set, but access_key is missing or empty"
|
||||
if hasKey {
|
||||
field = "settings.access_key_secret"
|
||||
detail = "access_key is set, but access_key_secret is missing or empty"
|
||||
}
|
||||
return []ValidationError{{
|
||||
Field: field,
|
||||
Detail: detail,
|
||||
}}
|
||||
}
|
||||
|
||||
// IsConfigured reports whether any load-bearing Bedrock field is set,
|
||||
// indicating that the operator wants the provider to authenticate via
|
||||
// AWS Bedrock rather than as a bearer-token Anthropic provider.
|
||||
|
||||
@@ -160,6 +160,172 @@ func TestAIProviderSettings_Roundtrip(t *testing.T) {
|
||||
require.Equal(t, orig, got)
|
||||
}
|
||||
|
||||
func TestAIProviderRequest_ValidateAPIKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
keys []string
|
||||
wantCreateField string
|
||||
wantUpdateField string
|
||||
wantDetail string
|
||||
}{
|
||||
{name: "Omitted"},
|
||||
{name: "Empty", keys: []string{}},
|
||||
{name: "One", keys: []string{"key-1"}},
|
||||
{name: "Five", keys: []string{"key-1", "key-2", "key-3", "key-4", "key-5"}},
|
||||
{
|
||||
name: "Six",
|
||||
keys: []string{"key-1", "key-2", "key-3", "key-4", "key-5", "key-6"},
|
||||
wantCreateField: "api_keys",
|
||||
wantUpdateField: "api_keys",
|
||||
wantDetail: "api_keys must contain at most 5 keys",
|
||||
},
|
||||
{
|
||||
name: "Duplicate",
|
||||
keys: []string{"key-1", "key-2", "key-1"},
|
||||
wantCreateField: "api_keys[2]",
|
||||
wantUpdateField: "api_keys[2].api_key",
|
||||
wantDetail: "duplicate key already provided at api_keys[0]",
|
||||
},
|
||||
{
|
||||
name: "DuplicateAfterDifferentKey",
|
||||
keys: []string{"key-1", "key-2", "key-2"},
|
||||
wantCreateField: "api_keys[2]",
|
||||
wantUpdateField: "api_keys[2].api_key",
|
||||
wantDetail: "duplicate key already provided at api_keys[1]",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
create := codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "keys",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeys: tc.keys,
|
||||
}
|
||||
createValidations := create.Validate()
|
||||
if tc.wantCreateField == "" {
|
||||
require.Empty(t, createValidations)
|
||||
} else {
|
||||
require.Len(t, createValidations, 1)
|
||||
require.Equal(t, tc.wantCreateField, createValidations[0].Field)
|
||||
require.Equal(t, tc.wantDetail, createValidations[0].Detail)
|
||||
for _, key := range tc.keys {
|
||||
require.NotContains(t, createValidations[0].Detail, key)
|
||||
}
|
||||
}
|
||||
|
||||
muts := make([]codersdk.AIProviderKeyMutation, 0, len(tc.keys))
|
||||
for _, key := range tc.keys {
|
||||
muts = append(muts, codersdk.AIProviderKeyMutation{APIKey: new(key)})
|
||||
}
|
||||
update := codersdk.UpdateAIProviderRequest{APIKeys: &muts}
|
||||
updateValidations := update.Validate()
|
||||
if tc.wantUpdateField == "" {
|
||||
require.Empty(t, updateValidations)
|
||||
} else {
|
||||
require.Len(t, updateValidations, 1)
|
||||
require.Equal(t, tc.wantUpdateField, updateValidations[0].Field)
|
||||
require.Equal(t, tc.wantDetail, updateValidations[0].Detail)
|
||||
for _, key := range tc.keys {
|
||||
require.NotContains(t, updateValidations[0].Detail, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAIProviderKeyUniqueness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seen := map[string]int{"key-1": 1}
|
||||
require.Empty(t, codersdk.ValidateAIProviderKeyUniqueness("key-2", "api_keys[2]", seen))
|
||||
require.Equal(t, map[string]int{"key-1": 1}, seen)
|
||||
|
||||
require.Equal(t, []codersdk.ValidationError{{
|
||||
Field: "api_keys[3]",
|
||||
Detail: "duplicate key already provided at api_keys[1]",
|
||||
}}, codersdk.ValidateAIProviderKeyUniqueness("key-1", "api_keys[3]", seen))
|
||||
require.Equal(t, map[string]int{"key-1": 1}, seen)
|
||||
}
|
||||
|
||||
func TestAIProviderRequest_ValidateBedrockCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key *string
|
||||
secret *string
|
||||
wantField string
|
||||
wantDetail string
|
||||
}{
|
||||
{name: "Omitted"},
|
||||
{name: "Empty", key: new(""), secret: new("")},
|
||||
{name: "EmptyKey", key: new("")},
|
||||
{name: "EmptySecret", secret: new("")},
|
||||
{name: "Paired", key: new("key"), secret: new("longer-secret")},
|
||||
{
|
||||
name: "MissingKey",
|
||||
secret: new("secret"),
|
||||
wantField: "settings.access_key",
|
||||
wantDetail: "access_key_secret is set, but access_key is missing or empty",
|
||||
},
|
||||
{
|
||||
name: "MissingSecret",
|
||||
key: new("key"),
|
||||
wantField: "settings.access_key_secret",
|
||||
wantDetail: "access_key is set, but access_key_secret is missing or empty",
|
||||
},
|
||||
{
|
||||
name: "ClearedKey",
|
||||
key: new(""),
|
||||
secret: new("secret"),
|
||||
wantField: "settings.access_key",
|
||||
wantDetail: "access_key_secret is set, but access_key is missing or empty",
|
||||
},
|
||||
{
|
||||
name: "ClearedSecret",
|
||||
key: new("key"),
|
||||
secret: new(""),
|
||||
wantField: "settings.access_key_secret",
|
||||
wantDetail: "access_key is set, but access_key_secret is missing or empty",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
Model: "model",
|
||||
SmallFastModel: "small-model",
|
||||
AccessKey: tc.key,
|
||||
AccessKeySecret: tc.secret,
|
||||
}}
|
||||
create := codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeBedrock, Name: "bedrock",
|
||||
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: settings,
|
||||
}
|
||||
var want []codersdk.ValidationError
|
||||
if tc.wantField != "" {
|
||||
want = []codersdk.ValidationError{{
|
||||
Field: tc.wantField, Detail: tc.wantDetail,
|
||||
}}
|
||||
}
|
||||
require.Equal(t, want, create.Validate())
|
||||
require.Equal(t, want, settings.Bedrock.ValidateCredentials())
|
||||
|
||||
update := codersdk.UpdateAIProviderRequest{Settings: &settings}
|
||||
if tc.key == nil || tc.secret == nil {
|
||||
// Omitted credentials are merged from storage by the API.
|
||||
require.Empty(t, update.Validate())
|
||||
} else {
|
||||
require.Equal(t, want, update.Validate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderRequest_ValidateRoleARN(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+6
@@ -6119,6 +6119,12 @@ export interface MatchedProvisioners {
|
||||
*/
|
||||
export const MaxAIModelPricesBytes = 1048576; // 1 MiB
|
||||
|
||||
// From codersdk/aiproviders.go
|
||||
/**
|
||||
* MaxAIProviderAPIKeys is the maximum number of API keys per AI provider.
|
||||
*/
|
||||
export const MaxAIProviderAPIKeys = 5;
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
/**
|
||||
* MaxAISpendLimitMicros is the highest AI spend limit that can be configured,
|
||||
|
||||
Reference in New Issue
Block a user