client

package
v0.30.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 11 Imported by: 0

README

dotvault public client API

github.com/goodtune/dotvault/client is dotvault's public, importable Go surface. It lets any Go module talk to the same Vault, authenticate the same way, and read from the exact path dotvault writes to, without re-implementing connectivity, token resolution, the login flow, or the user-path convention. dotvault stays the single source of truth; consumers can't silently diverge. A common use is a tool that needs to read a per-user credential (a token, an SSH key) that dotvault enrolled and keeps current — but the surface is deliberately generic and makes no assumptions about who is calling.

The package is a thin facade over dotvault's internals (internal/config, internal/auth, internal/vault). Those packages stay internal — the facade is the only supported import boundary, so dotvault can refactor freely behind it.

Quick start

import "github.com/goodtune/dotvault/client"

ctx := context.Background()

// DefaultConfigPath() returns the per-OS config *file* path:
// /etc/xdg/dotvault/config.yaml (Linux, honouring XDG_CONFIG_DIRS),
// %ProgramData%\dotvault\config.yaml (Windows), Application Support (macOS).
// LoadConfig reads that file — or, on Windows, the GPO registry instead when
// policy keys are present (it routes through config.LoadSystem).
cfg, err := client.LoadConfig(client.DefaultConfigPath())
if err != nil { /* fail closed */ }

cli, err := client.New(cfg)
if err != nil { /* fail closed */ }

// DOTVAULT_TOKEN env → token file → interactive login (OIDC browser / LDAP prompt).
// Authenticate logs in when no cached token works, so it returns ErrUnreachable
// or ErrAuthFailed — not ErrLoginRequired (that's AuthenticateCached's outcome).
if err := cli.Authenticate(ctx); err != nil {
    switch {
    case errors.Is(err, client.ErrUnreachable): // vault down — retry / back off
    case errors.Is(err, client.ErrAuthFailed):  // a login ran but failed
    }
    return err
}

// service is an enrolment path segment under kv/users/<user>/; field is a
// key within that secret. E.g. the github enrolment engine writes oauth_token.
tok, found, err := cli.ReadUserSecret(ctx, "gh", "oauth_token")

A runnable version of this flow, and a non-interactive-preflight variant, are in the package's Example functions (godoc / example_test.go). Prefer the dotvault import alias if you want a shorter qualifier: import dotvault "github.com/goodtune/dotvault/client".

Identity: it's the OS user, not the token

This is the one thing to internalise before depending on the package. dotvault derives the <user> segment of kv/users/<user>/... from the OS account the process runs as (the username with any DOMAIN\ prefix stripped), not from the Vault token's display_name, entity name, or metadata. IdentityName() returns that OS-derived name, and ReadUserSecret composes paths with it.

The practical consequence: by default a consumer must run as the same OS user as the dotvault that populated the secrets. That is normally true — dotvault is a per-user daemon running in the user's own context — but it means a service account or container running as a different user will, by default, read from a different (probably empty) path. The failure mode is silent: a wrong identity reads a non-existent path, which surfaces as found == false, not an error.

If your deployment can't guarantee same-user, pass client.WithIdentity("<name>") to New to set the path segment explicitly: cli, err := client.New(cfg, client.WithIdentity("alice")). This also makes downstream tests deterministic (no dependence on the host's OS account). It does not change the username used for an interactive LDAP prompt — only the kv/users/<name>/... path.

IdentityName() takes no context and makes no Vault call — the value is local (the override if set, else the OS user).

Authentication entry points

Method Behaviour Use when
Authenticate(ctx) DOTVAULT_TOKEN → token file → interactive login. Short-circuits with ErrUnreachable (no prompt) if Vault is down. Normal startup where a human is present.
AuthenticateCached(ctx) env → token file → local API socket → peer socket borrow. Never prompts. ErrLoginRequired if no usable token. Side-effect-free preflight (doctor), non-interactive / CI callers.
Login(ctx) Unconditional fresh login (ignores cached token). Equivalent to dotvault login. Forcing re-auth.

Authenticate and Login are interactive. They can open a browser (OIDC) or block reading a password and MFA code from the terminal (LDAP). That is surprising inside a library call: do not call them from a non-interactive service or daemon. In those contexts use AuthenticateCached and surface ErrLoginRequired to the operator, or arrange for a token to be present some other way. LDAP Login without a TTY returns an error wrapping ErrAuthFailed rather than hanging.

