Read the standard token sink, and tell a 403 from an outage

NewFromEnv now falls back to ~/.vault-token, the file `bao login` writes and
the bao and vault CLIs already read. An operator who has logged in once
should not have to re-export a secret, and a token that never has to be
typed is a token that never lands in shell history.

Preflight distinguishes the two things a 403 means. An expired token and a
token missing a policy look identical in the error, and the check that
separates them -- whether `bao token lookup` also fails -- is worth naming
where it is read rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
This commit is contained in:
tegwick 2026-09-04 22:27:01 +02:00
parent 7347bd6302
commit de7a7bdd2a
2 changed files with 28 additions and 3 deletions

View file

@ -76,7 +76,13 @@ func cmdPreflight(args []string) error {
appFields, found, err := store.Get(ctx, secrets.KeyOperatorApp)
switch {
case err != nil:
bad("openbao unreachable or token rejected: %v", err)
if strings.Contains(err.Error(), "403") {
bad("openbao rejected the token (403). If `bao token lookup` also fails,\n"+
" the token has expired -- run `bao login`. If lookup succeeds,\n"+
" the token lacks a policy for %s", store.Ref(secrets.KeyOperatorApp))
} else {
bad("openbao unreachable: %v", err)
}
case !found:
bad("no app credentials at %s\n write them with:\n"+
" bao kv put %s api_id=<n> api_hash=<hash>\n"+

View file

@ -13,6 +13,7 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
@ -37,7 +38,10 @@ type Store struct {
// NewFromEnv builds a store from the ambient OpenBao configuration.
func NewFromEnv(campaign string) (*Store, error) {
addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR"))
token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN"))
// Fall back to the token sink the bao and vault CLIs already use, so an
// operator who has logged in once does not have to re-export a secret --
// and so a token never has to be typed into a shell that records history.
token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN"), tokenFromDisk())
// Name the variable that is actually missing. "set both" sends someone
// checking the one they already set.
switch {
@ -47,7 +51,8 @@ func NewFromEnv(campaign string) (*Store, error) {
case addr == "":
return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)")
case token == "":
return nil, fmt.Errorf("BAO_TOKEN is not set (BAO_ADDR is %s)", addr)
return nil, fmt.Errorf("no OpenBao token: set BAO_TOKEN, or run `bao login` "+
"to write ~/.vault-token (BAO_ADDR is %s)", addr)
}
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
return &Store{
@ -142,6 +147,20 @@ func (s *Store) CreateIfAbsent(ctx context.Context, key string, fields map[strin
return true, s.Put(ctx, key, fields)
}
// tokenFromDisk reads ~/.vault-token, the file `bao login` writes. Absence is
// not an error: it is one of several ways a token may be supplied.
func tokenFromDisk() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
raw, err := os.ReadFile(filepath.Join(home, ".vault-token"))
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {