From 3568e4d9aec971e9a79f2a731d2c3195dbcf4cda Mon Sep 17 00:00:00 2001 From: Dmitriy Aleksandrov Date: Mon, 19 Oct 2026 12:32:15 +0300 Subject: [PATCH 1/3] src: vault driver logs in by AppRole and Kubernetes auth methods, the token is requested again before its lease expires Signed-off-by: Dmitriy Aleksandrov --- drivers/vault/client.go | 195 +++++++++++++++++++++++++++++++----- drivers/vault/login_test.go | 157 +++++++++++++++++++++++++++++ drivers/vault/vault_test.go | 36 +++++++ 3 files changed, 365 insertions(+), 23 deletions(-) create mode 100644 drivers/vault/login_test.go diff --git a/drivers/vault/client.go b/drivers/vault/client.go index 35f5613..f140da5 100644 --- a/drivers/vault/client.go +++ b/drivers/vault/client.go @@ -1,10 +1,12 @@ package vault import ( + "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -13,6 +15,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/insei/tinyconf/v2" @@ -26,6 +29,8 @@ const ( DefaultMount = "secret" // DefaultWatchInterval is the interval of checking the secrets for changes, Vault does not notify about them. DefaultWatchInterval = 30 * time.Second + // KubernetesTokenFile is the token of the service account of the pod, it is used by WithKubernetes by default. + KubernetesTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" ) // requestTimeout limits the requests to Vault. @@ -35,6 +40,8 @@ type options struct { address string token string tokenFile string + login *login + authMount string namespace string mount string kv1 bool @@ -69,6 +76,50 @@ func WithTokenFile(path string) Option { } } +// login is the auth method that gets the token from Vault. +type login struct { + mount string + // data returns the body of the login request, it is called before every login + data func() (map[string]any, error) +} + +// WithAppRole gets the token by the AppRole auth method with the role ID and the secret ID, +// the auth method is mounted at approle by default, it is changed by WithAuthMount. +// The token is requested again before its lease expires. It has priority over the token from the other options. +func WithAppRole(roleID, secretID string) Option { + return func(o *options) { + o.login = &login{mount: "approle", data: func() (map[string]any, error) { + return map[string]any{"role_id": roleID, "secret_id": secretID}, nil + }} + } +} + +// WithKubernetes gets the token by the Kubernetes auth method with the role and the token of the service account +// from the tokenFile, KubernetesTokenFile if it is empty. The file is read before every login, so the rotated token is used. +// The auth method is mounted at kubernetes by default, it is changed by WithAuthMount. +// The token of Vault is requested again before its lease expires. It has priority over the token from the other options. +func WithKubernetes(role, tokenFile string) Option { + if tokenFile == "" { + tokenFile = KubernetesTokenFile + } + return func(o *options) { + o.login = &login{mount: "kubernetes", data: func() (map[string]any, error) { + jwt, err := os.ReadFile(tokenFile) + if err != nil { + return nil, fmt.Errorf("failed to read the token of the service account: %w", err) + } + return map[string]any{"role": role, "jwt": strings.TrimSpace(string(jwt))}, nil + }} + } +} + +// WithAuthMount sets the mount of the auth method of WithAppRole or WithKubernetes. +func WithAuthMount(mount string) Option { + return func(o *options) { + o.authMount = strings.Trim(mount, "/") + } +} + // WithNamespace sets the namespace of Vault Enterprise, by default it is taken from the VAULT_NAMESPACE variable. func WithNamespace(namespace string) Option { return func(o *options) { @@ -121,6 +172,11 @@ func WithWatchInterval(interval time.Duration) Option { type client struct { options + + // token of the login and the time when it should be requested again, zero if it does not expire + mu sync.Mutex + loginToken string + renewAt time.Time } func newClient(o options) *client { @@ -144,11 +200,17 @@ func newClient(o options) *client { if o.httpClient == nil { o.httpClient = http.DefaultClient } + if o.login != nil && o.authMount != "" { + o.login = &login{mount: o.authMount, data: o.login.data} + } return &client{options: o} } -// getToken returns the token, the file with the token is read every time. -func (c *client) getToken() (string, error) { +// getToken returns the token: the token of the login, the file with the token is read every time. +func (c *client) getToken(ctx context.Context) (string, error) { + if c.login != nil { + return c.loginTokenFor(ctx) + } if c.tokenFile == "" { return c.token, nil } @@ -159,6 +221,87 @@ func (c *client) getToken() (string, error) { return strings.TrimSpace(string(data)), nil } +// loginTokenFor returns the token of the login, the new token is requested after 2/3 of the lease of the previous one. +func (c *client) loginTokenFor(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.loginToken != "" && (c.renewAt.IsZero() || time.Now().Before(c.renewAt)) { + return c.loginToken, nil + } + data, err := c.login.data() + if err != nil { + return "", err + } + // the data is the strings, so it is always encoded + body, _ := json.Marshal(data) + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.address+"/v1/auth/"+c.login.mount+"/login", bytes.NewReader(body)) + if err != nil { + return "", err + } + resp, err := c.do(req) + if err != nil { + return "", fmt.Errorf("failed to login by auth/%s: %w", c.login.mount, err) + } + switch resp.status { + case http.StatusOK: + case http.StatusBadRequest, http.StatusForbidden: + // the wrong role or credentials + return "", fmt.Errorf("%w: failed to login by auth/%s: %s", tinyconf.ErrAccessDenied, c.login.mount, vaultErrors(resp.body)) + default: + return "", fmt.Errorf("failed to login by auth/%s: %s: %s", c.login.mount, resp.statusText, vaultErrors(resp.body)) + } + var auth struct { + Auth struct { + ClientToken string `json:"client_token"` + LeaseDuration int `json:"lease_duration"` + } `json:"auth"` + } + if err = json.Unmarshal(resp.body, &auth); err != nil || auth.Auth.ClientToken == "" { + return "", fmt.Errorf("failed to login by auth/%s: no token in the response", c.login.mount) + } + c.loginToken, c.renewAt = auth.Auth.ClientToken, time.Time{} + if lease := time.Duration(auth.Auth.LeaseDuration) * time.Second; lease > 0 { + c.renewAt = time.Now().Add(lease * 2 / 3) + } + return c.loginToken, nil +} + +// resetLogin forgets the token of the login, the token is requested again by the next request: +// the token can be revoked before its lease expires. +func (c *client) resetLogin(token string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.loginToken == token { + c.loginToken = "" + } +} + +// response is the read response of Vault. +type response struct { + status int + statusText string + body []byte +} + +// do sends the request with the namespace and reads the response. +func (c *client) do(req *http.Request) (*response, error) { + if c.namespace != "" { + req.Header.Set("X-Vault-Namespace", c.namespace) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, err + } + return &response{status: resp.StatusCode, statusText: resp.Status, body: body}, nil +} + // secretURL returns the URL of the secret in the KV secrets engine. func (c *client) secretURL(path string) string { full := c.mount + "/data/" + path @@ -168,43 +311,49 @@ func (c *client) secretURL(path string) string { return c.address + "/v1/" + (&url.URL{Path: full}).EscapedPath() } -// read returns the data of the secret and its version: the number of the version for KV version 2, -// the hash of the data for KV version 1. Absent secret is fs.ErrNotExist, -// the token without the permission to read the secret is tinyconf.ErrAccessDenied. -func (c *client) read(ctx context.Context, path string) (map[string]any, string, error) { - token, err := c.getToken() +// readBody requests the secret and returns the token of the request and the body of the response. +func (c *client) readBody(ctx context.Context, path string) (string, []byte, error) { + token, err := c.getToken(ctx) if err != nil { - return nil, "", err + return "", nil, err } ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.secretURL(path), nil) if err != nil { - return nil, "", err + return token, nil, err } if token != "" { req.Header.Set("X-Vault-Token", token) } - if c.namespace != "" { - req.Header.Set("X-Vault-Namespace", c.namespace) - } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + resp, err := c.do(req) if err != nil { - return nil, "", err + return token, nil, err } - switch resp.StatusCode { + switch resp.status { case http.StatusOK: + return token, resp.body, nil case http.StatusNotFound: - return nil, "", fmt.Errorf("secret %s is not found: %w", path, fs.ErrNotExist) + return token, nil, fmt.Errorf("secret %s is not found: %w", path, fs.ErrNotExist) case http.StatusForbidden: - return nil, "", fmt.Errorf("%w: failed to read secret %s: %s", tinyconf.ErrAccessDenied, path, vaultErrors(body)) + return token, nil, fmt.Errorf("%w: failed to read secret %s: %s", tinyconf.ErrAccessDenied, path, vaultErrors(resp.body)) default: - return nil, "", fmt.Errorf("failed to read secret %s: %s: %s", path, resp.Status, vaultErrors(body)) + return token, nil, fmt.Errorf("failed to read secret %s: %s: %s", path, resp.statusText, vaultErrors(resp.body)) + } +} + +// read returns the data of the secret and its version: the number of the version for KV version 2, +// the hash of the data for KV version 1. Absent secret is fs.ErrNotExist, +// the token without the permission to read the secret is tinyconf.ErrAccessDenied. +// The secret is read again with the new token of the login, if the token is denied. +func (c *client) read(ctx context.Context, path string) (map[string]any, string, error) { + token, body, err := c.readBody(ctx, path) + if c.login != nil && token != "" && errors.Is(err, tinyconf.ErrAccessDenied) { + c.resetLogin(token) + _, body, err = c.readBody(ctx, path) + } + if err != nil { + return nil, "", err } if c.kv1 { var secret struct { diff --git a/drivers/vault/login_test.go b/drivers/vault/login_test.go new file mode 100644 index 0000000..3a87efd --- /dev/null +++ b/drivers/vault/login_test.go @@ -0,0 +1,157 @@ +package vault + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/insei/tinyconf/v2" +) + +func TestDriver_AppRole(t *testing.T) { + vault := newFakeVault(map[string]map[string]any{"app": {"name": "app"}}) + defer vault.Close() + vault.login = "approle" + vault.loginBody = map[string]any{"role_id": "role", "secret_id": "secret"} + vault.lease = 3600 + + driver, err := New("app", WithAddress(vault.URL), WithToken("ignored"), WithAppRole("role", "secret"), WithNamespace("team")) + assert.NoError(t, err) + conf, manager, err := parse(t, driver) + assert.NoError(t, err) + assert.Equal(t, "app", conf.Name) + assert.Equal(t, "login-1", vault.lastRequest().Header.Get("X-Vault-Token")) + assert.Equal(t, "team", vault.requests[0].Header.Get("X-Vault-Namespace")) + + t.Run("token is used until 2/3 of the lease", func(t *testing.T) { + assert.NoError(t, manager.Parse(context.Background(), &testConfig{})) + assert.Equal(t, 1, vault.loginCount()) + c := driver.(*vaultDriver).client + assert.WithinDuration(t, time.Now().Add(40*time.Minute), c.renewAt, time.Minute) + + c.renewAt = time.Now().Add(-time.Second) + assert.NoError(t, manager.Parse(context.Background(), &testConfig{})) + assert.Equal(t, 2, vault.loginCount()) + assert.Equal(t, "login-2", vault.lastRequest().Header.Get("X-Vault-Token")) + }) + + t.Run("revoked token is requested again", func(t *testing.T) { + vault.mu.Lock() + vault.token = "other" + vault.mu.Unlock() + assert.NoError(t, manager.Parse(context.Background(), &testConfig{})) + assert.Equal(t, 3, vault.loginCount()) + }) + + t.Run("denied secret is read once more with the new token", func(t *testing.T) { + vault.mu.Lock() + vault.denied["app"] = true + vault.mu.Unlock() + err := manager.Parse(context.Background(), &testConfig{}) + assert.ErrorIs(t, err, tinyconf.ErrAccessDenied) + // Parse reads the version of the secret and the secret, every read logins once more + assert.Equal(t, 5, vault.loginCount()) + }) +} + +func TestDriver_AppRoleWithoutLease(t *testing.T) { + vault := newFakeVault(map[string]map[string]any{"app": {"name": "app"}}) + defer vault.Close() + vault.login = "auth/approle-team" + vault.loginBody = map[string]any{"role_id": "role", "secret_id": "secret"} + + driver, err := New("app", WithAddress(vault.URL), WithAuthMount("/auth/approle-team/"), WithAppRole("role", "secret")) + assert.NoError(t, err) + _, manager, err := parse(t, driver) + assert.NoError(t, err) + assert.True(t, driver.(*vaultDriver).client.renewAt.IsZero()) + assert.NoError(t, manager.Parse(context.Background(), &testConfig{})) + assert.Equal(t, 1, vault.loginCount()) +} + +func TestDriver_Kubernetes(t *testing.T) { + vault := newFakeVault(map[string]map[string]any{"app": {"name": "app"}}) + defer vault.Close() + vault.login = "k8s" + vault.loginBody = map[string]any{"role": "app", "jwt": "first"} + tokenFile := filepath.Join(t.TempDir(), "token") + assert.NoError(t, os.WriteFile(tokenFile, []byte("first\n"), 0o600)) + + driver, err := New("app", WithAddress(vault.URL), WithKubernetes("app", tokenFile), WithAuthMount("k8s")) + assert.NoError(t, err) + conf, manager, err := parse(t, driver) + assert.NoError(t, err) + assert.Equal(t, "app", conf.Name) + + // the rotated token of the service account is read before the next login + vault.mu.Lock() + vault.loginBody = map[string]any{"role": "app", "jwt": "second"} + vault.mu.Unlock() + assert.NoError(t, os.WriteFile(tokenFile, []byte("second"), 0o600)) + driver.(*vaultDriver).client.renewAt = time.Now().Add(-time.Second) + assert.NoError(t, manager.Parse(context.Background(), &testConfig{})) + assert.Equal(t, 2, vault.loginCount()) + + assert.NoError(t, os.Remove(tokenFile)) + driver.(*vaultDriver).client.renewAt = time.Now().Add(-time.Second) + assert.ErrorContains(t, manager.Parse(context.Background(), &testConfig{}), "failed to read the token of the service account") + + o := options{} + WithKubernetes("app", "")(&o) + c := newClient(o) + assert.Equal(t, "kubernetes", c.login.mount) + _, err = c.login.data() + assert.ErrorContains(t, err, KubernetesTokenFile) +} + +func TestDriver_LoginErrors(t *testing.T) { + vault := newFakeVault(map[string]map[string]any{"app": {"name": "app"}}) + defer vault.Close() + vault.login = "approle" + vault.loginBody = map[string]any{"role_id": "role", "secret_id": "secret"} + parseWith := func(t *testing.T, opts ...Option) error { + driver, err := New("app", opts...) + assert.NoError(t, err) + _, _, err = parse(t, driver) + return err + } + + t.Run("wrong credentials", func(t *testing.T) { + err := parseWith(t, WithAddress(vault.URL), WithAppRole("role", "wrong")) + assert.ErrorIs(t, err, tinyconf.ErrAccessDenied) + assert.ErrorContains(t, err, "failed to login by auth/approle: invalid role or secret ID") + assert.Equal(t, 0, vault.loginCount()) + }) + + t.Run("server error", func(t *testing.T) { + vault.loginResponse = func(w http.ResponseWriter) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"errors":["Vault is sealed"]}`)) + } + defer func() { vault.loginResponse = nil }() + err := parseWith(t, WithAddress(vault.URL), WithAppRole("role", "secret")) + assert.ErrorContains(t, err, "failed to login by auth/approle: 503 Service Unavailable: Vault is sealed") + assert.NotErrorIs(t, err, tinyconf.ErrAccessDenied) + }) + + t.Run("no token in the response", func(t *testing.T) { + vault.loginResponse = func(w http.ResponseWriter) { _, _ = w.Write([]byte(`{"auth":null}`)) } + defer func() { vault.loginResponse = nil }() + assert.ErrorContains(t, parseWith(t, WithAddress(vault.URL), WithAppRole("role", "secret")), "no token in the response") + }) + + t.Run("unavailable", func(t *testing.T) { + closed := newFakeVault(nil) + closed.Close() + assert.ErrorContains(t, parseWith(t, WithAddress(closed.URL), WithAppRole("role", "secret")), "failed to login by auth/approle") + }) + + t.Run("wrong address", func(t *testing.T) { + assert.Error(t, parseWith(t, WithAddress("http://[::1"), WithAppRole("role", "secret"))) + }) +} diff --git a/drivers/vault/vault_test.go b/drivers/vault/vault_test.go index ca2e668..d2a53ff 100644 --- a/drivers/vault/vault_test.go +++ b/drivers/vault/vault_test.go @@ -3,12 +3,14 @@ package vault import ( "context" "encoding/json" + "fmt" "io/fs" "math" "net/http" "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -31,6 +33,13 @@ type fakeVault struct { denied map[string]bool token string requests []*http.Request + // login is the auth method that issues the tokens: the mount, the expected body and the lease of the tokens in seconds + login string + loginBody map[string]any + lease int + logins int + // loginResponse replaces the response of the login, if it is set + loginResponse func(w http.ResponseWriter) } func newFakeVault(secrets map[string]map[string]any) *fakeVault { @@ -68,6 +77,10 @@ func (f *fakeVault) handle(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"errors": errs}) } + if r.URL.Path == "/v1/auth/"+f.login+"/login" { + f.handleLogin(w, r, writeErrors) + return + } if f.token != "" && r.Header.Get("X-Vault-Token") != f.token { writeErrors(http.StatusForbidden, "permission denied") return @@ -92,6 +105,29 @@ func (f *fakeVault) handle(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +// handleLogin issues the new token, if the body of the login is expected; the previous tokens are not valid anymore. +func (f *fakeVault) handleLogin(w http.ResponseWriter, r *http.Request, writeErrors func(status int, errs ...string)) { + if f.loginResponse != nil { + f.loginResponse(w) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if !reflect.DeepEqual(body, f.loginBody) { + writeErrors(http.StatusBadRequest, "invalid role or secret ID") + return + } + f.logins++ + f.token = fmt.Sprintf("login-%d", f.logins) + _ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": f.token, "lease_duration": f.lease}}) +} + +func (f *fakeVault) loginCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.logins +} + type server struct { Host string `vault:"host"` Port int `vault:"port"` From dc25c43cf94e654af1f42787863d377a5eca62fa Mon Sep 17 00:00:00 2001 From: Dmitriy Aleksandrov Date: Mon, 19 Oct 2026 12:32:15 +0300 Subject: [PATCH 2/3] tests: e2e: login of the vault driver by AppRole Signed-off-by: Dmitriy Aleksandrov --- e2e/vault_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/e2e/vault_test.go b/e2e/vault_test.go index 1a243de..c9203bc 100644 --- a/e2e/vault_test.go +++ b/e2e/vault_test.go @@ -215,4 +215,32 @@ func TestVault(t *testing.T) { srv.put(t, "reload/config", map[string]any{"name": "second", "database": map[string]any{"port": 6432}}) assert.Equal(t, 6432, next(t, databases).Port) }) + + t.Run("approle", func(t *testing.T) { + srv.put(t, "approle/config", map[string]any{"name": "approle"}) + srv.request(t, http.MethodPost, "sys/auth/approle", map[string]any{"type": "approle"}) + srv.request(t, http.MethodPut, "sys/policies/acl/read-approle", map[string]any{ + "policy": `path "secret/data/approle/*" { capabilities = ["read"] }`, + }) + srv.request(t, http.MethodPost, "auth/approle/role/app", map[string]any{"token_policies": []string{"read-approle"}, "token_ttl": "1h"}) + var roleID, secretID struct { + Data struct { + RoleID string `json:"role_id"` + SecretID string `json:"secret_id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(srv.request(t, http.MethodGet, "auth/approle/role/app/role-id", nil), &roleID)) + require.NoError(t, json.Unmarshal(srv.request(t, http.MethodPost, "auth/approle/role/app/secret-id", nil), &secretID)) + + driver, err := vault.New("approle/config", vault.WithAddress(srv.address), vault.WithAppRole(roleID.Data.RoleID, secretID.Data.SecretID)) + require.NoError(t, err) + conf, _, err := parse(t, driver) + require.NoError(t, err) + assert.Equal(t, "approle", conf.Name) + + driver, err = vault.New("approle/config", vault.WithAddress(srv.address), vault.WithAppRole(roleID.Data.RoleID, "wrong")) + require.NoError(t, err) + _, _, err = parse(t, driver) + assert.ErrorIs(t, err, tinyconf.ErrAccessDenied) + }) } From 6965f99bfb3f1120d1c8e5bcf3da33d6e6f64b53 Mon Sep 17 00:00:00 2001 From: Dmitriy Aleksandrov Date: Mon, 19 Oct 2026 12:32:15 +0300 Subject: [PATCH 3/3] docs: AppRole and Kubernetes login of the vault driver Signed-off-by: Dmitriy Aleksandrov --- Readme.md | 1 + docs/drivers/vault.md | 32 ++++++++++++++++++++++++++++++-- docs/use-cases.md | 4 ++-- docs/why-tinyconf.md | 2 +- skills/tinyconf/SKILL.md | 2 +- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Readme.md b/Readme.md index 492ad66..cf4dedb 100644 --- a/Readme.md +++ b/Readme.md @@ -134,6 +134,7 @@ vaultDriver, err := vault.New("app/config", // vault:"password" inside vault:"da ) ``` The address and the token are taken from `VAULT_ADDR` and `VAULT_TOKEN` or set by options, KV version 2 at `secret/` by default. +The driver can log in itself: `vault.WithAppRole(roleID, secretID)` or `vault.WithKubernetes("app", "")` by the service account of the pod. The secrets are checked for changes every 30 seconds while there are subscriptions. ## etcd diff --git a/docs/drivers/vault.md b/docs/drivers/vault.md index dcf5938..1c72d23 100644 --- a/docs/drivers/vault.md +++ b/docs/drivers/vault.md @@ -58,6 +58,9 @@ vaultDriver, err := vault.New("app/config", | `WithAddress(address)` | `VAULT_ADDR` or `https://127.0.0.1:8200` | URL of Vault | | `WithToken(token)` | `VAULT_TOKEN` | the token | | `WithTokenFile(path)` | | the file with the token, it is read before every request | +| `WithAppRole(roleID, secretID)` | | login by AppRole, see [Authentication](#authentication) | +| `WithKubernetes(role, tokenFile)` | | login by the service account of the pod | +| `WithAuthMount(mount)` | `approle`, `kubernetes` | the mount of the auth method | | `WithNamespace(namespace)` | `VAULT_NAMESPACE` | the namespace of Vault Enterprise | | `WithMount(mount)` | `secret` | the mount of the KV secrets engine | | `WithKVv1()` | KV version 2 | read from the KV secrets engine version 1 | @@ -70,7 +73,32 @@ The first argument of `New` can be an empty string, if the secrets are set by `W ## Authentication -The driver uses a token. Other methods — Kubernetes, AppRole, cloud IAM — are handled by [Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent): it logs in, renews the token and writes it to the file. Point the driver to this file: +The driver logs in by AppRole or by the service account of a Kubernetes pod, or uses a token. + +### AppRole + +```go +vaultDriver, err := vault.New("app/config", + vault.WithAppRole(os.Getenv("VAULT_ROLE_ID"), os.Getenv("VAULT_SECRET_ID")), +) +``` + +### Kubernetes + +```go +// the token of the service account from /var/run/secrets/kubernetes.io/serviceaccount/token +vaultDriver, err := vault.New("app/config", vault.WithKubernetes("app", "")) +``` + +The second argument is the file with the token of the service account, a projected token with the audience of Vault for example. The file is read before every login, so the token rotated by Kubernetes is used. + +The auth methods are mounted at `approle` and `kubernetes` by default, another mount is set by `WithAuthMount("k8s-prod")`. + +The driver logs in at the first request and keeps the token. The new token is requested after 2/3 of its lease, and when Vault denies the request: the token can be revoked before its lease expires. Wrong credentials are an error of `Parse` that wraps `tinyconf.ErrAccessDenied`. A login method has priority over `WithToken`, `WithTokenFile` and `VAULT_TOKEN`. + +### Token + +`WithToken` or the `VAULT_TOKEN` variable set the token. Other methods — cloud IAM, LDAP, JWT — are handled by [Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent): it logs in, renews the token and writes it to the file. Point the driver to this file: ```go vaultDriver, err := vault.New("app/config", vault.WithTokenFile("/vault/secrets/token")) @@ -83,7 +111,7 @@ The file is read before every request, so the renewed token is used without rest | Case | Result | |---|---| | the secret is absent | error of `Parse`, unless `WithOptional()` | -| the token can not read the secret | error of `Parse` that wraps `tinyconf.ErrAccessDenied`, logged as `access denied` | +| the token can not read the secret, wrong credentials of the login | error of `Parse` that wraps `tinyconf.ErrAccessDenied`, logged as `access denied` | | Vault is not available or sealed | error of `Parse` | The driver is skipped in the `Parse` call with the error, see [Missing files and values](yaml.md#missing-files-and-values). diff --git a/docs/use-cases.md b/docs/use-cases.md index f6e772b..6d81d65 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -211,14 +211,14 @@ Where the value comes from: ```go // add after the file driver: the secret overrides the file - vault.New("app/database", vault.WithTokenFile("/vault/secrets/token")) + vault.New("app/database", vault.WithKubernetes("app", "")) ``` ```go Password string `yaml:"password" vault:"password" hidden:"true" required:"true"` ``` - The token is renewed by Vault Agent, see [vault](drivers/vault.md#authentication). + The driver logs in by the service account of the pod, AppRole and the token of Vault Agent are supported too, see [vault](drivers/vault.md#authentication). ## Containers diff --git a/docs/why-tinyconf.md b/docs/why-tinyconf.md index 2441864..d9a2591 100644 --- a/docs/why-tinyconf.md +++ b/docs/why-tinyconf.md @@ -89,7 +89,7 @@ Checked against the current documentation of the libraries on 2026-10-16. ## Known limitations - **Fewer remote stores than koanf.** Consul, Vault and etcd only; others need a [custom driver](drivers/custom.md). -- **Vault: token authentication only.** Kubernetes, AppRole and cloud login are delegated to Vault Agent; changes are detected by polling, because Vault does not notify. +- **Vault: AppRole, Kubernetes and token authentication.** Cloud IAM, LDAP and JWT login are delegated to Vault Agent; changes are detected by polling, because Vault does not notify. - **No HCL out of the box.** The [format driver](drivers/format.md) accepts any decoder, but the conversion of HCL to a map is up to you. - **Own flag parser.** It ignores unknown flags, so it lives next to cobra or any CLI framework, but it is not a pflag integration. - **Subscriptions are package functions,** `tinyconf.Subscribe(ctx, config, ...)`, to keep Go 1.19 support; methods with type parameters need Go 1.27. diff --git a/skills/tinyconf/SKILL.md b/skills/tinyconf/SKILL.md index d36b2f9..2e2ba6e 100644 --- a/skills/tinyconf/SKILL.md +++ b/skills/tinyconf/SKILL.md @@ -246,7 +246,7 @@ func main() { | env | `env.New(opts...)` | `WithPrefix("APP_")`, `WithAutoKeys()` names from the field paths without tags (`HTTP.MaxConns` → `HTTP_MAX_CONNS`), `WithIndexedKeys()` `SERVERS_0_HOST`, `WithSecretFiles()` `DB_PASSWORD_FILE`, `WithFiles(".env.local")` custom .env files, `WithFiles()` no .env files, `WithWatchInterval(d)` .env files are watched | | flag | `flag.New(opts...)` | `WithArgs(args)` instead of `os.Args[1:]`; slices by repeated or comma separated flags: `--host=a --host=b,c` | | Consul KV | `consul.New("app/config", opts...)` | key per field: `prefix/http/port`; `WithAddress`, `WithToken` (or `CONSUL_HTTP_ADDR`, `CONSUL_HTTP_TOKEN`), `WithDatacenter`; a whole document: `yaml.New("", yaml.WithSource(consul.Document("app/config")))` | -| Vault KV | `vault.New("app", opts...)` | one secret with JSON keys; `WithAddress`, `WithToken`, `WithTokenFile` (or `VAULT_ADDR`, `VAULT_TOKEN`), `WithMount("secret")`, `WithKVv1()`, `WithPaths(...)`, `WithOptional()`; changes by polling | +| Vault KV | `vault.New("app", opts...)` | one secret with JSON keys; `WithAddress`, `WithToken`, `WithTokenFile` (or `VAULT_ADDR`, `VAULT_TOKEN`), `WithAppRole(roleID, secretID)`, `WithKubernetes(role, "")` login by the service account, `WithMount("secret")`, `WithKVv1()`, `WithPaths(...)`, `WithOptional()`; changes by polling | | etcd | `etcd.New("/app/config", opts...)` | key per field; `WithEndpoints(...)`, `WithAuth(user, password)`; `etcd.Document(key)` like Consul | | any format | `format.New("hcl", decode, "config.hcl", opts...)` | `decode func([]byte) (map[string]any, error)`, the options of the file drivers, `WithEncoder` for `GenDoc` |