From 7c69ede9be54b49f664763dccee6c14de9d48a79 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 15 Oct 2026 15:36:15 -0700 Subject: [PATCH] feat(terraform): write-only secret attributes to keep secrets out of state (#46538) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- terraform/provider/CHANGELOG.md | 1 + .../provider/docs/resources/credential.md | 30 +++- .../provider/docs/resources/mcp_server.md | 24 ++- terraform/provider/docs/resources/model.md | 12 +- .../provider/docs/resources/vector_store.md | 2 +- .../provider/litellm/resource_credential.go | 32 +++- .../litellm/resource_credential_crud.go | 47 +++++- .../litellm/resource_credential_crud_test.go | 150 ++++++++++++++++++ .../provider/litellm/resource_mcp_server.go | 51 +++++- .../litellm/resource_mcp_server_crud.go | 17 ++ .../litellm/resource_mcp_server_crud_test.go | 142 +++++++++++++++++ terraform/provider/litellm/resource_model.go | 69 ++++++-- .../provider/litellm/resource_model_crud.go | 15 +- .../provider/litellm/resource_model_test.go | 100 ++++++++++++ .../provider/litellm/resource_vector_store.go | 49 +++++- .../resource_vector_store_crud_test.go | 35 ++++ terraform/provider/litellm/types.go | 9 ++ terraform/provider/litellm/utils.go | 15 ++ 18 files changed, 764 insertions(+), 36 deletions(-) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index d489a023bf2..e679329073b 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -16,6 +16,7 @@ longer signal it. ### Added +- **credential, model, mcp_server**: Write-only secret attributes so secrets never land in Terraform state (Terraform 1.11 or later). `litellm_credential` gains `credential_values_wo` (a JSON object, `jsonencode({...})`, since the SDK cannot mark a map write-only) with `credential_values_wo_version`, and `credential_values` becomes optional with exactly one of the two required. `litellm_model` gains `model_api_key_wo`, `aws_secret_access_key_wo` and `vertex_credentials_wo`, each with a `_wo_version` companion and each conflicting with its in-state twin. `litellm_mcp_server` gains `client_id`, `client_secret_wo` and `auth_value_wo` (with `_wo_version` companions), sent to the proxy as `credentials`, and `auth_type` now accepts every value the proxy does (`oauth2`, `api_key`, `bearer_token`, ...). Every `_wo` value is sent on each apply from the configuration and produces no diff, so bump its `_wo_version` to push a rotated secret. `litellm_vector_store.litellm_params` warns at plan time when it carries a secret-looking key such as `api_key` - **team**: Optional `team_member_model_max_budget` argument on `litellm_team`, a JSON string of per-model budgets applied to every team member and sent as `team_member_model_max_budget` on `/team/new` and `/team/update`; read back from `team_member_budget_table.model_max_budget` on `/team/info` so out-of-band changes show as drift, with `budget_limit`/`time_period` treated as equal to the `max_budget`/`budget_duration` spelling the proxy stores; removing it sends `{}`, which clears every per-model member budget; `tpm_limit`/`rpm_limit` are rejected at plan time because the proxy rejects them on team budgets. Needs a proxy that includes [#43887](https://github.com/BerriAI/litellm/pull/43887): v1.105.0 and v1.106.0-rc.1 accept the field but silently drop it, so every plan shows a diff - **model**: Optional `display_name` argument on `litellm_model`, sent as `model_info.display_name` and returned as `display_name` by `/v1/models`, so client model pickers show a readable name; changes are persisted through `/model/{id}/update` since `/model/update` ignores `model_info`; also exported by the `litellm_model` and `litellm_models` data sources - **key**: Computed `server_metadata` attribute on `litellm_key` exposing every metadata entry the proxy stores, so metadata created outside Terraform is visible in state and drift on it shows on refresh, while `metadata` keeps tracking only the declared entries and updates keep preserving undeclared ones diff --git a/terraform/provider/docs/resources/credential.md b/terraform/provider/docs/resources/credential.md index d75ee33140d..39306f51de2 100644 --- a/terraform/provider/docs/resources/credential.md +++ b/terraform/provider/docs/resources/credential.md @@ -122,12 +122,31 @@ resource "litellm_credential" "azure_openai" { } ``` +### Keeping Secrets Out of State + +```terraform +resource "litellm_credential" "openai_cred" { + credential_name = "openai-api-key" + + credential_info = { + provider = "openai" + } + + credential_values_wo = jsonencode({ + api_key = var.openai_api_key + }) + credential_values_wo_version = 1 +} +``` + ## Argument Reference The following arguments are supported: * `credential_name` - (Required) Name of the credential. This will be used as the identifier for the credential. -* `credential_values` - (Required, Sensitive) Map of sensitive credential values such as API keys, tokens, etc. +* `credential_values` - (Optional, Sensitive) Map of sensitive credential values such as API keys, tokens, etc. Written to the state file in plaintext. Exactly one of `credential_values` and `credential_values_wo` must be set. +* `credential_values_wo` - (Optional, Write-only, Sensitive) JSON object of string values, usually `jsonencode({ api_key = var.key })`. Every value must be a string (pass a service account file as `file(...)`, not `jsondecode(...)`) because the proxy only encrypts string values at rest; nested objects and numbers are rejected before any request. Sent to the proxy on every apply and never written to plan or state. Requires Terraform 1.11 or later and `credential_values_wo_version`. +* `credential_values_wo_version` - (Optional) Integer that triggers an update when `credential_values_wo` changes. Write-only values produce no diff on their own, so bump this number whenever you rotate the secret. * `model_id` - (Optional) Model ID associated with this credential. * `credential_info` - (Optional) Map of additional non-sensitive information about the credential. * `adopt_existing` - (Optional, default `false`) Take over a credential of this name that already exists on the proxy instead of failing. Turning this on overwrites the existing credential's values with the ones in this configuration. @@ -150,4 +169,11 @@ terraform import litellm_credential.example "credential-name" * The `credential_values` field is marked as sensitive and will not be displayed in Terraform output or logs. * Credential values are not read back from the API for security reasons, so they are preserved in the Terraform state. -* Like every Terraform attribute marked `Sensitive`, `credential_values` is still written in plaintext to the state file. Anyone with read access to the state (or state artifacts such as plan files) can recover the configured secrets. Use an encrypted remote backend with tight access controls, and prefer feeding secrets in via variables sourced from a secret manager rather than hardcoding them in configuration. +* Like every Terraform attribute marked `Sensitive`, `credential_values` is still written in plaintext to the state file. Anyone with read access to the state (or state artifacts such as plan files) can recover the configured secrets. Use `credential_values_wo` to keep them out of state entirely, or at least use an encrypted remote backend with tight access controls. + +## Migrating to Write-only Values + +1. Upgrade to Terraform 1.11 or later. +2. Replace `credential_values = { ... }` with `credential_values_wo = jsonencode({ ... })` and add `credential_values_wo_version = 1`. +3. Run `terraform apply`. The proxy receives the same values and the state file no longer carries them. +4. To rotate a secret later, change the value and bump `credential_values_wo_version`. diff --git a/terraform/provider/docs/resources/mcp_server.md b/terraform/provider/docs/resources/mcp_server.md index 77457a5ae55..e09c021a9b7 100644 --- a/terraform/provider/docs/resources/mcp_server.md +++ b/terraform/provider/docs/resources/mcp_server.md @@ -134,6 +134,23 @@ resource "litellm_mcp_server" "enterprise_api_server" { } ``` +### OAuth 2.0 Client Credentials Kept Out of State + +```terraform +resource "litellm_mcp_server" "crm" { + server_name = "crm" + url = "https://mcp.example.com/mcp" + transport = "http" + auth_type = "oauth2" + + client_id = var.crm_client_id + client_secret_wo = var.crm_client_secret + client_secret_wo_version = 1 +} +``` + +The proxy encrypts the credentials at rest and never returns them on read, so the only copy outside the proxy is the variable you pass in. + ## Argument Reference The following arguments are supported: @@ -149,7 +166,12 @@ The following arguments are supported: * `alias` - (Optional) Alias for the MCP server. Used for easier reference. * `description` - (Optional) Description of the MCP server. * `spec_version` - (Optional) MCP specification version. Defaults to `2024-11-05`. -* `auth_type` - (Optional) Authentication type. Valid values: `none`, `bearer`, `basic`. Defaults to `none`. +* `auth_type` - (Optional) Authentication type the proxy uses against this server. Valid values: `none`, `api_key`, `bearer_token`, `basic`, `authorization`, `oauth2`, `aws_sigv4`, `token`, `oauth2_token_exchange`, `oauth2_id_jag`, `true_passthrough`, `oauth_delegate` (`bearer` is still accepted). Defaults to `none`. +* `auth_value_wo` - (Optional, Write-only, Sensitive) Credential for `api_key`, `bearer_token`, `basic` and `authorization` auth types (the token, or `user:password` for basic). Sent to the proxy on every apply and never written to plan or state. Requires Terraform 1.11 or later and `auth_value_wo_version`. +* `auth_value_wo_version` - (Optional) Integer that triggers an update when `auth_value_wo` changes. Bump it whenever you rotate the token. +* `client_id` - (Optional) OAuth 2.0 client id the proxy presents to the server's token endpoint (`oauth2` auth types). Not a secret, stored in state. +* `client_secret_wo` - (Optional, Write-only, Sensitive) OAuth 2.0 client secret paired with `client_id`. Sent to the proxy on every apply and never written to plan or state. Requires Terraform 1.11 or later, `client_id` and `client_secret_wo_version`. +* `client_secret_wo_version` - (Optional) Integer that triggers an update when `client_secret_wo` changes. Bump it whenever you rotate the secret. * `mcp_access_groups` - (Optional) List of access groups that can use this MCP server. * `command` - (Optional) Command to run for stdio transport. * `args` - (Optional) List of arguments for the command (stdio transport only). Do not pass secrets as arguments; args are shown in plans, stored unencrypted in state, and visible in the server's process list. diff --git a/terraform/provider/docs/resources/model.md b/terraform/provider/docs/resources/model.md index 5bb68bfe918..b7c440b0e65 100644 --- a/terraform/provider/docs/resources/model.md +++ b/terraform/provider/docs/resources/model.md @@ -110,7 +110,9 @@ The following arguments are supported: * `custom_llm_provider` - (Required) string. The LLM provider for this model (e.g., "openai", "anthropic", "azure", "bedrock"). -* `model_api_key` - (Optional) string (Sensitive). The API key for the underlying model provider. Sensitive attributes are hidden from Terraform output but still stored in plaintext in the state file; prefer storing provider secrets in a `litellm_credential` and referencing it via `litellm_credential_name`, and secure your state backend. +* `model_api_key` - (Optional) string (Sensitive). The API key for the underlying model provider. Sensitive attributes are hidden from Terraform output but still stored in plaintext in the state file; use `model_api_key_wo` or a `litellm_credential` referenced via `litellm_credential_name` to keep it out of state. Conflicts with `model_api_key_wo`. +* `model_api_key_wo` - (Optional, Write-only) string (Sensitive). Same as `model_api_key`, but sent to the proxy on every apply and never written to plan or state. Requires Terraform 1.11 or later and `model_api_key_wo_version`. +* `model_api_key_wo_version` - (Optional) integer. Bump it whenever `model_api_key_wo` changes to trigger an update, since write-only values produce no diff on their own. * `model_api_base` - (Optional) string. The base URL for the model provider's API. @@ -169,7 +171,9 @@ The following arguments are supported: * `vertex_location` - (Optional) string. Vertex AI location (e.g., `us-central1`). -* `vertex_credentials` - (Optional) string. Vertex credentials (JSON string or path depending on your setup). +* `vertex_credentials` - (Optional) string (Sensitive). Vertex credentials (JSON string or path depending on your setup). Stored in plaintext in the state file. Conflicts with `vertex_credentials_wo`. +* `vertex_credentials_wo` - (Optional, Write-only) string (Sensitive). Same as `vertex_credentials`, but never written to plan or state. Requires Terraform 1.11 or later and `vertex_credentials_wo_version`. +* `vertex_credentials_wo_version` - (Optional) integer. Bump it whenever `vertex_credentials_wo` changes to trigger an update. * `additional_litellm_params` - (Optional) map(string). A map of arbitrary additional parameters that will be merged into the `litellm_params` object sent to the LiteLLM API. This is intended for provider-specific or experimental options not exposed as dedicated arguments. @@ -213,7 +217,9 @@ The following arguments are supported: * `aws_access_key_id` - (Optional) string (Sensitive). AWS access key ID for AWS-based models. -* `aws_secret_access_key` - (Optional) string (Sensitive). AWS secret access key for AWS-based models. As with `model_api_key`, the value is stored in plaintext in the state file; prefer a `litellm_credential` referenced via `litellm_credential_name` and secure your state backend. +* `aws_secret_access_key` - (Optional) string (Sensitive). AWS secret access key for AWS-based models. As with `model_api_key`, the value is stored in plaintext in the state file; use `aws_secret_access_key_wo` or a `litellm_credential` referenced via `litellm_credential_name` to keep it out of state. Conflicts with `aws_secret_access_key_wo`. +* `aws_secret_access_key_wo` - (Optional, Write-only) string (Sensitive). Same as `aws_secret_access_key`, but never written to plan or state. Requires Terraform 1.11 or later and `aws_secret_access_key_wo_version`. +* `aws_secret_access_key_wo_version` - (Optional) integer. Bump it whenever `aws_secret_access_key_wo` changes to trigger an update. * `aws_region_name` - (Optional) string. AWS region name for AWS-based models. diff --git a/terraform/provider/docs/resources/vector_store.md b/terraform/provider/docs/resources/vector_store.md index b839b327429..273e42c4253 100644 --- a/terraform/provider/docs/resources/vector_store.md +++ b/terraform/provider/docs/resources/vector_store.md @@ -194,7 +194,7 @@ The following arguments are supported: * `vector_store_description` - (Optional) Description of the vector store. * `vector_store_metadata` - (Optional) Map of metadata associated with the vector store. * `litellm_credential_name` - (Optional) Name of the LiteLLM credential to use for authentication. -* `litellm_params` - (Optional, Sensitive) Map of additional parameters specific to the vector store provider. Do not put API keys or other secrets here; this map is stored unencrypted in state. Store secrets in a `litellm_credential` and reference it via `litellm_credential_name`. +* `litellm_params` - (Optional, Sensitive) Map of additional parameters specific to the vector store provider. Do not put API keys or other secrets here; this map is stored unencrypted in state, and `terraform plan` warns when it sees a secret-looking key such as `api_key` or `aws_secret_access_key`. Store secrets in a `litellm_credential` (`credential_values_wo`) and reference it via `litellm_credential_name`. ## Attributes Reference diff --git a/terraform/provider/litellm/resource_credential.go b/terraform/provider/litellm/resource_credential.go index d1e41f6cf56..e3f4d0076bc 100644 --- a/terraform/provider/litellm/resource_credential.go +++ b/terraform/provider/litellm/resource_credential.go @@ -2,6 +2,7 @@ package litellm import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceLiteLLMCredential() *schema.Resource { @@ -33,11 +34,32 @@ func resourceLiteLLMCredential() *schema.Resource { Description: "Additional information about the credential", }, "credential_values": { - Type: schema.TypeMap, - Required: true, - Sensitive: true, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "Sensitive credential values (API keys, tokens, etc.)", + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ExactlyOneOf: []string{"credential_values", "credential_values_wo"}, + Description: "Sensitive credential values (API keys, tokens, etc.). Stored in plaintext in Terraform state; " + + "use credential_values_wo to keep them out of state.", + }, + "credential_values_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"credential_values_wo_version"}, + ValidateFunc: validation.StringIsJSON, + Description: "Write-only alternative to credential_values: a JSON object of credential values " + + "(jsonencode({ api_key = var.key })). Sent to the proxy on every apply and never written to " + + "plan or state. Requires Terraform 1.11 or later. Bump credential_values_wo_version to push a " + + "new value.", + }, + "credential_values_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"credential_values_wo"}, + Description: "Version of credential_values_wo. Write-only values produce no diff, so change this " + + "number whenever credential_values_wo changes to trigger an update.", }, "adopt_existing": { Type: schema.TypeBool, diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go index 6b31a03d404..8896986fb37 100644 --- a/terraform/provider/litellm/resource_credential_crud.go +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -1,6 +1,7 @@ package litellm import ( + "encoding/json" "errors" "fmt" "log" @@ -61,28 +62,58 @@ func retryCredentialRead(d *schema.ResourceData, m interface{}, maxRetries int) return err } -func credentialRequestFromResource(d *schema.ResourceData, credentialName string) CredentialRequest { +func credentialRequestFromResource(d *schema.ResourceData, credentialName string) (CredentialRequest, error) { credInfoMap := make(map[string]interface{}) for k, v := range d.Get("credential_info").(map[string]interface{}) { credInfoMap[k] = v } - credValuesMap := make(map[string]interface{}) - for k, v := range d.Get("credential_values").(map[string]interface{}) { - credValuesMap[k] = v + credValuesMap, err := credentialValuesFromResource(d) + if err != nil { + return CredentialRequest{}, err } return CredentialRequest{ CredentialName: credentialName, ModelID: d.Get("model_id").(string), CredentialInfo: credInfoMap, CredentialValues: credValuesMap, + }, nil +} + +// credentialValuesFromResource prefers the write-only JSON object over the +// in-state map, so a configuration that moved to credential_values_wo never +// sends the stale map it used to carry. +func credentialValuesFromResource(d *schema.ResourceData) (map[string]interface{}, error) { + if raw := writeOnlyString(d, "credential_values_wo"); raw != "" { + var values map[string]interface{} + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, fmt.Errorf("credential_values_wo must be a JSON object: %w", err) + } + if values == nil { + return nil, fmt.Errorf("credential_values_wo must be a JSON object, got null") + } + for key, value := range values { + if _, ok := value.(string); !ok { + return nil, fmt.Errorf("credential_values_wo[%q] must be a string, the proxy only encrypts string values at rest", key) + } + } + return values, nil } + credValuesMap := make(map[string]interface{}) + for k, v := range d.Get("credential_values").(map[string]interface{}) { + credValuesMap[k] = v + } + return credValuesMap, nil } func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) error { client := m.(*Client) credentialName := d.Get("credential_name").(string) - resp, err := MakeRequest(client, "POST", "/credentials", credentialRequestFromResource(d, credentialName)) + req, err := credentialRequestFromResource(d, credentialName) + if err != nil { + return err + } + resp, err := MakeRequest(client, "POST", "/credentials", req) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } @@ -167,7 +198,11 @@ func resourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error } func patchCredential(client *Client, d *schema.ResourceData, credentialName string) error { - resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointCredential, url.PathEscape(credentialName)), credentialRequestFromResource(d, credentialName)) + req, err := credentialRequestFromResource(d, credentialName) + if err != nil { + return err + } + resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointCredential, url.PathEscape(credentialName)), req) if err != nil { return fmt.Errorf("failed to update credential: %w", err) } diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 02ae7ef0671..07eba41dfbf 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -594,3 +595,152 @@ func TestResourceLiteLLMCredentialUpdate_TogglingAdoptExistingSendsNoPatch(t *te t.Fatalf("a real value change must still PATCH; got %d PATCH calls", got) } } + +// credentialDataWithRawConfig builds ResourceData the way a real apply does for a +// write-only attribute: the value is only in the raw config, never in the diff. +func credentialDataWithRawConfig(t *testing.T, id string, rawConfig map[string]cty.Value) *schema.ResourceData { + t.Helper() + r := resourceLiteLLMCredential() + d := r.Data(&terraform.InstanceState{ + ID: id, + Attributes: map[string]string{"id": id, "credential_name": id, "credential_values_wo_version": "1"}, + RawConfig: cty.ObjectVal(rawConfig), + }) + return d +} + +func TestCredentialCreateSendsWriteOnlyValuesFromRawConfig(t *testing.T) { + var created CredentialRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials": + if err := json.NewDecoder(r.Body).Decode(&created); err != nil { + t.Errorf("decode create payload: %v", err) + } + w.Write([]byte(`{"message":"ok"}`)) + case r.Method == http.MethodGet: + w.Write([]byte(`{"credential_name":"wo-cred","credential_info":{}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + d := credentialDataWithRawConfig(t, "wo-cred", map[string]cty.Value{ + "credential_name": cty.StringVal("wo-cred"), + "credential_values_wo": cty.StringVal(`{"api_key":"sk-only-in-config","org_id":"org-1"}`), + }) + + if err := resourceLiteLLMCredentialCreate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("create failed: %v", err) + } + if created.CredentialValues["api_key"] != "sk-only-in-config" || created.CredentialValues["org_id"] != "org-1" { + t.Fatalf("credential_values_wo not sent to the proxy, got %v", created.CredentialValues) + } + if got := d.Get("credential_values").(map[string]interface{}); len(got) != 0 { + t.Fatalf("in-state credential_values should stay empty when credential_values_wo is used, got %v", got) + } +} + +func TestCredentialUpdateSendsWriteOnlyValues(t *testing.T) { + var patched CredentialRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch: + if err := json.NewDecoder(r.Body).Decode(&patched); err != nil { + t.Errorf("decode patch payload: %v", err) + } + w.Write([]byte(`{"message":"ok"}`)) + case http.MethodGet: + w.Write([]byte(`{"credential_name":"wo-cred","credential_info":{}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "wo-cred", + "credential_values_wo": `{"api_key":"sk-rotated"}`, + "credential_values_wo_version": 2, + }) + d.SetId("wo-cred") + + if err := resourceLiteLLMCredentialUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + if patched.CredentialValues["api_key"] != "sk-rotated" { + t.Fatalf("rotated credential_values_wo not sent on update, got %v", patched.CredentialValues) + } +} + +func TestCredentialCreateRejectsNonObjectWriteOnlyValues(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("proxy must not be called with an invalid credential_values_wo: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + cases := map[string]struct{ raw, want string }{ + "array": {`["not","an","object"]`, "credential_values_wo must be a JSON object"}, + "null": {`null`, "credential_values_wo must be a JSON object"}, + "nested object": {`{"vertex_credentials":{"type":"service_account","private_key":"-----BEGIN"}}`, `credential_values_wo["vertex_credentials"] must be a string`}, + "number": {`{"api_key":12345}`, `credential_values_wo["api_key"] must be a string`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "wo-cred", + "credential_values_wo": tc.raw, + "credential_values_wo_version": 1, + }) + + err := resourceLiteLLMCredentialCreate(d, NewClient(srv.URL, "test-key", true)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error containing %q, got %v", tc.want, err) + } + }) + } +} + +func TestCredentialSchemaRequiresExactlyOneValuesSource(t *testing.T) { + cases := map[string]struct { + raw map[string]interface{} + wantErr bool + }{ + "in-state map only": { + raw: map[string]interface{}{"credential_name": "c", "credential_values": map[string]interface{}{"api_key": "k"}}, + wantErr: false, + }, + "write-only only": { + raw: map[string]interface{}{"credential_name": "c", "credential_values_wo": `{"api_key":"k"}`, "credential_values_wo_version": 1}, + wantErr: false, + }, + "neither": { + raw: map[string]interface{}{"credential_name": "c"}, + wantErr: true, + }, + "both": { + raw: map[string]interface{}{ + "credential_name": "c", + "credential_values": map[string]interface{}{"api_key": "k"}, + "credential_values_wo": `{"api_key":"k"}`, + "credential_values_wo_version": 1, + }, + wantErr: true, + }, + "write-only without version": { + raw: map[string]interface{}{"credential_name": "c", "credential_values_wo": `{"api_key":"k"}`}, + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + diags := resourceLiteLLMCredential().Validate(terraform.NewResourceConfigRaw(tc.raw)) + if diags.HasError() != tc.wantErr { + t.Fatalf("wantErr=%v, got diags %v", tc.wantErr, diags) + } + }) + } +} diff --git a/terraform/provider/litellm/resource_mcp_server.go b/terraform/provider/litellm/resource_mcp_server.go index 318925c4367..f87a4dab90c 100644 --- a/terraform/provider/litellm/resource_mcp_server.go +++ b/terraform/provider/litellm/resource_mcp_server.go @@ -58,10 +58,59 @@ func resourceLiteLLMMCPServer() *schema.Resource { Default: "none", ValidateFunc: validation.StringInSlice([]string{ "none", + "api_key", "bearer", + "bearer_token", "basic", + "authorization", + "oauth2", + "aws_sigv4", + "token", + "oauth2_token_exchange", + "oauth2_id_jag", + "true_passthrough", + "oauth_delegate", }, false), - Description: "Authentication type (none, bearer, basic)", + Description: "Authentication type the proxy uses against this server (none, api_key, bearer_token, basic, " + + "authorization, oauth2, aws_sigv4, token, oauth2_token_exchange, oauth2_id_jag, true_passthrough, " + + "oauth_delegate)", + }, + "auth_value_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"auth_value_wo_version"}, + Description: "Write-only credential for api_key, bearer_token, basic and authorization auth types " + + "(the token or user:password). Sent to the proxy on every apply and never written to plan or " + + "state. Requires Terraform 1.11 or later. Bump auth_value_wo_version to push a new value.", + }, + "auth_value_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"auth_value_wo"}, + Description: "Version of auth_value_wo. Change it whenever auth_value_wo changes to trigger an update.", + }, + "client_id": { + Type: schema.TypeString, + Optional: true, + Description: "OAuth 2.0 client id the proxy presents to the server's token endpoint (oauth2 auth types)", + }, + "client_secret_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"client_secret_wo_version", "client_id"}, + Description: "Write-only OAuth 2.0 client secret paired with client_id. Sent to the proxy on every " + + "apply and never written to plan or state. Requires Terraform 1.11 or later. Bump " + + "client_secret_wo_version to push a new value.", + }, + "client_secret_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"client_secret_wo"}, + Description: "Version of client_secret_wo. Change it whenever client_secret_wo changes to trigger an update.", }, "mcp_access_groups": { Type: schema.TypeList, diff --git a/terraform/provider/litellm/resource_mcp_server_crud.go b/terraform/provider/litellm/resource_mcp_server_crud.go index 2a8980960f1..9d0a405392a 100644 --- a/terraform/provider/litellm/resource_mcp_server_crud.go +++ b/terraform/provider/litellm/resource_mcp_server_crud.go @@ -54,6 +54,8 @@ func buildMCPServerRequest(d *schema.ResourceData) *MCPServerRequest { } } + req.Credentials = mcpCredentialsFromResource(d) + // Handle env if env, ok := d.GetOk("env"); ok { envMap := env.(map[string]interface{}) @@ -105,6 +107,21 @@ func buildMCPServerRequest(d *schema.ResourceData) *MCPServerRequest { return req } +// mcpCredentialsFromResource builds the credentials object from the write-only +// secrets and their non-secret companions; nil when none is configured so the +// proxy keeps whatever it already stores. +func mcpCredentialsFromResource(d *schema.ResourceData) *MCPCredentials { + creds := &MCPCredentials{ + AuthValue: writeOnlyString(d, "auth_value_wo"), + ClientID: d.Get("client_id").(string), + ClientSecret: writeOnlyString(d, "client_secret_wo"), + } + if *creds == (MCPCredentials{}) { + return nil + } + return creds +} + // Helper function to update schema data from MCPServerResponse func updateSchemaFromResponse(d *schema.ResourceData, resp *MCPServerResponse) error { d.Set("server_id", resp.ServerID) diff --git a/terraform/provider/litellm/resource_mcp_server_crud_test.go b/terraform/provider/litellm/resource_mcp_server_crud_test.go index 17300701954..fe1eb522239 100644 --- a/terraform/provider/litellm/resource_mcp_server_crud_test.go +++ b/terraform/provider/litellm/resource_mcp_server_crud_test.go @@ -1,9 +1,15 @@ package litellm import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestMCPServerReadDoesNotPersistServerEnv(t *testing.T) { @@ -42,3 +48,139 @@ func TestMCPServerReadDoesNotPersistServerEnv(t *testing.T) { t.Fatalf("read did not populate non-sensitive fields") } } + +func TestMCPServerCreateSendsWriteOnlyCredentials(t *testing.T) { + var created map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/mcp/server" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&created); err != nil { + t.Errorf("decode create payload: %v", err) + } + w.Write([]byte(`{"server_id":"srv-1","server_name":"crm","url":"https://mcp.example.com","transport":"http","auth_type":"oauth2"}`)) + })) + defer srv.Close() + + d := resourceLiteLLMMCPServer().Data(&terraform.InstanceState{ + Attributes: map[string]string{ + "server_name": "crm", + "url": "https://mcp.example.com", + "transport": "http", + "auth_type": "oauth2", + "client_id": "client-123", + "client_secret_wo_version": "1", + }, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "client_secret_wo": cty.StringVal("secret-only-in-config"), + }), + }) + + if err := resourceLiteLLMMCPServerCreate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("create failed: %v", err) + } + creds, ok := created["credentials"].(map[string]interface{}) + if !ok { + t.Fatalf("expected credentials object in create payload, got %v", created) + } + if creds["client_id"] != "client-123" || creds["client_secret"] != "secret-only-in-config" { + t.Fatalf("client_id/client_secret_wo not sent to the proxy, got %v", creds) + } + if _, present := creds["auth_value"]; present { + t.Fatalf("auth_value must be omitted when auth_value_wo is unset, got %v", creds) + } + if d.Id() != "srv-1" { + t.Fatalf("expected id srv-1, got %q", d.Id()) + } +} + +func TestMCPServerUpdateSendsBearerAuthValue(t *testing.T) { + var updated map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&updated); err != nil { + t.Errorf("decode update payload: %v", err) + } + w.Write([]byte(`{"server_id":"srv-1","server_name":"crm","url":"https://mcp.example.com","transport":"http","auth_type":"bearer_token"}`)) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resourceLiteLLMMCPServer().Schema, map[string]interface{}{ + "server_name": "crm", + "url": "https://mcp.example.com", + "transport": "http", + "auth_type": "bearer_token", + "auth_value_wo": "rotated-token", + "auth_value_wo_version": 2, + }) + d.SetId("srv-1") + + if err := resourceLiteLLMMCPServerUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + creds, ok := updated["credentials"].(map[string]interface{}) + if !ok || creds["auth_value"] != "rotated-token" { + t.Fatalf("auth_value_wo not sent on update, got %v", updated["credentials"]) + } +} + +func TestMCPServerRequestOmitsCredentialsWhenNoneConfigured(t *testing.T) { + d := schema.TestResourceDataRaw(t, resourceLiteLLMMCPServer().Schema, map[string]interface{}{ + "server_name": "plain", + "url": "https://mcp.example.com", + "transport": "http", + }) + body, err := json.Marshal(buildMCPServerRequest(d)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "credentials") { + t.Fatalf("request must not carry an empty credentials object, got %s", body) + } +} + +func TestMCPServerSchemaWriteOnlyCredentialRules(t *testing.T) { + base := map[string]interface{}{"server_name": "crm", "url": "https://mcp.example.com", "transport": "http"} + cases := map[string]struct { + extra map[string]interface{} + wantErr bool + }{ + "oauth2 client secret with id and version": { + extra: map[string]interface{}{"auth_type": "oauth2", "client_id": "c", "client_secret_wo": "s", "client_secret_wo_version": 1}, + wantErr: false, + }, + "client secret without client_id": { + extra: map[string]interface{}{"auth_type": "oauth2", "client_secret_wo": "s", "client_secret_wo_version": 1}, + wantErr: true, + }, + "auth value without version": { + extra: map[string]interface{}{"auth_type": "bearer_token", "auth_value_wo": "t"}, + wantErr: true, + }, + "legacy bearer still accepted": { + extra: map[string]interface{}{"auth_type": "bearer"}, + wantErr: false, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + raw := map[string]interface{}{} + for k, v := range base { + raw[k] = v + } + for k, v := range tc.extra { + raw[k] = v + } + diags := resourceLiteLLMMCPServer().Validate(terraform.NewResourceConfigRaw(raw)) + if diags.HasError() != tc.wantErr { + t.Fatalf("wantErr=%v, got diags %v", tc.wantErr, diags) + } + }) + } +} diff --git a/terraform/provider/litellm/resource_model.go b/terraform/provider/litellm/resource_model.go index 85cb1d038bf..1dc029b861c 100644 --- a/terraform/provider/litellm/resource_model.go +++ b/terraform/provider/litellm/resource_model.go @@ -60,9 +60,26 @@ func resourceLiteLLMModel() *schema.Resource { Optional: true, }, "model_api_key": { - Type: schema.TypeString, - Optional: true, - Sensitive: true, + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"model_api_key_wo"}, + }, + "model_api_key_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"model_api_key_wo_version"}, + Description: "Write-only alternative to model_api_key: sent to the proxy on every apply and never " + + "written to plan or state. Requires Terraform 1.11 or later. Bump model_api_key_wo_version to " + + "push a new value.", + }, + "model_api_key_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"model_api_key_wo"}, + Description: "Version of model_api_key_wo. Change it whenever model_api_key_wo changes to trigger an update.", }, "model_api_base": { Type: schema.TypeString, @@ -142,9 +159,26 @@ func resourceLiteLLMModel() *schema.Resource { Sensitive: true, }, "aws_secret_access_key": { - Type: schema.TypeString, - Optional: true, - Sensitive: true, + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"aws_secret_access_key_wo"}, + }, + "aws_secret_access_key_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"aws_secret_access_key_wo_version"}, + Description: "Write-only alternative to aws_secret_access_key: sent to the proxy on every apply and " + + "never written to plan or state. Requires Terraform 1.11 or later. Bump " + + "aws_secret_access_key_wo_version to push a new value.", + }, + "aws_secret_access_key_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"aws_secret_access_key_wo"}, + Description: "Version of aws_secret_access_key_wo. Change it whenever aws_secret_access_key_wo changes to trigger an update.", }, "aws_region_name": { Type: schema.TypeString, @@ -171,9 +205,26 @@ func resourceLiteLLMModel() *schema.Resource { Sensitive: true, }, "vertex_credentials": { - Type: schema.TypeString, - Optional: true, - Sensitive: true, + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"vertex_credentials_wo"}, + }, + "vertex_credentials_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + RequiredWith: []string{"vertex_credentials_wo_version"}, + Description: "Write-only alternative to vertex_credentials: sent to the proxy on every apply and never " + + "written to plan or state. Requires Terraform 1.11 or later. Bump vertex_credentials_wo_version " + + "to push a new value.", + }, + "vertex_credentials_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"vertex_credentials_wo"}, + Description: "Version of vertex_credentials_wo. Change it whenever vertex_credentials_wo changes to trigger an update.", }, "litellm_credential_name": { Type: schema.TypeString, diff --git a/terraform/provider/litellm/resource_model_crud.go b/terraform/provider/litellm/resource_model_crud.go index c7db2a673e5..af9fb5eb570 100644 --- a/terraform/provider/litellm/resource_model_crud.go +++ b/terraform/provider/litellm/resource_model_crud.go @@ -59,6 +59,15 @@ const ( endpointModelDelete = "/model/delete" ) +// secretFromResource reads a secret that can be configured either in state +// (attr) or write-only (attr + "_wo"); the schema forbids setting both. +func secretFromResource(d *schema.ResourceData, attr string) string { + if v := writeOnlyString(d, attr+"_wo"); v != "" { + return v + } + return d.Get(attr).(string) +} + func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) error { client, ok := m.(*Client) if !ok { @@ -114,7 +123,7 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e if outputCostPerMillion := d.Get("output_cost_per_million_tokens").(float64); outputCostPerMillion > 0 { litellmParams["output_cost_per_token"] = outputCostPerMillion / 1000000.0 } - if apiKey := d.Get("model_api_key").(string); apiKey != "" { + if apiKey := secretFromResource(d, "model_api_key"); apiKey != "" { litellmParams["api_key"] = apiKey } if apiBase := d.Get("model_api_base").(string); apiBase != "" { @@ -138,7 +147,7 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e if awsAccessKeyID := d.Get("aws_access_key_id").(string); awsAccessKeyID != "" { litellmParams["aws_access_key_id"] = awsAccessKeyID } - if awsSecretAccessKey := d.Get("aws_secret_access_key").(string); awsSecretAccessKey != "" { + if awsSecretAccessKey := secretFromResource(d, "aws_secret_access_key"); awsSecretAccessKey != "" { litellmParams["aws_secret_access_key"] = awsSecretAccessKey } if awsRegionName := d.Get("aws_region_name").(string); awsRegionName != "" { @@ -156,7 +165,7 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e if vertexLocation := d.Get("vertex_location").(string); vertexLocation != "" { litellmParams["vertex_location"] = vertexLocation } - if vertexCredentials := d.Get("vertex_credentials").(string); vertexCredentials != "" { + if vertexCredentials := secretFromResource(d, "vertex_credentials"); vertexCredentials != "" { litellmParams["vertex_credentials"] = vertexCredentials } if reasoningEffort := d.Get("reasoning_effort").(string); reasoningEffort != "" { diff --git a/terraform/provider/litellm/resource_model_test.go b/terraform/provider/litellm/resource_model_test.go index 0be3c39a3c5..bcb11462e09 100644 --- a/terraform/provider/litellm/resource_model_test.go +++ b/terraform/provider/litellm/resource_model_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -242,3 +243,102 @@ func TestResourceLiteLLMModelUpdateSkipsPatchWhenDisplayNameUnchanged(t *testing t.Fatalf("update failed: %v", err) } } + +func TestResourceLiteLLMModelCreateSendsWriteOnlySecrets(t *testing.T) { + var createPayload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/model/new": + if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil { + t.Errorf("failed to decode create payload: %v", err) + } + w.Write([]byte(modelInfoBody(""))) + case "/model/info": + w.Write([]byte(modelInfoBody(""))) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + d := resourceLiteLLMModel().Data(&terraform.InstanceState{ + Attributes: map[string]string{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + "model_api_key_wo_version": "1", + "aws_secret_access_key_wo_version": "1", + "vertex_credentials_wo_version": "1", + }, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "model_api_key_wo": cty.StringVal("sk-ant-only-in-config"), + "aws_secret_access_key_wo": cty.StringVal("aws-secret-only-in-config"), + "vertex_credentials_wo": cty.StringVal(`{"type":"service_account"}`), + }), + }) + + if err := resourceLiteLLMModelCreate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("create failed: %v", err) + } + + params, ok := createPayload["litellm_params"].(map[string]interface{}) + if !ok { + t.Fatalf("expected litellm_params object in create payload, got %v", createPayload["litellm_params"]) + } + want := map[string]string{ + "api_key": "sk-ant-only-in-config", + "aws_secret_access_key": "aws-secret-only-in-config", + "vertex_credentials": `{"type":"service_account"}`, + } + for key, value := range want { + if params[key] != value { + t.Errorf("litellm_params.%s = %v, want %q", key, params[key], value) + } + } + for _, attr := range []string{"model_api_key", "aws_secret_access_key", "vertex_credentials"} { + if got := d.Get(attr).(string); got != "" { + t.Errorf("in-state %s should stay empty when the _wo variant is used, got %q", attr, got) + } + } +} + +func TestResourceLiteLLMModelSchemaRejectsSecretInStateAndWriteOnly(t *testing.T) { + base := map[string]interface{}{ + "model_name": "m", + "custom_llm_provider": "openai", + "base_model": "gpt-4.1", + } + cases := map[string]struct { + extra map[string]interface{} + wantErr bool + }{ + "write-only with version": { + extra: map[string]interface{}{"model_api_key_wo": "sk", "model_api_key_wo_version": 1}, + wantErr: false, + }, + "write-only without version": { + extra: map[string]interface{}{"model_api_key_wo": "sk"}, + wantErr: true, + }, + "both in-state and write-only": { + extra: map[string]interface{}{"model_api_key": "sk", "model_api_key_wo": "sk", "model_api_key_wo_version": 1}, + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + raw := map[string]interface{}{} + for k, v := range base { + raw[k] = v + } + for k, v := range tc.extra { + raw[k] = v + } + diags := resourceLiteLLMModel().Validate(terraform.NewResourceConfigRaw(raw)) + if diags.HasError() != tc.wantErr { + t.Fatalf("wantErr=%v, got diags %v", tc.wantErr, diags) + } + }) + } +} diff --git a/terraform/provider/litellm/resource_vector_store.go b/terraform/provider/litellm/resource_vector_store.go index a3faf9673c3..cc2eed03cf5 100644 --- a/terraform/provider/litellm/resource_vector_store.go +++ b/terraform/provider/litellm/resource_vector_store.go @@ -1,9 +1,46 @@ package litellm import ( + "fmt" + "slices" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +var secretParamKeys = []string{ + "api_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "vertex_credentials", + "client_secret", + "password", + "token", +} + +// warnOnSecretKeys flags secret-looking keys in an in-state map at plan time, +// since the map is written to state in plaintext. +func warnOnSecretKeys(v interface{}, path cty.Path) diag.Diagnostics { + m, ok := v.(map[string]interface{}) + if !ok { + return nil + } + var diags diag.Diagnostics + for key := range m { + if slices.Contains(secretParamKeys, key) { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("%q is stored in plaintext in Terraform state", key), + Detail: "Move it to a litellm_credential using credential_values_wo and reference it through litellm_credential_name.", + AttributePath: path, + }) + } + } + return diags +} + func resourceLiteLLMVectorStore() *schema.Resource { return &schema.Resource{ Create: resourceLiteLLMVectorStoreCreate, @@ -47,11 +84,13 @@ func resourceLiteLLMVectorStore() *schema.Resource { Description: "Name of the LiteLLM credential to use", }, "litellm_params": { - Type: schema.TypeMap, - Optional: true, - Sensitive: true, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "Additional LiteLLM parameters", + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ValidateDiagFunc: warnOnSecretKeys, + Description: "Additional LiteLLM parameters. Stored in plaintext in Terraform state, so keep secrets " + + "in a litellm_credential (credential_values_wo) referenced through litellm_credential_name.", }, "created_at": { Type: schema.TypeString, diff --git a/terraform/provider/litellm/resource_vector_store_crud_test.go b/terraform/provider/litellm/resource_vector_store_crud_test.go index 485ec54346d..127baefe400 100644 --- a/terraform/provider/litellm/resource_vector_store_crud_test.go +++ b/terraform/provider/litellm/resource_vector_store_crud_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) { @@ -53,3 +56,35 @@ func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) { t.Fatalf("read did not populate non-sensitive fields") } } + +func TestVectorStoreLitellmParamsWarnsOnSecretKeys(t *testing.T) { + diags := resourceLiteLLMVectorStore().Validate(terraform.NewResourceConfigRaw(map[string]interface{}{ + "vector_store_name": "kb", + "custom_llm_provider": "openai", + "litellm_params": map[string]interface{}{ + "api_key": "sk-in-state", + "vector_store_id": "vs-123", + }, + })) + if diags.HasError() { + t.Fatalf("a secret key must warn, not fail: %v", diags) + } + var warned []string + for _, d := range diags { + if d.Severity == diag.Warning { + warned = append(warned, d.Summary) + } + } + if len(warned) != 1 || !strings.Contains(warned[0], `"api_key"`) { + t.Fatalf("expected exactly one warning naming api_key, got %v", warned) + } + + clean := resourceLiteLLMVectorStore().Validate(terraform.NewResourceConfigRaw(map[string]interface{}{ + "vector_store_name": "kb", + "custom_llm_provider": "openai", + "litellm_params": map[string]interface{}{"vector_store_id": "vs-123"}, + })) + if len(clean) != 0 { + t.Fatalf("non-secret keys must not warn, got %v", clean) + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index 03cb145dba2..3af4650de53 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -206,6 +206,15 @@ type MCPServerRequest struct { Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` + Credentials *MCPCredentials `json:"credentials,omitempty"` +} + +// MCPCredentials is the secret material the proxy uses against an MCP server. +// The proxy encrypts it at rest and redacts it on read, so it is never read back. +type MCPCredentials struct { + AuthValue string `json:"auth_value,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` } // MCPServerResponse represents a response from the API containing MCP server information. diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index ce1dae55f59..4b3f13d36c4 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -10,6 +10,9 @@ import ( "io" "net/http" "strings" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func isModelNotFoundError(errResp ErrorResponse) bool { @@ -323,3 +326,15 @@ func handleVectorStoreAPIResponse(resp *http.Response, result interface{}, clien return nil } + +// writeOnlyString returns the config value of a write-only string attribute. +// Terraform never stores write-only values in plan or state, so d.Get sees +// null in a real run; the raw config is the only place the value lives. +func writeOnlyString(d *schema.ResourceData, attr string) string { + raw, diags := d.GetRawConfigAt(cty.GetAttrPath(attr)) + if !diags.HasError() && raw.IsKnown() && !raw.IsNull() && raw.Type() == cty.String { + return raw.AsString() + } + v, _ := d.Get(attr).(string) + return v +}