Documentation
¶
Overview ¶
Package client is dotvault's public, importable Go API. It exposes dotvault's connectivity, token-resolution, login, and user-path conventions so any Go module can talk to the same Vault, authenticate the same way, and read from the exact path dotvault writes to — without re-implementing any of it and risking silent divergence.
dotvault remains the single source of truth for:
- connectivity (Vault address, TLS, CA),
- token-resolution order (DOTVAULT_TOKEN env → token file → interactive login; VAULT_TOKEN is deliberately ignored — it belongs to the `vault` CLI and must not leak into dotvault's session),
- the login flow itself (OIDC browser, LDAP with MFA),
- the convention mapping an authenticated user to a kv/users/<user>/... path.
Identity / path convention ¶
IMPORTANT: dotvault derives the <user> path segment from the OS user (the current account's username with any DOMAIN\ prefix stripped), NOT from the Vault token. A user logged in via OIDC as alice@corp whose OS account is "alice" has secrets at kv/users/alice/.... IdentityName returns this OS-derived name, and ReadUserSecret composes paths with it, so a consumer reads from exactly where dotvault's sync/enrolment writes. A consumer must therefore run as the same OS user as the dotvault that populated the secrets — typically true, since dotvault runs in the user's own context.
Typical use ¶
cfg, err := client.LoadConfig(client.DefaultConfigPath())
cli, err := client.New(cfg) // optionally: client.New(cfg, client.WithIdentity("alice"))
if err := cli.Authenticate(ctx); err != nil {
// categorise with errors.Is, one sentinel at a time. Authenticate
// yields ErrUnreachable or ErrAuthFailed (it consumes the no-token
// case and logs in); AuthenticateCached is what surfaces
// ErrLoginRequired.
// errors.Is(err, client.ErrUnreachable)
// errors.Is(err, client.ErrAuthFailed)
return err
}
tok, found, err := cli.ReadUserSecret(ctx, "gh", "oauth_token")
Example ¶
Example shows the typical consumer flow: load dotvault's system config, authenticate with the same precedence dotvault uses, then read a known per-user field. Errors are categorised via the exported sentinels.
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/goodtune/dotvault/client"
)
func main() {
ctx := context.Background()
cfg, err := client.LoadConfig(client.DefaultConfigPath())
if err != nil {
log.Fatalf("load dotvault config: %v", err)
}
cli, err := client.New(cfg)
if err != nil {
log.Fatalf("build client: %v", err)
}
// Authenticate: DOTVAULT_TOKEN → token file → interactive login.
if err := cli.Authenticate(ctx); err != nil {
switch {
case errors.Is(err, client.ErrUnreachable):
log.Fatalf("vault unreachable: %v", err)
case errors.Is(err, client.ErrAuthFailed):
log.Fatalf("login failed: %v", err)
default:
log.Fatalf("authenticate: %v", err)
}
}
// Read a known per-user field, e.g. the oauth_token written by the
// github enrolment engine. The (value, found, err) triple keeps a
// not-yet-enrolled secret distinct from a transport failure.
token, found, err := cli.ReadUserSecret(ctx, "gh", "oauth_token")
if err != nil {
log.Fatalf("read secret: %v", err)
}
if !found {
log.Fatal("gh/oauth_token not enrolled; run `dotvault enrol gh`")
}
fmt.Printf("resolved oauth_token (%d bytes)\n", len(token))
}
Output:
Index ¶
- Variables
- func DefaultConfigPath() string
- func DefaultTokenFile() (path string)
- type Client
- func (c *Client) Authenticate(ctx context.Context) error
- func (c *Client) AuthenticateCached(ctx context.Context) error
- func (c *Client) IdentityName() (string, error)
- func (c *Client) Login(ctx context.Context) error
- func (c *Client) ReadKVField(ctx context.Context, mount, path, field string) (string, bool, error)
- func (c *Client) ReadUserSecret(ctx context.Context, service, field string) (string, bool, error)
- func (c *Client) Token() string
- type Config
- type Option
- type Reader
- type VaultConfig
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrLoginRequired indicates no usable cached token was found — neither // DOTVAULT_TOKEN nor the token file yielded a token that LookupSelf accepts. // It is returned by AuthenticateCached (which never prompts). Authenticate // does not return it: on a reachable Vault it consumes this condition and // proceeds to an interactive Login instead. ErrLoginRequired = errors.New("dotvault: login required (no valid cached token)") // ErrAuthFailed indicates the configured fresh-auth flow (Login, or the // login fallback inside Authenticate) ran but did not yield a usable // token. This covers a genuine auth failure (bad password, declined MFA, // OIDC callback error, no TTY for an LDAP prompt) as well as a // misconfigured auth method (an unsupported AuthMethod, or AuthMethod // "token" with no token on disk — for which Login has nothing to do). // It is distinct from ErrLoginRequired, which means a fresh login was // not attempted at all. ErrAuthFailed = errors.New("dotvault: authentication failed") // ErrDenied indicates Vault rejected a KV read with 401/403 — the token // is missing the required policy, or was revoked between the LookupSelf // check and the read (see TestReadKVField_Denied). Note that a 401/403 // from validating a *cached* token during AuthenticateCached is reported // as ErrLoginRequired instead (the token needs replacing, not the // caller's authority), so ErrDenied is the read-path authorisation // failure, not every 403 the package sees. ErrDenied = errors.New("dotvault: vault denied the request") // ErrUnreachable indicates the Vault server could not be reached // (DNS, connection refused, TLS handshake, timeout) or could not service // the request right now (5xx, or 429 rate-limiting) — i.e. a retryable // transport/availability problem rather than an authorisation decision. ErrUnreachable = errors.New("dotvault: vault unreachable") )
Sentinel errors expose a small, stable set of failure categories so callers can map outcomes onto metrics without string-matching. Every error returned by this package that fits one of these categories wraps the corresponding sentinel, so callers use errors.Is rather than comparing values directly.
The categories line up with the outcomes a consumer tracks:
success → nil error missing_token → ErrLoginRequired denied → ErrDenied, ErrAuthFailed unreachable → ErrUnreachable missing_field → (value, false, nil) from ReadKVField/ReadUserSecret
ErrAuthFailed covers an interactive login that started but did not yield a usable token (bad password, declined MFA, OIDC callback error). It is distinct from ErrLoginRequired, which means "no usable token was found and no interactive login was attempted"; a consumer that buckets outcomes for metrics can fold it into the same "denied" label as ErrDenied, but it is a separate sentinel so callers that want to distinguish "wrong creds" from "no creds offered" can.
Every categorised error wraps one of these sentinels with %w, so a caller can errors.Is it. Where there is an underlying Vault cause, that cause is wrapped too (a second %w), so the same value also errors.As to a *vaultapi.ResponseError; the no-token branch of AuthenticateCached has no such cause and wraps only the sentinel. The wrapped text comes from Vault's API error (which echoes the server response body, never the request token) plus the mount/path being read — none of it carries token material, so callers may log these errors verbatim.
New's own input-validation errors (nil config, missing address) are plain errors, not categorised: they are programmer errors surfaced before any Vault interaction, outside the sentinel taxonomy below.
Functions ¶
func DefaultConfigPath ¶
func DefaultConfigPath() string
DefaultConfigPath returns the platform-appropriate path to dotvault's system config file — the same file the daemon loads. On Linux this is /etc/xdg/dotvault/config.yaml (honouring XDG_CONFIG_DIRS).
func DefaultTokenFile ¶
func DefaultTokenFile() (path string)
DefaultTokenFile returns the platform-appropriate path to the Vault token file dotvault reads and writes (~/.dotvault-token), or "" if the OS home directory cannot be resolved.
paths.VaultTokenPath panics (via mustHomeDir) when os.UserHomeDir fails — acceptable inside the daemon, but a public library must not panic on a recoverable environment condition. We therefore guard it and return "" rather than fabricating a path. An empty token-file path is already well-defined throughout the package: token resolution simply skips the file and uses DOTVAULT_TOKEN only. Returning "" (not a relative ".dotvault-token", which would be cwd-dependent and could silently diverge from where the daemon looks) keeps that contract honest; a caller that needs a specific location sets Config.TokenFile explicitly.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps a Vault client with dotvault's auth and KV-read conventions. Construct one with New. A Client is not safe for concurrent Authenticate / Login calls, but concurrent reads after authentication are fine.
func New ¶
New constructs a Client from cfg, applying any options. It builds the underlying Vault client (applying TLS/CA settings) but performs no network calls and does not authenticate — call Authenticate (or Login) before reading secrets.
Empty optional fields in cfg are filled with dotvault's defaults (KVMount "kv", UserPrefix "users/", TokenFile ~/.dotvault-token), so a directly constructed Config behaves the same as one returned by LoadConfig.
func (*Client) Authenticate ¶
Authenticate makes the Client hold a usable Vault token, following dotvault's precedence:
- DOTVAULT_TOKEN environment variable,
- the configured token file,
- if neither yields a token Vault accepts, the configured fresh-auth flow (OIDC browser / LDAP terminal prompt — the same path as `dotvault login`).
If Vault is unreachable, it returns an error wrapping ErrUnreachable without attempting an interactive login (no point prompting when the server is down). If a fresh login is required but fails, the error wraps ErrAuthFailed.
Use AuthenticateCached when interactive login must not happen (e.g. a side-effect-free health check).
func (*Client) AuthenticateCached ¶
AuthenticateCached resolves a token from DOTVAULT_TOKEN then the token file and validates it with a LookupSelf, but never initiates an interactive login. It returns nil if a cached token is usable, an error wrapping ErrLoginRequired if no usable token is present (missing, expired, or revoked), or an error wrapping ErrUnreachable if Vault cannot be reached to validate the token.
This is the entry point for callers that must remain side-effect-free — no browser pop, no password prompt — such as a `doctor`/preflight check.
Example ¶
ExampleClient_AuthenticateCached shows the side-effect-free preflight a `doctor` subcommand would use: it never opens a browser or prompts. A missing or expired token surfaces as ErrLoginRequired rather than dropping the user into a login flow.
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/goodtune/dotvault/client"
)
func main() {
cfg, err := client.LoadConfig(client.DefaultConfigPath())
if err != nil {
log.Fatal(err)
}
cli, err := client.New(cfg)
if err != nil {
log.Fatal(err)
}
switch err := cli.AuthenticateCached(context.Background()); {
case err == nil:
fmt.Println("cached vault token is usable")
case errors.Is(err, client.ErrLoginRequired):
fmt.Println("run `dotvault login` (or let Authenticate prompt next run)")
case errors.Is(err, client.ErrUnreachable):
fmt.Println("vault is unreachable")
}
}
Output:
func (*Client) IdentityName ¶
IdentityName returns the <user> path segment dotvault uses to lay out kv/users/<user>/.... This is the OS username with any DOMAIN\ prefix stripped — NOT a value derived from the Vault token (display_name, entity name, or token metadata). Consumers reading per-user secrets MUST use this so they hit the same path dotvault writes to.
It performs no Vault call and takes no context: the value comes from the OS account the process runs as, unless overridden with WithIdentity. Callers that need secrets written by a given dotvault instance must either run as the same OS user or set WithIdentity to that user's name.
func (*Client) Login ¶
Login runs the configured fresh-auth flow unconditionally, ignoring any cached token — the equivalent of `dotvault login`. OIDC opens a browser; LDAP prompts for a password (and MFA) on the terminal. On success the new token is written to the configured token file (matching dotvault) and held on the Client. Any failure to produce a token — a genuine auth failure or a misconfigured auth method (unsupported AuthMethod, or "token" with nothing on disk) — returns an error wrapping ErrAuthFailed.
Login requires an interactive context for LDAP (a terminal on stdin); it will not prompt when stdin is not a TTY and instead returns an error wrapping ErrAuthFailed. Headless callers (including the Windows GUI-subsystem binary, which has no console) should drive auth through OIDC, or stick to AuthenticateCached and surface ErrLoginRequired to the operator.
func (*Client) ReadKVField ¶
ReadKVField reads a single field from a KV v2 secret at the given mount and path. It returns:
- (value, true, nil) when the secret exists and the field is present;
- ("", false, nil) when the secret exists but the field is absent, OR the secret path does not exist (both are "the field you asked for isn't there", which callers map to a missing_field outcome);
- ("", false, err) for transport/auth failures, wrapping ErrUnreachable or ErrDenied.
Caveat: Vault answers a read against a missing or disabled KV mount with a 404, which is indistinguishable here from a not-yet-written secret — both yield ("", false, nil). So a wrong mount (a mis-set kv_mount) reads as "not enrolled" rather than an error. A caller that wants to tell a misconfigured deployment apart from an un-enrolled user should verify the mount independently (e.g. a known-present sentinel path) rather than infer it from found == false.
Non-string field values are stringified via fmt's %v: numbers and bools render as you'd expect; a nested object or array renders as its Go-syntax form (map[...]/[...]). dotvault stores credential material as strings, so in practice the fields a consumer reads are already strings.
func (*Client) ReadUserSecret ¶
ReadUserSecret reads a single field from kv/users/<IdentityName>/<service>, using the configured KV mount and user prefix. It is IdentityName + ReadKVField composed, with dotvault owning the path layout end-to-end: {KVMount}/{UserPrefix}{identity}/{service}, field {field}.
Example: ReadUserSecret(ctx, "gh", "oauth_token") reads the oauth_token field of kv/users/<user>/gh. Return semantics match ReadKVField.
type Config ¶
type Config struct {
Vault VaultConfig
// TokenFile is the path to the Vault token file consulted after
// DOTVAULT_TOKEN. Empty means dotvault's platform default:
// .dotvault-token in the user's home directory (resolved via
// os.UserHomeDir). dotvault does not expose this in its YAML today;
// it is here as a programmatic override point and defaults to the
// canonical location.
TokenFile string
}
Config is the connectivity-and-auth view of dotvault's system config. It is a deliberately narrow projection of the full dotvault configuration: only the fields needed to talk to Vault, authenticate, and locate a user's secrets are exposed. Sync rules, enrolment definitions, web UI, and observability settings stay internal to dotvault and are not part of this surface.
A Config can be produced two ways:
- LoadConfig parses dotvault's on-disk system config (the same file the daemon reads), so a consumer inherits the operator's connectivity and auth settings verbatim. This is the recommended path — it keeps dotvault the single source of truth.
- Constructed directly by a caller that already knows its connectivity (useful for tests, or callers wiring values from another source). New applies the same defaults LoadConfig would (KVMount "kv", UserPrefix "users/", TokenFile ~/.dotvault-token).
func LoadConfig ¶
LoadConfig parses dotvault's system config at path and projects it onto the connectivity-and-auth Config. Pass DefaultConfigPath() for the canonical location. The file is parsed and validated by dotvault's own loader, so a malformed or incomplete config (missing vault.address, etc.) surfaces the same error the daemon would report.
On Windows, if Group Policy registry keys are present, dotvault loads its config from the registry and ignores the file; LoadConfig follows that same precedence via the shared loader.
type Option ¶
type Option func(*Client)
Option configures a Client at construction time. Options are the forward-compatible extension point for New: new behaviour can be added as an Option without changing New's signature, so existing callers keep compiling. See WithIdentity.
Options are applied to the already-built Client after the underlying Vault client is constructed, so they tune the Client's own behaviour. An option that needs to influence Vault-client construction itself (a custom HTTP transport, say) would require New to grow a separate build step first; the current options do not.
func WithIdentity ¶
WithIdentity overrides the identity segment used to lay out kv/users/<identity>/... paths. By default the Client derives it from the OS user (see IdentityName), which assumes the consumer runs as the same OS account as the dotvault that wrote the secrets. A consumer that runs under a different account (a service, a container) — or a test that needs a deterministic identity — sets this explicitly. It does not change the username used for an interactive LDAP login prompt, only the KV path.
The value is interpolated verbatim into the Vault KV path and is not sanitised — it is a caller-controlled value used by the caller's own token, and what that token can read is bounded by its Vault policy regardless of the path composed, so this grants no authority the token didn't already have. An empty string is ignored (the OS user is used).
type Reader ¶
type Reader interface {
// IdentityName returns the kv/users/<identity>/... path segment.
IdentityName() (string, error)
// ReadKVField reads one field of a KV v2 secret. See Client.ReadKVField.
ReadKVField(ctx context.Context, mount, path, field string) (string, bool, error)
// ReadUserSecret reads kv/users/<identity>/<service> field <field>.
ReadUserSecret(ctx context.Context, service, field string) (string, bool, error)
}
Reader is the read-side contract a consumer depends on after a Client is authenticated. It exists so downstream code can accept this narrow interface and substitute a fake in tests without standing up a Vault — the shape is owned here so every consumer fakes the same thing and the methods can't drift between them. *Client satisfies it.
Authentication (Authenticate/Login) is intentionally excluded: it has side effects (token file writes, browser/terminal interaction) that belong to process wiring, not to the unit under test. Construct and authenticate a real *Client in main; depend on Reader everywhere a secret is consumed.
Example ¶
ExampleReader shows how a consumer tests code that reads secrets by substituting a fake for the live client — the recommended pattern.
package main
import (
"context"
"fmt"
"log"
"github.com/goodtune/dotvault/client"
)
// fetchCreds is the kind of helper a consumer would write: it depends on the
// narrow client.Reader interface, never on *client.Client, so it can be unit
// tested against a fake without a live Vault.
func fetchCreds(ctx context.Context, r client.Reader) (string, error) {
tok, found, err := r.ReadUserSecret(ctx, "gh", "oauth_token")
if err != nil {
return "", err
}
if !found {
return "", fmt.Errorf("gh/oauth_token not enrolled")
}
return tok, nil
}
// fakeReader is a hand-written test double satisfying client.Reader. A
// consumer drops one of these into its own tests; no Vault, no network.
type fakeReader struct {
identity string
secrets map[string]string
}
func (f fakeReader) IdentityName() (string, error) { return f.identity, nil }
func (f fakeReader) ReadKVField(_ context.Context, _, _, _ string) (string, bool, error) {
return "", false, nil
}
func (f fakeReader) ReadUserSecret(_ context.Context, service, field string) (string, bool, error) {
v, ok := f.secrets[service+"/"+field]
return v, ok, nil
}
func main() {
r := fakeReader{
identity: "alice",
secrets: map[string]string{"gh/oauth_token": "ghp_fake"},
}
tok, err := fetchCreds(context.Background(), r)
if err != nil {
log.Fatal(err)
}
fmt.Println(tok)
}
Output: ghp_fake
type VaultConfig ¶
type VaultConfig struct {
// Address is the Vault server URL (e.g. https://vault.example.com:8200).
// Required.
Address string
// CACert is the path to a PEM CA bundle for verifying the Vault server.
CACert string
// TLSSkipVerify disables TLS verification. Insecure; for dev only.
TLSSkipVerify bool
// KVMount is the KV v2 mount that holds user secrets. Defaults to "kv".
KVMount string
// UserPrefix is the path prefix under which per-user secrets live.
// Defaults to "users/" and is normalised to carry exactly one trailing
// slash, so the full layout is {KVMount}/{UserPrefix}{identity}/{service}.
UserPrefix string
// AuthMethod is the fresh-auth method dotvault uses when no cached token
// is usable: "oidc", "ldap", "token", "mtls", or "mtls+tpm". A "+tpm"
// suffix on any base method (e.g. "oidc+tpm") additionally TPM-seals the
// cached token file at rest. Reads are transparent to this — a sealed
// token file is self-describing and unsealed automatically — so a consumer
// inherits the operator's setting verbatim and need not branch on it.
AuthMethod string
// AuthMount is the auth backend mount path (defaults per method: "oidc"
// or "ldap").
AuthMount string
// AuthRole is an optional Vault role passed to the auth method.
AuthRole string
// TokenSocket is an optional path to a peer dotvault daemon's web-API
// Unix socket. When set, an interactive Login first tries to borrow a
// live token from the peer over the socket (the equivalent of
// `curl --unix-socket <path> http://localhost/api/v1/token`) before
// running the configured auth flow — the dotvault-to-dotvault sharing
// seam. A missing or stale socket is ignored. A leading ~ is expanded.
TokenSocket string
}
VaultConfig mirrors the connectivity + auth fields of dotvault's vault: config stanza.
Vault namespaces are not a dotvault YAML field; the underlying Vault client honours the VAULT_NAMESPACE environment variable, so namespaced deployments work without an explicit field here.