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>
This commit is contained in:
Yassin Kortam 2026-10-15 15:36:15 -07:00 • committed by GitHub
parent 2eb78bc3df
commit 7c69ede9be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 764 additions and 36 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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)
}

View file

@ -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)
}
})
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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)
}
})
}
}

View file

@ -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,

View file

@ -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 != "" {

View file

@ -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)
}
})
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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
}