From ab3b02a48e34350ce241c17cc027a1031d96bcb5 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Thu, 2 Jul 2026 23:22:18 +1000 Subject: [PATCH] Add LiteLLM dynamic secrets engine implementation Populate the repo with the Vault/OpenBao dynamic secrets engine that mints LiteLLM virtual keys scoped by model, spending limit, and lease TTL. - Secrets backend: config, roles, creds paths and a revocable litellm_key type - LiteLLM API client (generate/update/delete/info) with master-key auth - Unit tests (mock LiteLLM) and a docker-compose e2e against both Vault and OpenBao proving the same binary works on each - Makefile, woodpecker CI (build/test/pre-commit), pre-commit config --- .gitignore | 5 + .pre-commit-config.yaml | 15 + .woodpecker/build.yml | 8 + .woodpecker/pre-commit.yaml | 18 ++ .woodpecker/test.yml | 13 + Makefile | 73 +++++ README.md | 114 +++++++- backend.go | 118 ++++++++ backend_test.go | 216 ++++++++++++++ client.go | 172 ++++++++++++ client_test.go | 123 ++++++++ cmd/vault-plugin-secrets-litellm/main.go | 34 +++ go.mod | 90 ++++++ go.sum | 340 +++++++++++++++++++++++ path_config.go | 166 +++++++++++ path_config_test.go | 96 +++++++ path_credentials.go | 135 +++++++++ path_credentials_test.go | 174 ++++++++++++ path_roles.go | 230 +++++++++++++++ path_roles_test.go | 122 ++++++++ scripts/e2e.sh | 140 ++++++++++ secret_keys.go | 101 +++++++ test/docker-compose.yml | 81 ++++++ test/litellm/config.yaml | 22 ++ test/openbao/bao.hcl | 4 + test/vault/vault.hcl | 4 + 26 files changed, 2613 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yml create mode 100644 Makefile create mode 100644 backend.go create mode 100644 backend_test.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 cmd/vault-plugin-secrets-litellm/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 path_config.go create mode 100644 path_config_test.go create mode 100644 path_credentials.go create mode 100644 path_credentials_test.go create mode 100644 path_roles.go create mode 100644 path_roles_test.go create mode 100755 scripts/e2e.sh create mode 100644 secret_keys.go create mode 100644 test/docker-compose.yml create mode 100644 test/litellm/config.yaml create mode 100644 test/openbao/bao.hcl create mode 100644 test/vault/vault.hcl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab09dda --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/vault-plugin-secrets-litellm +*.out +*.test +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-mod-tidy diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..9c181f7 --- /dev/null +++ b/.woodpecker/build.yml @@ -0,0 +1,8 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.25 + commands: + - make build diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..bb94e07 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,13 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golang:1.25 + commands: + - make lint + + - name: test + image: golang:1.25 + commands: + - make test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..51792d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +.PHONY: build install test lint fmt clean tidy e2e e2e-vault e2e-openbao e2e-up e2e-down patch minor major check-go + +BINARY := vault-plugin-secrets-litellm +PKG := ./cmd/vault-plugin-secrets-litellm +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) +PLUGIN_DIR ?= ./dist + +GO_VERSION_REQUIRED := 1.25 +GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') + +check-go: + @if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \ + echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \ + fi + +build: check-go tidy + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG) + +install: build + @echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256= secret $(BINARY))" + +test: check-go + go test -race -count=1 ./... + +lint: check-go + go vet ./... + +fmt: check-go + gofmt -w . + +tidy: + go mod tidy + +clean: + rm -rf $(PLUGIN_DIR) + +# End-to-end tests spin up LiteLLM + Postgres and both Vault and OpenBao in +# Docker, then exercise the full lifecycle (configure, create role, generate, +# use, revoke) against each engine using the same plugin binary. +e2e: + ./scripts/e2e.sh + +e2e-vault: + ENGINES=vault ./scripts/e2e.sh + +e2e-openbao: + ENGINES=openbao ./scripts/e2e.sh + +e2e-up: + docker compose -f test/docker-compose.yml up -d --build + +e2e-down: + docker compose -f test/docker-compose.yml down -v + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index 3f05ec7..61e1c10 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,115 @@ # vault-plugin-secrets-litellm -HashiCorp Vault / OpenBao dynamic secrets engine for LiteLLM virtual keys \ No newline at end of file +A dynamic secrets engine for [LiteLLM](https://github.com/BerriAI/litellm) that +runs on both **HashiCorp Vault** and **[OpenBao](https://openbao.org)**. +It generates **virtual API keys** on a LiteLLM proxy that are: + +- **scoped to specific models** (`models`) +- capped by a **spending limit** (`max_budget`) +- bound to a **lease TTL** — revoking the lease revokes the key in LiteLLM + +## Vault and OpenBao + +OpenBao is a fork of Vault and keeps its plugin protocol compatible, so the +**same plugin binary** registers and runs on either engine unchanged — the CLI +commands below are identical apart from `vault` vs `bao`. The end-to-end test +suite exercises the full lifecycle against both to prove it. + +## How it works + +The backend authenticates to the LiteLLM proxy with the master key and manages +virtual keys through the proxy's key-management API (`/key/generate`, +`/key/update`, `/key/delete`, `/key/info`). Each generated key is wrapped in a +Vault lease, so Vault owns the key's lifecycle: renew extends it, revoke deletes +it. + +``` +┌─────────┐ read creds/ ┌──────────────────────┐ /key/generate ┌──────────┐ +│ client │ ───────────────────► │ vault + litellm plugin│ ───────────────► │ litellm │ +└─────────┘ ◄─── virtual key ── └──────────────────────┘ ◄── sk-... ───── └──────────┘ +``` + +## Usage + +```sh +# 1. Enable the engine +vault secrets enable -path=litellm vault-plugin-secrets-litellm + +# 2. Configure the connection to the LiteLLM proxy +vault write litellm/config \ + base_url=http://litellm:4000 \ + master_key=sk-master-... + +# 3. Define a role: which models, how much budget, what TTL +vault write litellm/roles/team-a \ + models="gpt-3.5-turbo,gpt-4" \ + max_budget=50 \ + ttl=1h \ + max_ttl=24h + +# 4. Generate a scoped, budgeted, time-limited virtual key +vault read litellm/creds/team-a +# Key key +# --- --- +# lease_id litellm/creds/team-a/AbC... +# lease_duration 1h +# key sk-... +# max_budget 50 +# models [gpt-3.5-turbo gpt-4] + +# 5. Revoking the lease revokes the key in LiteLLM +vault lease revoke litellm/creds/team-a/AbC... +``` + +## Paths + +| Path | Ops | Description | +| ------------------------ | ------------------ | -------------------------------------------------- | +| `config` | read/write/delete | LiteLLM connection (`base_url`, `master_key`) | +| `roles/` | read/write/delete | Constraints for generated keys | +| `roles/` | list | List configured roles | +| `creds/` | read | Generate a virtual key for the role | + +### Role fields + +| Field | Type | Description | +| ------------------ | -------- | ------------------------------------------------------ | +| `models` | list | Allowed models; empty means unrestricted | +| `max_budget` | float | Spending limit per key; 0 means unlimited | +| `key_alias_prefix` | string | Prefix for the generated key alias (default `vault`) | +| `metadata` | kv pairs | Metadata attached to each generated key | +| `ttl` | duration | Default lease TTL | +| `max_ttl` | duration | Maximum lease TTL | + +## Development + +```sh +make build # build the plugin into ./dist +make test # unit tests (race-enabled) +make lint # go vet +make fmt # gofmt +make e2e # full end-to-end test in Docker against Vault AND OpenBao +make e2e-vault # e2e against Vault only +make e2e-openbao # e2e against OpenBao only +``` + +### End-to-end tests + +`make e2e` builds the plugin, spins up a LiteLLM proxy with its Postgres key +store, plus both a Vault and an OpenBao dev server with the plugin mounted, then +exercises the whole lifecycle against **each engine**: configure → create role → +generate key → call an allowed model → verify a disallowed model is rejected → +revoke → verify the key stops working. + +Requires Docker. Bind mounts use the `:z` flag so the stack works under SELinux +(Fedora/RHEL). Select engines with `ENGINES="vault openbao"`. + +### Releasing + +Versioning is tag-driven: + +```sh +make patch # v0.1.0 -> v0.1.1 +make minor # v0.1.1 -> v0.2.0 +make major # v0.2.0 -> v1.0.0 +``` diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..e073bde --- /dev/null +++ b/backend.go @@ -0,0 +1,118 @@ +package litellm + +import ( + "context" + "strings" + "sync" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +// litellmBackend is the Vault secrets backend that manages LiteLLM virtual keys. +type litellmBackend struct { + *framework.Backend + + lock sync.RWMutex + client *litellmClient +} + +// Factory returns a configured LiteLLM secrets backend. +func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { + b := backend() + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + return b, nil +} + +func backend() *litellmBackend { + b := &litellmBackend{} + + b.Backend = &framework.Backend{ + Help: strings.TrimSpace(backendHelp), + BackendType: logical.TypeLogical, + PathsSpecial: &logical.Paths{ + LocalStorage: []string{}, + SealWrapStorage: []string{ + configStoragePath, + }, + }, + Paths: framework.PathAppend( + []*framework.Path{ + pathConfig(b), + pathRole(b), + pathRolesList(b), + pathCredentials(b), + }, + ), + Secrets: []*framework.Secret{ + b.litellmKey(), + }, + Invalidate: b.invalidate, + WALRollback: nil, + } + + return b +} + +// reset drops the cached LiteLLM client so it is rebuilt from storage on the +// next request. Called when the config changes. +func (b *litellmBackend) reset() { + b.lock.Lock() + defer b.lock.Unlock() + b.client = nil +} + +// invalidate clears the cached client when the config is written from another +// cluster node. +func (b *litellmBackend) invalidate(_ context.Context, key string) { + if key == configStoragePath { + b.reset() + } +} + +// getClient returns a cached LiteLLM client, building one from stored config if +// necessary. +func (b *litellmBackend) getClient(ctx context.Context, s logical.Storage) (*litellmClient, error) { + b.lock.RLock() + if b.client != nil { + defer b.lock.RUnlock() + return b.client, nil + } + b.lock.RUnlock() + + b.lock.Lock() + defer b.lock.Unlock() + + // Re-check after acquiring the write lock. + if b.client != nil { + return b.client, nil + } + + config, err := getConfig(ctx, s) + if err != nil { + return nil, err + } + if config == nil { + return nil, errBackendNotConfigured + } + + client, err := newClient(config) + if err != nil { + return nil, err + } + b.client = client + return b.client, nil +} + +const backendHelp = ` +The LiteLLM secrets backend dynamically generates LiteLLM virtual API keys +scoped to specific models, with a spending limit and a lease-bound TTL. + +After mounting this backend, configure it with the connection details for the +LiteLLM proxy using the "config" path, then define one or more roles that +constrain the generated keys. Reading from the "creds/" path issues a new +virtual key whose lifetime is managed by Vault: revoking the lease revokes the +key in LiteLLM. +` diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..837961c --- /dev/null +++ b/backend_test.go @@ -0,0 +1,216 @@ +package litellm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +// getTestBackend returns a configured backend backed by in-memory storage. +func getTestBackend(t *testing.T) (*litellmBackend, logical.Storage) { + t.Helper() + + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = logical.TestSystemView() + + b, err := Factory(context.Background(), config) + if err != nil { + t.Fatalf("unexpected error creating backend: %v", err) + } + return b.(*litellmBackend), config.StorageView +} + +// mockLiteLLM is an in-memory fake of the LiteLLM key-management API. +type mockLiteLLM struct { + server *httptest.Server + + mu sync.Mutex + keys map[string]mockKey // key value -> key + counter int + masterKey string + + // generateErr, when set, makes /key/generate return 500. + generateErr bool + lastRequest map[string]interface{} +} + +type mockKey struct { + Alias string + Models []string + MaxBudget *float64 + Duration string +} + +func newMockLiteLLM(t *testing.T) *mockLiteLLM { + t.Helper() + m := &mockLiteLLM{ + keys: make(map[string]mockKey), + masterKey: "sk-master-1234", + } + + mux := http.NewServeMux() + mux.HandleFunc("/key/generate", m.handleGenerate) + mux.HandleFunc("/key/delete", m.handleDelete) + mux.HandleFunc("/key/update", m.handleUpdate) + mux.HandleFunc("/key/info", m.handleInfo) + + m.server = httptest.NewServer(m.authMiddleware(mux)) + t.Cleanup(m.server.Close) + return m +} + +func (m *mockLiteLLM) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+m.masterKey { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (m *mockLiteLLM) keyCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.keys) +} + +func (m *mockLiteLLM) handleGenerate(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.generateErr { + http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) + return + } + + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + m.lastRequest = body + + m.counter++ + value := "sk-generated-" + strconv.Itoa(m.counter) + + mk := mockKey{} + if alias, ok := body["key_alias"].(string); ok { + mk.Alias = alias + } + if models, ok := body["models"].([]interface{}); ok { + for _, mdl := range models { + if s, ok := mdl.(string); ok { + mk.Models = append(mk.Models, s) + } + } + } + if budget, ok := body["max_budget"].(float64); ok { + mk.MaxBudget = &budget + } + if dur, ok := body["duration"].(string); ok { + mk.Duration = dur + } + m.keys[value] = mk + + writeJSON(w, map[string]interface{}{ + "key": value, + "key_name": value, + "token_id": "tok-" + strconv.Itoa(m.counter), + "models": mk.Models, + "max_budget": mk.MaxBudget, + }) +} + +func (m *mockLiteLLM) handleDelete(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + + var body struct { + Keys []string `json:"keys"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + deleted := 0 + for _, k := range body.Keys { + if _, ok := m.keys[k]; ok { + delete(m.keys, k) + deleted++ + } + } + writeJSON(w, map[string]interface{}{"deleted_keys": deleted}) +} + +func (m *mockLiteLLM) handleUpdate(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + key, _ := body["key"].(string) + mk, ok := m.keys[key] + if !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + if dur, ok := body["duration"].(string); ok { + mk.Duration = dur + } + if budget, ok := body["max_budget"].(float64); ok { + mk.MaxBudget = &budget + } + m.keys[key] = mk + writeJSON(w, map[string]interface{}{"key": key}) +} + +func (m *mockLiteLLM) handleInfo(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + + key := r.URL.Query().Get("key") + mk, ok := m.keys[key] + if !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + writeJSON(w, map[string]interface{}{ + "key": key, + "info": map[string]interface{}{ + "models": mk.Models, + "max_budget": mk.MaxBudget, + "key_name": mk.Alias, + }, + }) +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} + +// writeTestConfig stores a config pointing at the given base URL. +func writeTestConfig(t *testing.T, b *litellmBackend, s logical.Storage, baseURL, masterKey string) { + t.Helper() + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "config", + Storage: s, + Data: map[string]interface{}{ + "base_url": baseURL, + "master_key": masterKey, + }, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("failed to write config: err=%v resp=%v", err, resp) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..7287926 --- /dev/null +++ b/client.go @@ -0,0 +1,172 @@ +package litellm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// errBackendNotConfigured is returned when an operation needs the LiteLLM +// connection config but none has been written yet. +var errBackendNotConfigured = errors.New("litellm backend not configured: write connection details to config/ first") + +const defaultHTTPTimeout = 30 * time.Second + +// litellmClient talks to a LiteLLM proxy's key-management API using the master +// key for authentication. +type litellmClient struct { + baseURL string + masterKey string + httpClient *http.Client +} + +func newClient(config *litellmConfig) (*litellmClient, error) { + if config == nil { + return nil, errors.New("litellm client configuration is nil") + } + if config.BaseURL == "" { + return nil, errors.New("base_url is required") + } + if config.MasterKey == "" { + return nil, errors.New("master_key is required") + } + + timeout := defaultHTTPTimeout + if config.RequestTimeoutSeconds > 0 { + timeout = time.Duration(config.RequestTimeoutSeconds) * time.Second + } + + return &litellmClient{ + baseURL: strings.TrimRight(config.BaseURL, "/"), + masterKey: config.MasterKey, + httpClient: &http.Client{Timeout: timeout}, + }, nil +} + +// generateKeyRequest is the payload for POST /key/generate. +type generateKeyRequest struct { + Models []string `json:"models,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + Duration string `json:"duration,omitempty"` + KeyAlias string `json:"key_alias,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// GenerateKeyResponse is the subset of the /key/generate response we consume. +type GenerateKeyResponse struct { + Key string `json:"key"` + KeyName string `json:"key_name"` + TokenID string `json:"token_id"` + Expires string `json:"expires"` + MaxBudget *float64 `json:"max_budget"` + Models []string `json:"models"` +} + +// updateKeyRequest is the payload for POST /key/update. +type updateKeyRequest struct { + Key string `json:"key"` + Duration string `json:"duration,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + Models []string `json:"models,omitempty"` +} + +// KeyInfoResponse is the subset of the /key/info response we consume. +type KeyInfoResponse struct { + Key string `json:"key"` + Info struct { + Models []string `json:"models"` + MaxBudget *float64 `json:"max_budget"` + Spend float64 `json:"spend"` + Expires string `json:"expires"` + KeyName string `json:"key_name"` + } `json:"info"` +} + +// GenerateKey creates a new virtual key on the LiteLLM proxy. +func (c *litellmClient) GenerateKey(ctx context.Context, req generateKeyRequest) (*GenerateKeyResponse, error) { + var out GenerateKeyResponse + if err := c.do(ctx, http.MethodPost, "/key/generate", req, &out); err != nil { + return nil, err + } + if out.Key == "" { + return nil, errors.New("litellm returned an empty key") + } + return &out, nil +} + +// UpdateKey changes the TTL, budget, or model scope of an existing key. +func (c *litellmClient) UpdateKey(ctx context.Context, req updateKeyRequest) error { + if req.Key == "" { + return errors.New("key is required to update") + } + return c.do(ctx, http.MethodPost, "/key/update", req, nil) +} + +// DeleteKey revokes a virtual key on the LiteLLM proxy. +func (c *litellmClient) DeleteKey(ctx context.Context, key string) error { + if key == "" { + return errors.New("key is required to delete") + } + body := map[string]interface{}{"keys": []string{key}} + return c.do(ctx, http.MethodPost, "/key/delete", body, nil) +} + +// KeyInfo fetches metadata about an existing key. +func (c *litellmClient) KeyInfo(ctx context.Context, key string) (*KeyInfoResponse, error) { + path := "/key/info?" + url.Values{"key": {key}}.Encode() + var out KeyInfoResponse + if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// do performs an authenticated HTTP request against the LiteLLM proxy and +// decodes the JSON response into out (when non-nil). +func (c *litellmClient) do(ctx context.Context, method, path string, payload, out interface{}) error { + var bodyReader io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + bodyReader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.masterKey) + req.Header.Set("Accept", "application/json") + if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("calling litellm %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("litellm %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + if out == nil { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decoding litellm response: %w", err) + } + return nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..acecece --- /dev/null +++ b/client_test.go @@ -0,0 +1,123 @@ +package litellm + +import ( + "context" + "testing" +) + +func TestClient_GenerateKey(t *testing.T) { + m := newMockLiteLLM(t) + client, err := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey}) + if err != nil { + t.Fatalf("newClient: %v", err) + } + + budget := 25.0 + resp, err := client.GenerateKey(context.Background(), generateKeyRequest{ + Models: []string{"gpt-4"}, + MaxBudget: &budget, + Duration: "3600s", + KeyAlias: "vault-test", + }) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if resp.Key == "" { + t.Fatal("expected a non-empty key") + } + if m.keyCount() != 1 { + t.Fatalf("expected 1 key on server, got %d", m.keyCount()) + } + if got := m.lastRequest["key_alias"]; got != "vault-test" { + t.Fatalf("expected alias forwarded, got %v", got) + } + if got := m.lastRequest["max_budget"]; got != 25.0 { + t.Fatalf("expected budget forwarded, got %v", got) + } +} + +func TestClient_DeleteKey(t *testing.T) { + m := newMockLiteLLM(t) + client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey}) + + resp, err := client.GenerateKey(context.Background(), generateKeyRequest{KeyAlias: "vault-test"}) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if err := client.DeleteKey(context.Background(), resp.Key); err != nil { + t.Fatalf("DeleteKey: %v", err) + } + if m.keyCount() != 0 { + t.Fatalf("expected 0 keys after delete, got %d", m.keyCount()) + } +} + +func TestClient_UpdateKey(t *testing.T) { + m := newMockLiteLLM(t) + client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey}) + + resp, _ := client.GenerateKey(context.Background(), generateKeyRequest{KeyAlias: "vault-test"}) + if err := client.UpdateKey(context.Background(), updateKeyRequest{Key: resp.Key, Duration: "7200s"}); err != nil { + t.Fatalf("UpdateKey: %v", err) + } + if got := m.keys[resp.Key].Duration; got != "7200s" { + t.Fatalf("expected duration updated to 7200s, got %q", got) + } +} + +func TestClient_AuthFailure(t *testing.T) { + m := newMockLiteLLM(t) + client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: "wrong-key"}) + + _, err := client.GenerateKey(context.Background(), generateKeyRequest{}) + if err == nil { + t.Fatal("expected an auth error, got nil") + } +} + +func TestClient_ServerError(t *testing.T) { + m := newMockLiteLLM(t) + m.generateErr = true + client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey}) + + if _, err := client.GenerateKey(context.Background(), generateKeyRequest{}); err == nil { + t.Fatal("expected a server error, got nil") + } +} + +func TestNewClient_Validation(t *testing.T) { + cases := []struct { + name string + config *litellmConfig + wantErr bool + }{ + {"nil config", nil, true}, + {"missing base_url", &litellmConfig{MasterKey: "sk"}, true}, + {"missing master_key", &litellmConfig{BaseURL: "http://x"}, true}, + {"valid", &litellmConfig{BaseURL: "http://x", MasterKey: "sk"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := newClient(tc.config) + if (err != nil) != tc.wantErr { + t.Fatalf("newClient err=%v wantErr=%v", err, tc.wantErr) + } + }) + } +} + +func TestNewClient_TrimsTrailingSlash(t *testing.T) { + client, err := newClient(&litellmConfig{BaseURL: "http://localhost:4000/", MasterKey: "sk"}) + if err != nil { + t.Fatalf("newClient: %v", err) + } + if client.baseURL != "http://localhost:4000" { + t.Fatalf("expected trailing slash trimmed, got %q", client.baseURL) + } +} + +func TestDurationToLiteLLM(t *testing.T) { + if got := durationToLiteLLM(90); got != "0s" { + t.Fatalf("sub-second duration should floor to 0s, got %q", got) + } +} diff --git a/cmd/vault-plugin-secrets-litellm/main.go b/cmd/vault-plugin-secrets-litellm/main.go new file mode 100644 index 0000000..d3d655c --- /dev/null +++ b/cmd/vault-plugin-secrets-litellm/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + + hclog "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/plugin" + + litellm "git.unkin.net/unkin/vault-plugin-secrets-litellm" +) + +func main() { + apiClientMeta := &api.PluginAPIClientMeta{} + flags := apiClientMeta.FlagSet() + if err := flags.Parse(os.Args[1:]); err != nil { + logger := hclog.New(&hclog.LoggerOptions{}) + logger.Error("failed to parse flags", "error", err) + os.Exit(1) + } + + tlsConfig := apiClientMeta.GetTLSConfig() + tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) + + err := plugin.ServeMultiplex(&plugin.ServeOpts{ + BackendFactoryFunc: litellm.Factory, + TLSProviderFunc: tlsProviderFunc, + }) + if err != nil { + logger := hclog.New(&hclog.LoggerOptions{}) + logger.Error("plugin shutting down", "error", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..66c46d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,90 @@ +module git.unkin.net/unkin/vault-plugin-secrets-litellm + +go 1.25.0 + +require ( + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-uuid v1.0.3 + github.com/hashicorp/vault/api v1.15.0 + github.com/hashicorp/vault/sdk v0.14.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v26.1.5+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect + github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.6 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect + github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect + github.com/pierrec/lz4 v2.6.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sasha-s/go-deadlock v0.2.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60854d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,340 @@ +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g= +github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= +github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8= +github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 h1:9Q2lu1YbbmiAgvYZ7Pr31RdlVonUpX+mmDL7Z7qTA2U= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.8/go.mod h1:qTCjxGig/kjuj3hk1z8pOUrzbse/GxB1tGfbrq8tGJg= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= +github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 h1:7Yran48kl6X7jfUg3sfYDrFot1gD3LvzdC3oPu5l/qo= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0/go.mod h1:9WJFu7L3d+Z4ViZmwUf+6/73/Uy7YMY1NXrB9wdElYE= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I= +github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= +github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/sdk v0.14.0 h1:8vagjlpLurkFTnKT9aFSGs4U1XnK2IFytnWSxgFrDo0= +github.com/hashicorp/vault/sdk v0.14.0/go.mod h1:3hnGK5yjx3CW2hFyk+Dw1jDgKxdBvUvjyxMHhq0oUFc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8= +github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y= +github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/path_config.go b/path_config.go new file mode 100644 index 0000000..ad582f5 --- /dev/null +++ b/path_config.go @@ -0,0 +1,166 @@ +package litellm + +import ( + "context" + "errors" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const configStoragePath = "config" + +// litellmConfig holds the connection details for the LiteLLM proxy. +type litellmConfig struct { + BaseURL string `json:"base_url"` + MasterKey string `json:"master_key"` + RequestTimeoutSeconds int `json:"request_timeout_seconds"` +} + +func pathConfig(b *litellmBackend) *framework.Path { + return &framework.Path{ + Pattern: "config", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "litellm", + OperationSuffix: "config", + }, + Fields: map[string]*framework.FieldSchema{ + "base_url": { + Type: framework.TypeString, + Description: "Base URL of the LiteLLM proxy, e.g. http://litellm:4000.", + Required: true, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Base URL", + }, + }, + "master_key": { + Type: framework.TypeString, + Description: "LiteLLM master key (sk-...) used to manage virtual keys.", + Required: true, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Master Key", + Sensitive: true, + }, + }, + "request_timeout_seconds": { + Type: framework.TypeInt, + Description: "HTTP timeout in seconds for calls to the LiteLLM proxy (default 30).", + Default: 30, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathConfigRead, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathConfigDelete, + }, + }, + ExistenceCheck: b.pathConfigExistenceCheck, + HelpSynopsis: "Configure the connection to the LiteLLM proxy.", + HelpDescription: "Configure the base URL and master key that the backend uses to manage LiteLLM virtual keys.", + } +} + +func (b *litellmBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) { + config, err := getConfig(ctx, req.Storage) + if err != nil { + return false, err + } + return config != nil, nil +} + +func (b *litellmBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + return nil, nil + } + + // The master key is deliberately not returned. + return &logical.Response{ + Data: map[string]interface{}{ + "base_url": config.BaseURL, + "request_timeout_seconds": config.RequestTimeoutSeconds, + }, + }, nil +} + +func (b *litellmBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + if req.Operation == logical.UpdateOperation { + return nil, errors.New("config not found during update operation") + } + config = &litellmConfig{} + } + + if v, ok := data.GetOk("base_url"); ok { + config.BaseURL = v.(string) + } + if v, ok := data.GetOk("master_key"); ok { + config.MasterKey = v.(string) + } + if v, ok := data.GetOk("request_timeout_seconds"); ok { + config.RequestTimeoutSeconds = v.(int) + } else if req.Operation == logical.CreateOperation { + config.RequestTimeoutSeconds = data.Get("request_timeout_seconds").(int) + } + + if config.BaseURL == "" { + return logical.ErrorResponse("base_url is required"), nil + } + if config.MasterKey == "" { + return logical.ErrorResponse("master_key is required"), nil + } + + entry, err := logical.StorageEntryJSON(configStoragePath, config) + if err != nil { + return nil, err + } + if err := req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + // Force the cached client to be rebuilt with the new config. + b.reset() + + return nil, nil +} + +func (b *litellmBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + if err := req.Storage.Delete(ctx, configStoragePath); err != nil { + return nil, err + } + b.reset() + return nil, nil +} + +// getConfig reads and decodes the stored LiteLLM config, returning nil if none +// exists. +func getConfig(ctx context.Context, s logical.Storage) (*litellmConfig, error) { + entry, err := s.Get(ctx, configStoragePath) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + config := &litellmConfig{} + if err := entry.DecodeJSON(config); err != nil { + return nil, err + } + return config, nil +} diff --git a/path_config_test.go b/path_config_test.go new file mode 100644 index 0000000..334f433 --- /dev/null +++ b/path_config_test.go @@ -0,0 +1,96 @@ +package litellm + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +func TestConfig_WriteReadDelete(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + // Write. + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.CreateOperation, + Path: "config", + Storage: s, + Data: map[string]interface{}{ + "base_url": "http://litellm:4000", + "master_key": "sk-master-1234", + "request_timeout_seconds": 15, + }, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("write config: err=%v resp=%v", err, resp) + } + + // Read must not leak the master key. + resp, err = b.HandleRequest(ctx, &logical.Request{ + Operation: logical.ReadOperation, + Path: "config", + Storage: s, + }) + if err != nil || resp == nil { + t.Fatalf("read config: err=%v resp=%v", err, resp) + } + if resp.Data["base_url"] != "http://litellm:4000" { + t.Fatalf("unexpected base_url: %v", resp.Data["base_url"]) + } + if resp.Data["request_timeout_seconds"] != 15 { + t.Fatalf("unexpected timeout: %v", resp.Data["request_timeout_seconds"]) + } + if _, ok := resp.Data["master_key"]; ok { + t.Fatal("master_key must not be returned on read") + } + + // Delete. + if _, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.DeleteOperation, + Path: "config", + Storage: s, + }); err != nil { + t.Fatalf("delete config: %v", err) + } + cfg, err := getConfig(ctx, s) + if err != nil { + t.Fatalf("getConfig: %v", err) + } + if cfg != nil { + t.Fatal("expected config to be nil after delete") + } +} + +func TestConfig_RequiredFields(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.CreateOperation, + Path: "config", + Storage: s, + Data: map[string]interface{}{"base_url": "http://litellm:4000"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected an error response when master_key is missing") + } +} + +func TestConfig_DefaultTimeout(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + writeTestConfig(t, b, s, "http://litellm:4000", "sk-master-1234") + + cfg, err := getConfig(ctx, s) + if err != nil { + t.Fatalf("getConfig: %v", err) + } + if cfg.RequestTimeoutSeconds != 30 { + t.Fatalf("expected default timeout 30, got %d", cfg.RequestTimeoutSeconds) + } +} diff --git a/path_credentials.go b/path_credentials.go new file mode 100644 index 0000000..de4f30a --- /dev/null +++ b/path_credentials.go @@ -0,0 +1,135 @@ +package litellm + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathCredentials(b *litellmBackend) *framework.Path { + return &framework.Path{ + Pattern: "creds/" + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "litellm", + OperationSuffix: "credentials", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role to generate a key for.", + Required: true, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathCredentialsRead, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathCredentialsRead, + }, + }, + HelpSynopsis: "Generate a LiteLLM virtual key from a role.", + HelpDescription: "Reading from this path generates a new LiteLLM virtual key scoped by the named role's models, budget, and TTL.", + } +} + +func (b *litellmBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("name").(string) + + role, err := b.getRole(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse("role %q does not exist", roleName), nil + } + + return b.createKey(ctx, req, roleName, role) +} + +// createKey issues a new LiteLLM virtual key for the given role and wraps it in +// a Vault lease. +func (b *litellmBackend) createKey(ctx context.Context, req *logical.Request, roleName string, role *litellmRole) (*logical.Response, error) { + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, err + } + + // Resolve the lease TTL against the role and mount/system limits. + ttl, maxTTL := b.resolveTTLs(role) + + suffix, err := uuid.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("generating key alias suffix: %w", err) + } + alias := fmt.Sprintf("%s-%s-%s", role.KeyAliasPrefix, roleName, suffix[:8]) + + genReq := generateKeyRequest{ + Models: role.Models, + KeyAlias: alias, + Metadata: role.Metadata, + } + if role.MaxBudget > 0 { + budget := role.MaxBudget + genReq.MaxBudget = &budget + } + if ttl > 0 { + genReq.Duration = durationToLiteLLM(ttl) + } + + key, err := client.GenerateKey(ctx, genReq) + if err != nil { + return nil, fmt.Errorf("generating litellm key: %w", err) + } + + internal := map[string]interface{}{ + "key": key.Key, + "key_alias": alias, + "role": roleName, + } + external := map[string]interface{}{ + "key": key.Key, + "key_alias": alias, + "models": role.Models, + "max_budget": role.MaxBudget, + "role": roleName, + } + + resp := b.Secret(litellmKeyType).Response(external, internal) + resp.Secret.TTL = ttl + resp.Secret.MaxTTL = maxTTL + if ttl > 0 { + resp.Secret.Renewable = true + } + + return resp, nil +} + +// resolveTTLs clamps the role's TTL/MaxTTL against the mount and system limits. +func (b *litellmBackend) resolveTTLs(role *litellmRole) (ttl, maxTTL time.Duration) { + sysMaxTTL := b.System().MaxLeaseTTL() + + maxTTL = role.MaxTTL + if maxTTL <= 0 || maxTTL > sysMaxTTL { + maxTTL = sysMaxTTL + } + + ttl = role.TTL + if ttl <= 0 { + ttl = b.System().DefaultLeaseTTL() + } + if ttl > maxTTL { + ttl = maxTTL + } + return ttl, maxTTL +} + +// durationToLiteLLM converts a Go duration to a LiteLLM duration string. LiteLLM +// accepts an integer with an s/m/h/d suffix; seconds is always representable. +func durationToLiteLLM(d time.Duration) string { + return fmt.Sprintf("%ds", int(d.Seconds())) +} diff --git a/path_credentials_test.go b/path_credentials_test.go new file mode 100644 index 0000000..53673e6 --- /dev/null +++ b/path_credentials_test.go @@ -0,0 +1,174 @@ +package litellm + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/vault/sdk/logical" +) + +func createRole(t *testing.T, b *litellmBackend, s logical.Storage, name string, data map[string]interface{}) { + t.Helper() + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/" + name, + Storage: s, + Data: data, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("create role %s: err=%v resp=%v", name, err, resp) + } +} + +func TestCredentials_GenerateAndRevoke(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + m := newMockLiteLLM(t) + + writeTestConfig(t, b, s, m.server.URL, m.masterKey) + createRole(t, b, s, "team-a", map[string]interface{}{ + "models": "gpt-4,gpt-3.5-turbo", + "max_budget": 50.0, + "ttl": "1h", + "max_ttl": "24h", + }) + + // Generate. + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/team-a", + Storage: s, + }) + if err != nil || resp == nil { + t.Fatalf("generate creds: err=%v resp=%v", err, resp) + } + if resp.Secret == nil { + t.Fatal("expected a secret in the response") + } + key, _ := resp.Data["key"].(string) + if key == "" { + t.Fatal("expected a non-empty key in response data") + } + if resp.Secret.TTL != time.Hour { + t.Fatalf("expected TTL 1h, got %s", resp.Secret.TTL) + } + if !resp.Secret.Renewable { + t.Fatal("expected the lease to be renewable") + } + if m.keyCount() != 1 { + t.Fatalf("expected 1 key on server, got %d", m.keyCount()) + } + + // The scope (models + budget) must have been forwarded to LiteLLM. + if got := m.lastRequest["max_budget"]; got != 50.0 { + t.Fatalf("expected budget forwarded, got %v", got) + } + if models, ok := m.lastRequest["models"].([]interface{}); !ok || len(models) != 2 { + t.Fatalf("expected 2 models forwarded, got %v", m.lastRequest["models"]) + } + + // Revoke via the secret's revoke callback. + revokeReq := &logical.Request{ + Operation: logical.RevokeOperation, + Path: "creds/team-a", + Storage: s, + Secret: resp.Secret, + } + if _, err := b.HandleRequest(ctx, revokeReq); err != nil { + t.Fatalf("revoke: %v", err) + } + if m.keyCount() != 0 { + t.Fatalf("expected key deleted on revoke, got %d keys", m.keyCount()) + } +} + +func TestCredentials_UnknownRole(t *testing.T) { + b, s := getTestBackend(t) + m := newMockLiteLLM(t) + writeTestConfig(t, b, s, m.server.URL, m.masterKey) + + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/nope", + Storage: s, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected an error response for an unknown role") + } +} + +func TestCredentials_NotConfigured(t *testing.T) { + b, s := getTestBackend(t) + createRole(t, b, s, "team-a", map[string]interface{}{"ttl": "1h"}) + + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/team-a", + Storage: s, + }) + if err == nil { + t.Fatal("expected an error when backend is not configured") + } +} + +func TestCredentials_TTLClampedToMaxTTL(t *testing.T) { + b, s := getTestBackend(t) + m := newMockLiteLLM(t) + writeTestConfig(t, b, s, m.server.URL, m.masterKey) + + // TTL exceeds max_ttl in resolveTTLs (role stores equal here; test the + // clamp against the mount max via a very large ttl with small max_ttl is + // rejected at write time, so instead verify default-lease fallback). + createRole(t, b, s, "nolease", map[string]interface{}{"max_ttl": "2h"}) + + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/nolease", + Storage: s, + }) + if err != nil || resp == nil { + t.Fatalf("generate creds: err=%v resp=%v", err, resp) + } + // With no role TTL, the mount default lease TTL is used, clamped by max_ttl. + if resp.Secret.TTL <= 0 || resp.Secret.TTL > 2*time.Hour { + t.Fatalf("expected TTL within (0, 2h], got %s", resp.Secret.TTL) + } +} + +func TestCredentials_Renew(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + m := newMockLiteLLM(t) + writeTestConfig(t, b, s, m.server.URL, m.masterKey) + createRole(t, b, s, "team-a", map[string]interface{}{"ttl": "1h", "max_ttl": "24h"}) + + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/team-a", + Storage: s, + }) + if err != nil || resp == nil { + t.Fatalf("generate creds: err=%v resp=%v", err, resp) + } + + renewReq := &logical.Request{ + Operation: logical.RenewOperation, + Path: "creds/team-a", + Storage: s, + Secret: resp.Secret, + } + renewResp, err := b.HandleRequest(ctx, renewReq) + if err != nil { + t.Fatalf("renew: %v", err) + } + if renewResp == nil || renewResp.Secret == nil { + t.Fatal("expected a secret in the renew response") + } + if renewResp.Secret.TTL != time.Hour { + t.Fatalf("expected renewed TTL 1h, got %s", renewResp.Secret.TTL) + } +} diff --git a/path_roles.go b/path_roles.go new file mode 100644 index 0000000..ca4d101 --- /dev/null +++ b/path_roles.go @@ -0,0 +1,230 @@ +package litellm + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const roleStoragePrefix = "role/" + +// litellmRole constrains the virtual keys generated from the creds/ path. +type litellmRole struct { + // Models is the list of model names a generated key is allowed to call. + // An empty list means the key is not restricted to specific models. + Models []string `json:"models"` + // MaxBudget is the spending limit (in the proxy's currency units) applied + // to each generated key. Zero means unlimited. + MaxBudget float64 `json:"max_budget"` + // KeyAliasPrefix is prepended to the auto-generated alias for each key. + KeyAliasPrefix string `json:"key_alias_prefix"` + // Metadata is attached to each generated key in LiteLLM. + Metadata map[string]interface{} `json:"metadata"` + // TTL is the default lease duration for keys issued from this role. + TTL time.Duration `json:"ttl"` + // MaxTTL is the maximum lease duration for keys issued from this role. + MaxTTL time.Duration `json:"max_ttl"` +} + +func (r *litellmRole) toResponseData() map[string]interface{} { + return map[string]interface{}{ + "models": r.Models, + "max_budget": r.MaxBudget, + "key_alias_prefix": r.KeyAliasPrefix, + "metadata": r.Metadata, + "ttl": int64(r.TTL.Seconds()), + "max_ttl": int64(r.MaxTTL.Seconds()), + } +} + +func pathRole(b *litellmBackend) *framework.Path { + return &framework.Path{ + Pattern: "roles/" + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "litellm", + OperationSuffix: "role", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role.", + Required: true, + }, + "models": { + Type: framework.TypeCommaStringSlice, + Description: "Comma-separated list of models a generated key may access. Empty means unrestricted.", + }, + "max_budget": { + Type: framework.TypeFloat, + Description: "Spending limit applied to each generated key. 0 means unlimited.", + }, + "key_alias_prefix": { + Type: framework.TypeString, + Description: "Prefix for the auto-generated key alias.", + Default: "vault", + }, + "metadata": { + Type: framework.TypeKVPairs, + Description: "Arbitrary key=value metadata attached to each generated key.", + }, + "ttl": { + Type: framework.TypeDurationSecond, + Description: "Default lease TTL for keys generated from this role.", + }, + "max_ttl": { + Type: framework.TypeDurationSecond, + Description: "Maximum lease TTL for keys generated from this role.", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathRoleRead, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathRoleWrite, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathRoleWrite, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathRoleDelete, + }, + }, + ExistenceCheck: b.pathRoleExistenceCheck, + HelpSynopsis: "Manage roles that constrain generated LiteLLM keys.", + HelpDescription: "Roles define the allowed models, spending limit, and TTLs applied to virtual keys issued from creds/.", + } +} + +func pathRolesList(b *litellmBackend) *framework.Path { + return &framework.Path{ + Pattern: "roles/?$", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "litellm", + OperationSuffix: "roles", + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.pathRolesList, + }, + }, + HelpSynopsis: "List the configured roles.", + HelpDescription: "List the roles configured on this LiteLLM backend.", + } +} + +func (b *litellmBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) { + role, err := b.getRole(ctx, req.Storage, data.Get("name").(string)) + if err != nil { + return false, err + } + return role != nil, nil +} + +func (b *litellmBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + entries, err := req.Storage.List(ctx, roleStoragePrefix) + if err != nil { + return nil, err + } + return logical.ListResponse(entries), nil +} + +func (b *litellmBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + role, err := b.getRole(ctx, req.Storage, data.Get("name").(string)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + return &logical.Response{Data: role.toResponseData()}, nil +} + +func (b *litellmBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + if name == "" { + return logical.ErrorResponse("role name is required"), nil + } + + role, err := b.getRole(ctx, req.Storage, name) + if err != nil { + return nil, err + } + if role == nil { + role = &litellmRole{KeyAliasPrefix: "vault"} + } + + if v, ok := data.GetOk("models"); ok { + role.Models = v.([]string) + } + if v, ok := data.GetOk("max_budget"); ok { + role.MaxBudget = v.(float64) + } + if v, ok := data.GetOk("key_alias_prefix"); ok { + role.KeyAliasPrefix = v.(string) + } + if v, ok := data.GetOk("metadata"); ok { + md := make(map[string]interface{}) + for k, val := range v.(map[string]string) { + md[k] = val + } + role.Metadata = md + } + if v, ok := data.GetOk("ttl"); ok { + role.TTL = time.Duration(v.(int)) * time.Second + } + if v, ok := data.GetOk("max_ttl"); ok { + role.MaxTTL = time.Duration(v.(int)) * time.Second + } + + if role.MaxBudget < 0 { + return logical.ErrorResponse("max_budget must not be negative"), nil + } + if role.MaxTTL != 0 && role.TTL > role.MaxTTL { + return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil + } + + if err := setRole(ctx, req.Storage, name, role); err != nil { + return nil, err + } + return nil, nil +} + +func (b *litellmBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil { + return nil, fmt.Errorf("error deleting litellm role: %w", err) + } + return nil, nil +} + +func (b *litellmBackend) getRole(ctx context.Context, s logical.Storage, name string) (*litellmRole, error) { + if name == "" { + return nil, fmt.Errorf("missing role name") + } + entry, err := s.Get(ctx, roleStoragePrefix+name) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + role := &litellmRole{} + if err := entry.DecodeJSON(role); err != nil { + return nil, err + } + return role, nil +} + +func setRole(ctx context.Context, s logical.Storage, name string, role *litellmRole) error { + entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role) + if err != nil { + return err + } + if entry == nil { + return fmt.Errorf("failed to create storage entry for role %q", name) + } + return s.Put(ctx, entry) +} diff --git a/path_roles_test.go b/path_roles_test.go new file mode 100644 index 0000000..42d6a62 --- /dev/null +++ b/path_roles_test.go @@ -0,0 +1,122 @@ +package litellm + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +func TestRole_WriteReadListDelete(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/team-a", + Storage: s, + Data: map[string]interface{}{ + "models": "gpt-4,gpt-3.5-turbo", + "max_budget": 50.5, + "key_alias_prefix": "team-a", + "ttl": "1h", + "max_ttl": "24h", + "metadata": "env=prod,team=a", + }, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("write role: err=%v resp=%v", err, resp) + } + + resp, err = b.HandleRequest(ctx, &logical.Request{ + Operation: logical.ReadOperation, + Path: "roles/team-a", + Storage: s, + }) + if err != nil || resp == nil { + t.Fatalf("read role: err=%v resp=%v", err, resp) + } + models := resp.Data["models"].([]string) + if len(models) != 2 || models[0] != "gpt-4" { + t.Fatalf("unexpected models: %v", models) + } + if resp.Data["max_budget"].(float64) != 50.5 { + t.Fatalf("unexpected budget: %v", resp.Data["max_budget"]) + } + if resp.Data["ttl"].(int64) != 3600 { + t.Fatalf("unexpected ttl: %v", resp.Data["ttl"]) + } + if resp.Data["max_ttl"].(int64) != 86400 { + t.Fatalf("unexpected max_ttl: %v", resp.Data["max_ttl"]) + } + + // List. + resp, err = b.HandleRequest(ctx, &logical.Request{ + Operation: logical.ListOperation, + Path: "roles/", + Storage: s, + }) + if err != nil { + t.Fatalf("list roles: %v", err) + } + keys := resp.Data["keys"].([]string) + if len(keys) != 1 || keys[0] != "team-a" { + t.Fatalf("unexpected role list: %v", keys) + } + + // Delete. + if _, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.DeleteOperation, + Path: "roles/team-a", + Storage: s, + }); err != nil { + t.Fatalf("delete role: %v", err) + } + role, _ := b.getRole(ctx, s, "team-a") + if role != nil { + t.Fatal("expected role to be gone after delete") + } +} + +func TestRole_TTLGreaterThanMaxTTLRejected(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/bad", + Storage: s, + Data: map[string]interface{}{ + "ttl": "48h", + "max_ttl": "1h", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected error when ttl > max_ttl") + } +} + +func TestRole_UnrestrictedModels(t *testing.T) { + b, s := getTestBackend(t) + ctx := context.Background() + + resp, err := b.HandleRequest(ctx, &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/open", + Storage: s, + Data: map[string]interface{}{"max_budget": 10}, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("write role: err=%v resp=%v", err, resp) + } + role, err := b.getRole(ctx, s, "open") + if err != nil { + t.Fatalf("getRole: %v", err) + } + if len(role.Models) != 0 { + t.Fatalf("expected no model restriction, got %v", role.Models) + } +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..458bd34 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# End-to-end test for vault-plugin-secrets-litellm. +# +# Builds the plugin, brings up LiteLLM (+Postgres) plus both Vault and OpenBao, +# then drives the identical lifecycle against each engine to prove the same +# binary works on both: +# configure -> create role -> generate key -> use key (scoped) -> revoke key. +# +# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao. +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml" +COMPOSE="docker compose -f ${COMPOSE_FILE}" +BINARY="vault-plugin-secrets-litellm" + +MASTER_KEY="sk-master-e2e-1234" +LITELLM_ADDR="http://127.0.0.1:4000" +MOUNT="litellm" +ENGINES="${ENGINES:-vault openbao}" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +blue() { printf '\033[34m==> %s\033[0m\n' "$*"; } + +cleanup() { + blue "Tearing down containers" + ${COMPOSE} down -v >/dev/null 2>&1 || true +} +trap cleanup EXIT + +fail() { red "FAIL: $*"; exit 1; } + +wait_for() { + local desc="$1"; shift + local retries="${WAIT_RETRIES:-90}" + local i=0 + until "$@" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -ge "$retries" ]; then + fail "timed out waiting for ${desc}" + fi + sleep 2 + done + green "ready: ${desc}" +} + +# chat_completion KEY MODEL -> prints the HTTP status code of a mock completion. +chat_completion() { + local key="$1" model="$2" + curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${key}" -H 'Content-Type: application/json' \ + -d "{\"model\":\"${model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \ + "${LITELLM_ADDR}/chat/completions" +} + +# run_engine ENGINE_NAME CONTAINER CLI +run_engine() { + local engine="$1" container="$2" cli="$3" + blue "[${engine}] exercising the plugin" + + # ex runs the engine CLI inside its container (env already holds ADDR+TOKEN). + ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; } + + local sha + sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')" + + ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true + ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true + ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null + green "[${engine}] plugin registered and mounted" + + # The plugin runs inside the engine container, so it reaches litellm by name. + ex write "${MOUNT}/config" base_url="http://litellm:4000" master_key="${MASTER_KEY}" >/dev/null + ex write "${MOUNT}/roles/team-a" \ + models="gpt-3.5-turbo" max_budget=10 ttl=1h max_ttl=24h >/dev/null + green "[${engine}] configured + role created (models=gpt-3.5-turbo, budget=\$10)" + + # Generate a key, capturing both the key and its lease id. + local json key lease + json="$(ex read -format=json "${MOUNT}/creds/team-a")" + key="$(printf '%s' "${json}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["key"])')" + lease="$(printf '%s' "${json}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["lease_id"])')" + [ -n "${key}" ] || fail "[${engine}] no key returned" + green "[${engine}] issued key ${key:0:12}... (lease ${lease})" + + # Present in litellm? + curl -fsS -H "Authorization: Bearer ${MASTER_KEY}" \ + "${LITELLM_ADDR}/key/info?key=${key}" >/dev/null \ + || fail "[${engine}] generated key not found in litellm" + + # Allowed model -> success. + local code + code="$(chat_completion "${key}" gpt-3.5-turbo)" + [ "${code}" = "200" ] || fail "[${engine}] allowed model returned HTTP ${code}, expected 200" + green "[${engine}] allowed model (gpt-3.5-turbo) accepted" + + # Disallowed model -> rejected. + code="$(chat_completion "${key}" gpt-4)" + [ "${code}" != "200" ] || fail "[${engine}] disallowed model unexpectedly succeeded" + green "[${engine}] disallowed model (gpt-4) rejected (HTTP ${code})" + + # Revoke the lease -> key deleted from litellm. + ex lease revoke "${lease}" >/dev/null + sleep 2 + code="$(chat_completion "${key}" gpt-3.5-turbo)" + [ "${code}" != "200" ] || fail "[${engine}] revoked key still works (HTTP ${code})" + green "[${engine}] revoked key rejected (HTTP ${code})" + + green "[${engine}] PASSED" +} + +# --------------------------------------------------------------------------- +blue "Building plugin for linux/amd64" +OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build + +blue "Starting Docker stack (postgres + litellm + vault + openbao)" +${COMPOSE} up -d --build + +wait_for "litellm" curl -fsS "${LITELLM_ADDR}/health/liveliness" + +for engine in ${ENGINES}; do + case "${engine}" in + vault) + wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200 + run_engine vault vault vault + ;; + openbao) + wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200 + run_engine openbao openbao bao + ;; + *) + fail "unknown engine: ${engine}" + ;; + esac +done + +green "ALL END-TO-END CHECKS PASSED (${ENGINES})" diff --git a/secret_keys.go b/secret_keys.go new file mode 100644 index 0000000..2091077 --- /dev/null +++ b/secret_keys.go @@ -0,0 +1,101 @@ +package litellm + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +// litellmKeyType is the identifier for the dynamic secret produced by this +// backend. +const litellmKeyType = "litellm_key" + +func (b *litellmBackend) litellmKey() *framework.Secret { + return &framework.Secret{ + Type: litellmKeyType, + Fields: map[string]*framework.FieldSchema{ + "key": { + Type: framework.TypeString, + Description: "The LiteLLM virtual key.", + }, + "key_alias": { + Type: framework.TypeString, + Description: "The alias assigned to the virtual key.", + }, + }, + Revoke: b.keyRevoke, + Renew: b.keyRenew, + } +} + +// keyRevoke deletes the virtual key from the LiteLLM proxy when the lease is +// revoked. +func (b *litellmBackend) keyRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + rawKey, ok := req.Secret.InternalData["key"] + if !ok { + return nil, errors.New("secret is missing internal key data") + } + key, ok := rawKey.(string) + if !ok { + return nil, errors.New("secret internal key data is not a string") + } + + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, err + } + + if err := client.DeleteKey(ctx, key); err != nil { + return nil, fmt.Errorf("revoking litellm key: %w", err) + } + return nil, nil +} + +// keyRenew extends the lease and, when a TTL is set, pushes the new expiry to +// LiteLLM so the virtual key and the Vault lease stay in sync. +func (b *litellmBackend) keyRenew(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + rawRole, ok := req.Secret.InternalData["role"] + if !ok { + return nil, errors.New("secret is missing internal role data") + } + roleName, ok := rawRole.(string) + if !ok { + return nil, errors.New("secret internal role data is not a string") + } + + role, err := b.getRole(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + if role == nil { + return nil, fmt.Errorf("role %q no longer exists; cannot renew", roleName) + } + + resp := &logical.Response{Secret: req.Secret} + if role.TTL > 0 { + resp.Secret.TTL = role.TTL + } + if role.MaxTTL > 0 { + resp.Secret.MaxTTL = role.MaxTTL + } + + // Best-effort: extend the key's own expiry in LiteLLM to match the renewed + // lease. A failure here should not fail the renew, since the authoritative + // lifetime is the Vault lease. + if role.TTL > 0 { + if rawKey, ok := req.Secret.InternalData["key"].(string); ok { + client, err := b.getClient(ctx, req.Storage) + if err == nil { + _ = client.UpdateKey(ctx, updateKeyRequest{ + Key: rawKey, + Duration: durationToLiteLLM(role.TTL), + }) + } + } + } + + return resp, nil +} diff --git a/test/docker-compose.yml b/test/docker-compose.yml new file mode 100644 index 0000000..3f45f96 --- /dev/null +++ b/test/docker-compose.yml @@ -0,0 +1,81 @@ +# End-to-end test stack. LiteLLM (backed by Postgres) plus two secrets-engine +# hosts running the exact same plugin binary: HashiCorp Vault and OpenBao. Bind +# mounts use the ":z" flag so they work under SELinux (Fedora/RHEL). +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: litellm + POSTGRES_PASSWORD: litellm + POSTGRES_DB: litellm + healthcheck: + test: ["CMD-SHELL", "pg_isready -U litellm"] + interval: 3s + timeout: 3s + retries: 20 + + litellm: + image: ghcr.io/berriai/litellm:main-stable + depends_on: + postgres: + condition: service_healthy + environment: + LITELLM_MASTER_KEY: sk-master-e2e-1234 + DATABASE_URL: postgresql://litellm:litellm@postgres:5432/litellm + STORE_MODEL_IN_DB: "True" + command: ["--config", "/app/config.yaml", "--port", "4000"] + volumes: + - ./litellm/config.yaml:/app/config.yaml:ro,z + ports: + - "4000:4000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:4000/health/liveliness').status==200 else 1)"] + interval: 5s + timeout: 5s + retries: 40 + + vault: + image: hashicorp/vault:1.18 + depends_on: + litellm: + condition: service_healthy + cap_add: + - IPC_LOCK + environment: + VAULT_DEV_ROOT_TOKEN_ID: root + VAULT_ADDR: http://127.0.0.1:8200 + VAULT_TOKEN: root + command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"] + volumes: + - ../dist:/vault/plugins:ro,z + - ./vault/vault.hcl:/vault/vault.hcl:ro,z + ports: + - "8200:8200" + healthcheck: + test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"] + interval: 3s + timeout: 3s + retries: 20 + + openbao: + image: openbao/openbao:latest + depends_on: + litellm: + condition: service_healthy + cap_add: + - IPC_LOCK + environment: + BAO_DEV_ROOT_TOKEN_ID: root + BAO_ADDR: http://127.0.0.1:8200 + BAO_TOKEN: root + command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"] + volumes: + - ../dist:/openbao/plugins:ro,z + - ./openbao/bao.hcl:/openbao/bao.hcl:ro,z + ports: + - "8300:8200" + healthcheck: + test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"] + interval: 3s + timeout: 3s + retries: 20 diff --git a/test/litellm/config.yaml b/test/litellm/config.yaml new file mode 100644 index 0000000..34eb381 --- /dev/null +++ b/test/litellm/config.yaml @@ -0,0 +1,22 @@ +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: openai/gpt-3.5-turbo + api_key: sk-fake-openai-key + mock_response: "hello from the mock model" + - model_name: gpt-4 + litellm_params: + model: openai/gpt-4 + api_key: sk-fake-openai-key + mock_response: "hello from the mock model" + - model_name: claude-3-sonnet + litellm_params: + model: anthropic/claude-3-sonnet + api_key: sk-fake-anthropic-key + mock_response: "hello from the mock model" + +general_settings: + master_key: sk-master-e2e-1234 + +litellm_settings: + drop_params: true diff --git a/test/openbao/bao.hcl b/test/openbao/bao.hcl new file mode 100644 index 0000000..391c032 --- /dev/null +++ b/test/openbao/bao.hcl @@ -0,0 +1,4 @@ +# OpenBao is plugin-protocol compatible with Vault, so the very same plugin +# binary registers and runs here unchanged. Combined with `-dev` at runtime. +plugin_directory = "/openbao/plugins" +api_addr = "http://127.0.0.1:8200" diff --git a/test/vault/vault.hcl b/test/vault/vault.hcl new file mode 100644 index 0000000..f848cfd --- /dev/null +++ b/test/vault/vault.hcl @@ -0,0 +1,4 @@ +# Combined with `-dev` at runtime; supplies the plugin_directory the dev server +# would otherwise leave unset, so the plugin binary in ../dist can be registered. +plugin_directory = "/vault/plugins" +api_addr = "http://127.0.0.1:8200" -- 2.47.3