textmachine/platform/cmd/tmplatformctl/main.go

248 lines
8.2 KiB
Go

// Command tmplatformctl is the admin surface (P-8): credit an account, read a balance, look at
// sign-ins, end sessions.
//
// A CLI rather than a protected HTTP route, deliberately. An admin endpoint needs a second
// authorisation model — roles, an escalation path, a way to lose the admin cookie — for four
// operations. The trust boundary for these is already "can open a shell on the box and read the
// DSN", and that boundary is enforced by the machine rather than by code we would have to write
// and get right. If a browser-facing admin panel is ever wanted, it wraps these same store calls.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"text/tabwriter"
"time"
"textmachine/platform/internal/config"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
if errors.Is(err, errUsage) {
_, _ = fmt.Fprintln(os.Stderr, usage)
os.Exit(2)
}
_, _ = fmt.Fprintln(os.Stderr, "tmplatformctl:", err)
os.Exit(1)
}
}
// errUsage asks main to print the usage text; every other error is a message on its own.
var errUsage = errors.New("usage")
const usage = `usage: tmplatformctl <command> [flags]
grant --user <id> --usd <amount> [--note <text>] [--key <idempotency key>]
adjust --user <id> --usd <amount> --note <text> [--key <idempotency key>]
balance --user <id>
logins --user <id> [--limit <n>]
revoke --user <id>
The DSN comes from TM_PLATFORM_DSN or TM_PLATFORM_DSN_FILE.`
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
dsn, err := config.Secret("TM_PLATFORM_DSN")
if err != nil {
return err
}
if dsn == "" {
return errors.New("TM_PLATFORM_DSN (or TM_PLATFORM_DSN_FILE) is not set")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
store, err := pgstore.Open(ctx, dsn)
if err != nil {
return err
}
defer store.Close()
cmd, rest := args[0], args[1:]
switch cmd {
case "grant":
return grant(ctx, store, rest, out)
case "adjust":
return adjust(ctx, store, rest, out)
case "balance":
return balance(ctx, store, rest, out)
case "logins":
return logins(ctx, store, rest, out)
case "revoke":
return revoke(ctx, store, rest, out)
default:
return fmt.Errorf("unknown command %q: %w", cmd, errUsage)
}
}
// grant writes one ledger row: that is the whole of the free tier.
func grant(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("grant", flag.ContinueOnError)
user := fs.String("user", "", "account id")
amount := fs.String("usd", "", "amount in dollars, e.g. 5 or 2.50")
note := fs.String("note", "", "why")
// Idempotency is OPT-IN. A default key derived from the account and the day looked safe and was
// not: two legitimate grants on one day collapse into the first, and the second reports success
// while crediting nothing. Each invocation is its own intent unless the operator says otherwise.
key := fs.String("key", "", "idempotency key: repeating the command with the same one is a no-op")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" || *amount == "" {
return errors.New("grant needs --user and --usd")
}
micro, err := money.ParseUSD(*amount)
if err != nil {
return err
}
return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) {
return store.Grant(ctx, *user, micro, "admin", id, *note, now)
}, "granted "+micro.USD()+" to "+*user)
}
// adjust corrects a balance with a second row: ledger rows are never edited.
func adjust(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("adjust", flag.ContinueOnError)
user := fs.String("user", "", "account id")
amount := fs.String("usd", "", "signed amount in dollars, e.g. -2.50")
note := fs.String("note", "", "why (required: an unexplained correction is unauditable)")
key := fs.String("key", "", "idempotency key")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" || *amount == "" || *note == "" {
return errors.New("adjust needs --user, --usd and --note")
}
micro, err := money.ParseUSD(*amount)
if err != nil {
return err
}
return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) {
return store.Adjust(ctx, *user, micro, "admin", id, *note, now)
}, "adjusted "+*user+" by "+micro.USD())
}
// write runs one ledger operation and reports what actually happened. "Applied" and "the key was
// already spent" are different outcomes and the operator is told which one they got.
func write(ctx context.Context, store *pgstore.Store, out io.Writer, user, key string,
op func(id string, now time.Time) (bool, error), what string) error {
now := time.Now().UTC()
id := key
if id == "" {
id = newKey()
}
applied, err := op(id, now)
if err != nil {
return err
}
// Past this point the write is committed and NOTHING may report failure. An operator who reads
// an error retries, and a retry without --key mints a fresh idempotency key, so the second run
// credits again — a failed BALANCE READ would have bought a double credit. The balance is a
// courtesy; its failure is a note on the same line. Found by review.
shown := "balance unavailable: " + user
if after, err := store.Balance(ctx, user); err == nil {
shown = "balance is " + after.USD()
} else {
_, _ = fmt.Fprintf(out, "warning: could not read the balance back: %v\n", err)
}
if !applied {
_, _ = fmt.Fprintf(out, "no-op: key %s was already used on %s; %s\n", id, user, shown)
return nil
}
_, _ = fmt.Fprintf(out, "%s (key %s); %s\n", what, id, shown)
return nil
}
// newKey mints a key for a one-off command, so that two deliberate grants on the same day are two
// grants.
func newKey() string {
var b [8]byte
rand.Read(b[:]) // never fails
return "cli-" + hex.EncodeToString(b[:])
}
func balance(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("balance", flag.ContinueOnError)
user := fs.String("user", "", "account id")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("balance needs --user")
}
// One snapshot: reading the cache and the ledger in two queries reports drift that a concurrent
// grant caused between them.
a, err := store.ReadAccount(ctx, *user)
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "balance %s\n", a.Balance.USD())
if a.Reserved != 0 {
_, _ = fmt.Fprintf(out, "reserved %s (open holds, already deducted)\n", a.Reserved.USD())
}
if a.Balance != a.LedgerSum {
_, _ = fmt.Fprintf(out, "⚠ ledger sums to %s: the cached balance has drifted\n", a.LedgerSum.USD())
}
open, err := store.OpenReservations(ctx, *user)
if err != nil {
return err
}
for _, r := range open {
_, _ = fmt.Fprintf(out, " hold %s on book %s since %s (run %s)\n",
r.Amount.USD(), r.BookID, r.OpenedAt.UTC().Format(time.RFC3339), r.EngineRunID)
}
return nil
}
func logins(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("logins", flag.ContinueOnError)
user := fs.String("user", "", "account id")
limit := fs.Int("limit", 20, "how many")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("logins needs --user")
}
entries, err := store.RecentLogins(ctx, *user, *limit)
if err != nil {
return err
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "WHEN\tPROVIDER\tOUTCOME\tCLIENT\tFROM\tREASON")
for _, e := range entries {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
e.At.UTC().Format(time.RFC3339), e.Provider, e.Outcome, e.Client, e.IPPrefix, e.Reason)
}
return w.Flush()
}
func revoke(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("revoke", flag.ContinueOnError)
user := fs.String("user", "", "account id")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("revoke needs --user")
}
n, err := store.RevokeUserSessions(ctx, *user, time.Now().UTC())
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "revoked %d sessions of %s\n", n, *user)
return nil
}