fluid-telegram/internal/secrets/secrets.go
tegwick ff2b19456f Make the secret layout configuration, not a constant
ops-warden routes API-key needs to railiance-platform, whose convention is
platform/workloads/<domain>/<workload>/<bundle>. This repository invented
secret/fluid-telegram/<campaign>/telegram instead, which is not its call to
make -- a service that picks its own paths in someone else's store is how a
policy ends up written around a mistake.

Mount and prefix are now BAO_MOUNT and FLUID_BAO_PREFIX, with the old scheme
kept as a development fallback. The runbook points at `warden access` for the
current shape and at OIDC login rather than a plain token, and names the
check that tells whether a login actually took.

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
2026-09-04 22:32:09 +02:00

178 lines
5.8 KiB
Go

// Package secrets is the provisioner's only route to credentials.
//
// Everything sensitive lives in OpenBao: the operator's MTProto session, the
// app credentials, the bot token, and the redaction salt. Nothing here writes a
// secret to disk, and nothing returns one in an error message -- an error says
// which path failed, never what was at it.
package secrets
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Paths under the interface's subtree. The campaign is part of the path so that
// two campaigns on one interface cannot read each other's credentials.
const (
KeyOperatorApp = "operator-app" // api_id, api_hash
KeyOperatorSession = "operator-session" // MTProto session; a full-account credential
KeyBotToken = "bot-token"
KeyRedactionSalt = "redaction-salt"
)
type Store struct {
addr string
token string
mount string
prefix string
hc *http.Client
}
// 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"))
// 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 {
case addr == "" && token == "":
return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " +
"(see docs/seeding-runbook.md)")
case addr == "":
return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)")
case token == "":
return nil, fmt.Errorf("no OpenBao token: set BAO_TOKEN, or run `bao login` "+
"to write ~/.vault-token (BAO_ADDR is %s)", addr)
}
// Mount and prefix are configurable because this repo does not get to invent
// the fleet's secret layout. ops-warden routes API-key needs to
// railiance-platform, whose convention is
// platform/workloads/<domain>/<workload>/<bundle> -- see
// ops-warden/wiki/CredentialRouting.md. The defaults below are a local
// fallback for development, not the intended production location.
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"), "fluid-telegram/"+campaign+"/telegram")
return &Store{
addr: strings.TrimSuffix(addr, "/"),
token: token,
mount: mount,
prefix: strings.Trim(prefix, "/"),
hc: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (s *Store) path(key string) string {
return fmt.Sprintf("%s/v1/%s/data/%s/%s", s.addr, s.mount, s.prefix, key)
}
// Ref is the human-readable location of a secret, safe to print. Used in plans
// and error messages so an operator can find what is missing.
func (s *Store) Ref(key string) string {
return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key)
}
type kvPayload struct {
Data struct {
Data map[string]string `json:"data"`
} `json:"data"`
}
// Get returns the fields at a path. Missing is reported as (nil, false, nil):
// absence is an ordinary state on a first run, not a failure.
func (s *Store) Get(ctx context.Context, key string) (map[string]string, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.path(key), nil)
if err != nil {
return nil, false, err
}
req.Header.Set("X-Vault-Token", s.token)
resp, err := s.hc.Do(req)
if err != nil {
return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotFound:
return nil, false, nil
case http.StatusOK:
default:
return nil, false, fmt.Errorf("read %s: unexpected status %s", s.Ref(key), resp.Status)
}
var p kvPayload
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err)
}
return p.Data.Data, true, nil
}
// Put replaces the fields at a path.
func (s *Store) Put(ctx context.Context, key string, fields map[string]string) error {
body, err := json.Marshal(map[string]any{"data": fields})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.path(key), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("X-Vault-Token", s.token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.hc.Do(req)
if err != nil {
return fmt.Errorf("write %s: %w", s.Ref(key), err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("write %s: unexpected status %s", s.Ref(key), resp.Status)
}
return nil
}
// CreateIfAbsent writes fields only when nothing is there, and reports whether
// it wrote. It has no counterpart that overwrites, and that is deliberate: the
// redaction salt is stored this way, and rotating it silently invalidates every
// longitudinal comparison the interface has made, with no visible failure. A
// tool that can rewrite it is a tool that eventually will.
func (s *Store) CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error) {
_, found, err := s.Get(ctx, key)
if err != nil {
return false, err
}
if found {
return false, nil
}
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 != "" {
return v
}
}
return ""
}