Token precedence and the login flow match the daemon's exactly. VAULT_TOKEN is deliberately ignored — including the Vault SDK's own automatic pickup, which the underlying client construction neutralises — so a concurrent vault CLI session's environment never leaks in; use DOTVAULT_TOKEN to supply a token via the environment. The token file location (~/.dotvault-token) is dotvault's built-in default rather than a configured value — it isn't carried in the YAML/registry config; New fills an empty Config.TokenFile from DefaultTokenFile(). Set Config.TokenFile explicitly to override it.

If either socket field is set, AuthenticateCached borrows a live token from a peer after DOTVAULT_TOKEN and the token file come up empty, before reporting ErrLoginRequired. Two are consulted, in order: VaultConfig.APISocket (dotvault's api section — the local daemon's API socket) first, then VaultConfig.TokenSocket (vault.token_socket — a peer daemon's socket, typically an SSH RemoteForward). The local socket is preferred because it is the more stable of the two: the forwarded one dies with the SSH session, so a long-running consumer that borrowed only from it would fail its next read once the session ended. LoadConfig fills both from the operator's config, so a consumer inherits the ordering without doing anything. The borrow is a plain HTTP GET over the socket with no browser or prompt, so it stays within the cached, side-effect-free contract — a consumer on a host with no local token but a live peer socket (the SSH RemoteForward topology) reads secrets without an interactive login of its own. It is best-effort: a missing or stale socket simply yields no token.

Peer actions: Browse, Notify, and Clipboard

Over the same TokenSocket peer, the client can ask the workstation dotvault to open a URL in a browser, raise a desktop notification, or put text on the clipboard — the programmatic equivalents of dotvault browse/dotvault notify/dotvault clipboard. This is for the headless-consumer topology: a program on a machine with no browser hands a URL, a notification, or a value to paste back over the forwarded socket, so a browser-driven flow (an OAuth page, a report link), a "job finished" toast, or the one-time token that page asks for lands on the workstation where a human is looking.

if err := cli.Browse(ctx, "https://example.com/report"); err != nil {
    // errors.Is(err, client.ErrPeerUnavailable) → no socket / peer down / open failed
}
if err := cli.Notify(ctx, "info", "Backup complete", "42 files, 0 errors", ""); err != nil {
    // same taxonomy as Browse
}
// Attach a clickable link (opens on click on Windows; appended to the body on macOS/Linux):
cli.Notify(ctx, "error", "Build failed", "click for the run", "https://ci.example/build/42")
// Stage a value on the workstation's clipboard, ready to paste:
cli.Clipboard(ctx, oneTimeToken)

Browse/Notify/Clipboard differ from the dotvault CLIs in two deliberate ways: there is no local fallback (a headless library has no local browser, notifier, or clipboard), so an unreachable peer is an error rather than a silent local action; and there is no local validation — the peer endpoint validates and sanitizes the URL / level / title / text authoritatively (that is where the action happens and where the security boundary belongs), so the facade stays a thin transport. A peer that is not configured, cannot be reached, or reports it could not perform the action returns ErrPeerUnavailable; a request the peer rejects as invalid (a non-http(s) URL, an unknown level, an empty title, empty or over-64-KiB clipboard text) returns a plain error carrying the peer's message. Notify's level is one of info, warning, error, attention; its final argument is an optional actionURL — an http/https link the notification opens when clicked (on Windows; appended to the body on macOS/Linux). Pass "" for no link. Clipboard's text is written verbatim on the peer (non-empty UTF-8, no NUL bytes, ≤ 64 KiB); the peer never logs the content, only its length.

Error categories

Sentinels are errors.Is-able and map to a small, stable set of outcomes:

Sentinel Meaning Suggested metric label
nil success success
ErrLoginRequired no usable cached token; login not run missing_token
ErrDenied Vault rejected the request (401/403) denied
ErrUnreachable DNS/connection/TLS/timeout/5xx unreachable
ErrAuthFailed interactive login ran but didn't yield a token denied (your choice)
ErrPeerUnavailable Browse/Notify/Clipboard: no socket, peer down, or action failed peer_unavailable
(value, false, nil) from a read secret/field absent missing_field

ErrAuthFailed is a distinct sentinel from ErrDenied so you can tell "wrong/declined credentials" from "token lacks the policy". Folding both into a denied metric label is reasonable but is your decision, not the library's.

A missing secret path and a missing field both return found == false with a nil error, so "the field isn't there" is never conflated with "couldn't reach Vault". On each error sentinel a consumer typically: ErrLoginRequired → tell the user to run dotvault login (or call Authenticate interactively); ErrUnreachable → retry / back off; ErrDenied / ErrAuthFailed → fail closed and surface the auth problem; found == false → treat the credential as not-yet-enrolled.

KV mount and path layout

The KV v2 mount and user prefix come from dotvault's config (vault.kv_mount, default kv; vault.user_prefix, default users/). ReadUserSecret(ctx, service, field) reads {kv_mount}/{user_prefix}{identity}/{service} field {field}. Use ReadKVField(ctx, mount, path, field) directly if you need to address a non-standard layout.

Vault namespaces are not a dotvault config field; the underlying client honours VAULT_NAMESPACE.

Testing on the consumer side

The package ships client.Reader, a narrow interface covering the read side (IdentityName, ReadKVField, ReadUserSecret) that *client.Client satisfies. Depend on client.Reader wherever your code consumes a secret, and substitute a hand-written fake in tests — no live Vault, no network. See the runnable ExampleReader in example_test.go for a ~15-line fake.

Authentication is intentionally left out of Reader: it has side effects (token-file writes, browser/terminal interaction) that belong in main, not in the unit under test. Construct and authenticate a real *client.Client at startup; pass it (as a Reader) into the code that reads.

One caveat: Authenticate/AuthenticateCached read process environment (DOTVAULT_TOKEN, VAULT_NAMESPACE), so tests that exercise the real client must use t.Setenv and can't run with t.Parallel(). The Reader fake sidesteps this entirely.

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

Index

Examples

Constants

This section is empty.

Variables

View Source
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")

	// ErrPeerUnavailable indicates a peer-action call (Browse, Notify) could
	// not be completed by the peer dotvault named by TokenSocket: the socket
	// is not configured, the peer could not be reached (missing/stale socket,
	// SSH forward down), or the peer answered that it could not perform the
	// action (e.g. a 502 because a browser opener failed, or a 503 because it
	// was busy). It is the peer-side analogue of ErrUnreachable — a retryable
	// availability problem, distinct from a request the peer rejected as
	// invalid, which surfaces as a plain (uncategorised) error carrying the
	// peer's message.
	ErrPeerUnavailable = errors.New("dotvault: peer action unavailable")
)

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

