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) appFields, found, err := store.Get(ctx, secrets.KeyOperatorApp)
switch { switch {
case err != nil: 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: case !found:
bad("no app credentials at %s\n write them with:\n"+ bad("no app credentials at %s\n write them with:\n"+
" bao kv put %s api_id=<n> api_hash=<hash>\n"+ " bao kv put %s api_id=<n> api_hash=<hash>\n"+

View file

@ -13,6 +13,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
) )
@ -37,7 +38,10 @@ type Store struct {
// NewFromEnv builds a store from the ambient OpenBao configuration. // NewFromEnv builds a store from the ambient OpenBao configuration.
func NewFromEnv(campaign string) (*Store, error) { func NewFromEnv(campaign string) (*Store, error) {
addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR")) 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 // Name the variable that is actually missing. "set both" sends someone
// checking the one they already set. // checking the one they already set.
switch { switch {
@ -47,7 +51,8 @@ func NewFromEnv(campaign string) (*Store, error) {
case addr == "": case addr == "":
return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)") return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)")
case token == "": 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") mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
return &Store{ 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) 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 { func firstNonEmpty(vals ...string) string {
for _, v := range vals { for _, v := range vals {
if v != "" { if v != "" {