From 31b3e42c6ed3f99f1fc9e3079d0ee51b79fd5982 Mon Sep 17 00:00:00 2001 From: Patricia Salajova Date: Thu, 21 May 2026 12:01:10 +0200 Subject: [PATCH] Add read-only mode --- .../kv_update_transport.go | 5 ++ cmd/vault-subpath-proxy/main.go | 11 ++- cmd/vault-subpath-proxy/main_test.go | 73 ++++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/cmd/vault-subpath-proxy/kv_update_transport.go b/cmd/vault-subpath-proxy/kv_update_transport.go index c3673f913d0..ca12a08db98 100644 --- a/cmd/vault-subpath-proxy/kv_update_transport.go +++ b/cmd/vault-subpath-proxy/kv_update_transport.go @@ -43,6 +43,7 @@ type kvUpdateTransport struct { // If enabled, the roundtripper will wait for secret // sync to complete. Should only be enabled in tests. synchronousSecretSync bool + readOnly bool privilegedVaultClient *vaultclient.VaultClient // existingSecretKeysByNamespaceName is used in the key validation. @@ -84,6 +85,10 @@ func (k *kvUpdateTransport) RoundTrip(r *http.Request) (*http.Response, error) { if (r.Method != http.MethodPut && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete) || !strings.HasPrefix(r.URL.Path, "/v1/"+k.kvMountPath) { return k.upstream.RoundTrip(r) } + if k.readOnly { + l.Warn("Rejected write operation: vault is in read-only mode") + return newResponse(http.StatusForbidden, r, "Vault is in read-only mode for migration. No secret modifications are allowed."), nil + } if r.Method == http.MethodDelete { resp, err := k.upstream.RoundTrip(r) if err != nil || resp.StatusCode < 200 || resp.StatusCode > 299 { diff --git a/cmd/vault-subpath-proxy/main.go b/cmd/vault-subpath-proxy/main.go index cda4e3bacda..61d5d2baaf6 100644 --- a/cmd/vault-subpath-proxy/main.go +++ b/cmd/vault-subpath-proxy/main.go @@ -39,6 +39,7 @@ type options struct { kubernetesOptions flagutil.KubernetesOptions vaultToken string vaultRole string + readOnly bool } func gatherOptions() (*options, error) { @@ -52,6 +53,7 @@ func gatherOptions() (*options, error) { o.kubernetesOptions.AddFlags(fs) fs.StringVar(&o.vaultToken, "vault-token", "", "Vault token that will be used to detect conflicting secrets. Must have read access to the whole kv store. Mutually exclusive with --vault-token.") fs.StringVar(&o.vaultRole, "vault-role", "", "Vault role to use for detecting conflicting secrets. Must have access to the whole kv store. Mutually exclusive with --vault-token.") + fs.BoolVar(&o.readOnly, "read-only", false, "Reject all write operations to the KV store. Use during Vault-to-GSM migration to freeze secrets.") if err := fs.Parse(os.Args[1:]); err != nil { return nil, fmt.Errorf("failed to parse flags: %w", err) } @@ -92,7 +94,10 @@ func main() { logrus.WithError(err).Fatal("failed to load kubeconfigs") } - server, err := createProxyServer(opts.vaultAddr, opts.listenAddr, opts.kvMountPath, clientGetter, privilegedVaultClient) + if opts.readOnly { + logrus.Warn("Running in read-only mode: all write operations will be rejected") + } + server, err := createProxyServer(opts.vaultAddr, opts.listenAddr, opts.kvMountPath, clientGetter, privilegedVaultClient, opts.readOnly) if err != nil { logrus.WithError(err).Fatal("failed to create server") } @@ -111,7 +116,7 @@ func main() { } } -func createProxyServer(vaultAddr string, listenAddr string, kvMountPath string, clients func() map[string]ctrlruntimeclient.Client, privilegedVaultClient *vaultclient.VaultClient) (*http.Server, error) { +func createProxyServer(vaultAddr string, listenAddr string, kvMountPath string, clients func() map[string]ctrlruntimeclient.Client, privilegedVaultClient *vaultclient.VaultClient, readOnly bool) (*http.Server, error) { vaultClient, err := api.NewClient(&api.Config{Address: vaultAddr}) if err != nil { return nil, fmt.Errorf("failed to create vault client: %w", err) @@ -122,7 +127,7 @@ func createProxyServer(vaultAddr string, listenAddr string, kvMountPath string, } proxy := httputil.NewSingleHostReverseProxy(vaultURL) - transport := &kvUpdateTransport{kvMountPath: kvMountPath, upstream: http.DefaultTransport, kubeClients: clients, privilegedVaultClient: privilegedVaultClient} + transport := &kvUpdateTransport{kvMountPath: kvMountPath, upstream: http.DefaultTransport, kubeClients: clients, privilegedVaultClient: privilegedVaultClient, readOnly: readOnly} transport.initialize() proxy.Transport = transport injector := &kvSubPathInjector{ diff --git a/cmd/vault-subpath-proxy/main_test.go b/cmd/vault-subpath-proxy/main_test.go index 873c02cd473..e3c4a2ed2c4 100644 --- a/cmd/vault-subpath-proxy/main_test.go +++ b/cmd/vault-subpath-proxy/main_test.go @@ -69,7 +69,7 @@ path "secret/metadata/team-1/*" { } proxyServerPort := testhelper.GetFreePort(t) - proxyServer, err := createProxyServer("http://"+vaultAddr, "127.0.0.1:"+proxyServerPort, "secret", nil, rootDirect) + proxyServer, err := createProxyServer("http://"+vaultAddr, "127.0.0.1:"+proxyServerPort, "secret", nil, rootDirect, false) if err != nil { t.Fatalf("failed to create proxy server: %v", err) } @@ -502,6 +502,77 @@ path "secret/metadata/team-1/*" { } }) + t.Run("readOnlyMode", func(t *testing.T) { + kvUpdateTransport.readOnly = true + t.Cleanup(func() { kvUpdateTransport.readOnly = false }) + + readOnlyTestCases := []struct { + name string + operation string + path string + data map[string]string + expectedStatusCode int + expectedErrors []string + }{ + { + name: "Write is rejected", + operation: "write", + path: "secret/read-only-test/should-fail", + data: map[string]string{"key": "value"}, + expectedStatusCode: 403, + expectedErrors: []string{"Vault is in read-only mode for migration. No secret modifications are allowed."}, + }, + { + name: "Delete is rejected", + operation: "delete", + path: "secret/metadata/top-level", + expectedStatusCode: 403, + expectedErrors: []string{"Vault is in read-only mode for migration. No secret modifications are allowed."}, + }, + { + name: "Read still works", + operation: "read", + path: "secret/metadata", + }, + } + + for _, tc := range readOnlyTestCases { + t.Run(tc.name, func(t *testing.T) { + var actualStatusCode int + var actualErrors []string + + var err error + switch tc.operation { + case "write": + err = rootProxy.UpsertKV(tc.path, tc.data) + case "delete": + _, err = rootProxy.Logical().Delete(tc.path) + case "read": + result, readErr := rootProxy.Logical().List(tc.path) + if readErr != nil { + t.Fatalf("expected read to succeed in read-only mode, got error: %v", readErr) + } + if result == nil || result.Data == nil { + t.Fatal("expected non-nil result for read in read-only mode") + } + } + if err != nil { + responseErr, ok := err.(*api.ResponseError) + if !ok { + t.Fatalf("got an error back that was not a response error but a %T", err) + } + actualStatusCode = responseErr.StatusCode + actualErrors = responseErr.Errors + } + if actualStatusCode != tc.expectedStatusCode { + t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, actualStatusCode) + } + if diff := cmp.Diff(actualErrors, tc.expectedErrors); diff != "" { + t.Errorf("actual errors differ from expected: %s", diff) + } + }) + } + }) } func writeKV(client *api.Client, path string, data map[string]string) error { request := client.NewRequest("POST", path)