func New(cfg *Config, opts ...Option) (*Client, error)

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

func (c *Client) Authenticate(ctx context.Context) error

Authenticate makes the Client hold a usable Vault token, following dotvault's precedence:

  1. DOTVAULT_TOKEN environment variable,
  2. the configured token file,
  3. 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

func (c *Client) AuthenticateCached(ctx context.Context) error

AuthenticateCached resolves a usable Vault token, trying each source in turn and validating it with a LookupSelf: DOTVAULT_TOKEN, then the token file, then — if a peer socket is configured — by borrowing a live token from the peer dotvault over that socket. It never initiates an interactive login. It returns nil once a candidate validates, an error wrapping ErrLoginRequired if no source yields a usable token (missing, expired, or revoked), or an error wrapping ErrUnreachable if Vault cannot be reached to validate.

A cached token that a *reachable* Vault rejects does not end the search: the peer socket is tried next, so a remote host with a stale local token file but a live peer still recovers — mirroring the daemon's startup path, which clears a rejected token and then borrows. An ErrUnreachable from validating any candidate short-circuits immediately: when Vault is down a borrowed token could not be validated either, so there is nothing to gain by trying.

The socket borrow stays within the side-effect-free contract: it is a plain HTTP GET over a Unix socket (the equivalent of `curl --unix-socket <path> http://localhost/api/v1/token`) — no browser, no password prompt — so a consumer that runs on a host with no usable local token but a live peer socket (the SSH RemoteForward topology) authenticates without an interactive Login. Best-effort: a missing/stale socket simply yields no candidate.

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

func (*Client) Browse added in v0.29.0

func (c *Client) Browse(ctx context.Context, rawURL string) error

Browse asks the peer dotvault named by the configured TokenSocket to open rawURL in a browser on the peer's host — the programmatic equivalent of `dotvault browse <url>` and of

curl --unix-socket <TokenSocket> http://localhost/api/v1/remote/browse -d url=<rawURL>

It is for the headless-consumer topology: a program on a machine with no browser hands a URL back over the same SSH-forwarded socket it borrows its token from, so a browser-driven flow (an OAuth page, a report link) opens on the workstation where a human is looking. Unlike `dotvault browse`, the facade has no local fallback — a library on a headless box has no local browser to fall back to — so a peer that cannot be reached is an error, not a silent local open.

The URL is validated authoritatively by the peer endpoint (http/https only, a host, no embedded credentials); a rejected URL comes back as a plain (uncategorised) error carrying the peer's message. A peer that cannot be reached, or that reports it could not open the browser, wraps ErrPeerUnavailable. Returns nil once the peer reports the browser opened.

func (*Client) Clipboard added in v0.30.0

func (c *Client) Clipboard(ctx context.Context, text string) error

Clipboard asks the peer dotvault named by the configured TokenSocket to put text on the clipboard of the peer's host — the programmatic equivalent of `dotvault clipboard` and the third peer action over the same socket. Where Browse opens a login page on the workstation and Notify tells the user something happened, Clipboard delivers the value they need to paste — a one-time token, a device code — so a headless program can stage the credential right where the user's Ctrl+V is. Like the other peer actions there is no local fallback: an unreachable peer is an error, never a write to the headless host's (typically non-existent) clipboard.

The text is validated authoritatively by the peer endpoint (non-empty, valid UTF-8, no NUL bytes, at most 64 KiB) and written verbatim — the peer never logs the content, only its length. A rejected text comes back as a plain (uncategorised) error carrying the peer's message. A peer that cannot be reached, or that reports it could not write the clipboard, wraps ErrPeerUnavailable. Returns nil once the peer reports the clipboard set.

func (*Client) IdentityName

func (c *Client) IdentityName() (string, error)

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

func (c *Client) Login(ctx context.Context) error

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) Notify added in v0.29.0

func (c *Client) Notify(ctx context.Context, level, title, body, actionURL string) error

Notify asks the peer dotvault named by the configured TokenSocket to raise a native desktop notification on the peer's host — the programmatic equivalent of `dotvault notify <level> <title> [body]`. It is the notification sibling of Browse over the same socket: a long-running job on a headless box surfaces a toast/notification on the workstation.

level must be one of "info", "warning", "error", "attention"; title is required; body and actionURL are optional. actionURL, when set, is an http/https link the notification takes the user to when clicked — clickable on Windows, appended to the body on macOS/Linux (see the notify docs). The level, text, and action URL are validated and sanitized authoritatively by the peer endpoint (the same neutralization the daemon applies before delivery), so a bad level, empty title, or malformed action URL comes back as a plain (uncategorised) error carrying the peer's message. A peer that cannot be reached, or that reports it could not deliver, wraps ErrPeerUnavailable.

func (*Client) ReadKVField

func (c *Client) ReadKVField(ctx context.Context, mount, path, field string) (string, bool, error)

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

func (c *Client) ReadUserSecret(ctx context.Context, service, field string) (string, bool, error)

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.

func (*Client) Token

func (c *Client) Token() string

Token returns the Vault token the Client currently holds, or "" if none. Exposed so a caller can pass the same token to other Vault-aware tooling.

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

func LoadConfig(path string) (*Config, error)

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

func WithIdentity(name string) Option

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

	// OIDCCallbackPort is the fixed local TCP port an interactive OIDC Login
	// binds for the OAuth redirect_uri. Zero (the default) resolves to 8250,
	// matching the `vault` CLI's own default; if that port is unavailable,
	// Login falls back to a random port. Mirrors vault.oidc_callback_port.
	OIDCCallbackPort int

	// Policies is the least-privilege set of Vault policies the working token
	// should carry. When non-empty, an interactive Login exchanges the login
	// token for a child token restricted to exactly these policies (Vault
	// enforces the subset rule). Empty inherits every policy the auth role
	// granted — dotvault's historical behaviour. Mirrors vault.policies.
	Policies []string

	// NoDefaultPolicy strips the implicit `default` policy from the working
	// token. Mirrors vault.no_default_policy. Combine with Policies to pin the
	// token to exactly the capabilities the consumer needs.
	NoDefaultPolicy bool

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

	// APISocket is an optional path to the *local* dotvault daemon's API
	// socket (mirrors the api section: the resolved api.unix.path, or the
	// per-user runtime default when api.enabled is set without a path).
	//
	// It is the same endpoint as TokenSocket and is tried ahead of it,
	// because the two differ in lifetime rather than capability: the local
	// socket is served by the long-lived per-user daemon, while TokenSocket
	// is typically an SSH RemoteForward that vanishes when the session drops.
	// A consumer started inside an SSH session therefore keeps borrowing
	// successfully after that session ends.
	//
	// Borrow direction only. The peer actions (Browse / Notify / Clipboard)
	// deliberately keep using TokenSocket: their whole purpose is to reach
	// the workstation where a human is looking, and sending them to the local
	// daemon would open a browser on the headless host nobody is sitting at.
	APISocket 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL