Compare commits
6
Commits
b436e46e2a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7831cd1fe
|
||
|
|
629d515911
|
||
|
|
2dc9a30644
|
||
|
|
13627a369a
|
||
|
|
1aa524fb22
|
||
|
|
445acb18da
|
@@ -1,16 +1,35 @@
|
||||
.PHONY: build run gen test tidy
|
||||
BINARY := bin/hammyd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
|
||||
.PHONY: build run gen test race cover lint tidy clean
|
||||
|
||||
build:
|
||||
go build -o bin/hammyd ./cmd/hammyd
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/hammyd
|
||||
|
||||
run:
|
||||
go run ./cmd/hammyd
|
||||
go run -ldflags "$(LDFLAGS)" ./cmd/hammyd serve
|
||||
|
||||
# Regenerate internal/db from queries/*.sql. Run after touching a migration or
|
||||
# a query; forgetting turns a compile error into a runtime scan error.
|
||||
gen:
|
||||
sqlc generate
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
race:
|
||||
go test -race ./...
|
||||
|
||||
cover:
|
||||
go test -cover ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
gofmt -l .
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
|
||||
)
|
||||
|
||||
// runKeygen issues an API key from the command line. This is how you get the
|
||||
// first key, before there is any self-service registration, and how you issue
|
||||
// yourself something to test against.
|
||||
//
|
||||
// hammyd keygen -email [email protected] -scopes reference,callsign
|
||||
func runKeygen(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
||||
|
||||
email := fs.String("email", "", "owner email (required); created if new")
|
||||
callsign := fs.String("callsign", "", "owner callsign (optional, uppercase)")
|
||||
label := fs.String("label", "", "what this key is for, shown in listings")
|
||||
scopes := fs.String("scopes", "reference", "comma-separated scopes")
|
||||
tier := fs.String("tier", "default", "quota tier: default, verified, trusted, internal")
|
||||
env := fs.String("env", "live", "live or test")
|
||||
expires := fs.Duration("expires", 0, "optional lifetime, e.g. 720h. Zero means no expiry")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *email == "" {
|
||||
fs.Usage()
|
||||
|
||||
return errors.New("keygen: -email is required")
|
||||
}
|
||||
|
||||
// The core.email domain requires lowercase and core.callsign requires
|
||||
// uppercase. Normalise here so the CLI is forgiving and the database stays
|
||||
// canonical.
|
||||
normEmail := strings.ToLower(strings.TrimSpace(*email))
|
||||
normCall := strings.ToUpper(strings.TrimSpace(*callsign))
|
||||
|
||||
dsn := os.Getenv("HAMMY_DSN")
|
||||
if dsn == "" {
|
||||
return errors.New("keygen: HAMMY_DSN is not set")
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keygen: connect: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
q := db.New(pool)
|
||||
|
||||
owner, err := q.OwnerByEmail(ctx, normEmail)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var callsignArg any
|
||||
if normCall != "" {
|
||||
callsignArg = normCall
|
||||
}
|
||||
|
||||
created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{
|
||||
Email: normEmail,
|
||||
Callsign: callsignArg,
|
||||
})
|
||||
if cerr != nil {
|
||||
return fmt.Errorf("keygen: creating owner: %w", cerr)
|
||||
}
|
||||
|
||||
owner.ID = created.ID
|
||||
fmt.Printf("created owner %d for %s\n", owner.ID, normEmail)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("keygen: looking up owner: %w", err)
|
||||
}
|
||||
|
||||
gen, err := apikey.Generate(apikey.Environment(*env))
|
||||
if err != nil {
|
||||
return fmt.Errorf("keygen: %w", err)
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if *expires > 0 {
|
||||
t := time.Now().Add(*expires)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
scopeList := splitScopes(*scopes)
|
||||
|
||||
key, err := q.CreateKey(ctx, db.CreateKeyParams{
|
||||
OwnerID: owner.ID,
|
||||
KeyHash: gen.Hash,
|
||||
KeyPrefix: gen.Display,
|
||||
Label: nullableString(*label),
|
||||
Scopes: scopeList,
|
||||
QuotaTier: *tier,
|
||||
ExpiresAt: db.Timestamptz(expiresAt),
|
||||
})
|
||||
if err != nil {
|
||||
// The keys_scopes_known CHECK rejects unknown scopes, which is the
|
||||
// most likely failure here and worth naming.
|
||||
return fmt.Errorf("keygen: creating key (check scopes against migrations/003_api.sql): %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Key issued. This is the only time it will be shown.")
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", gen.Plaintext)
|
||||
fmt.Println()
|
||||
fmt.Printf(" id %d\n", key.ID)
|
||||
fmt.Printf(" owner %d (%s)\n", owner.ID, normEmail)
|
||||
fmt.Printf(" scopes %s\n", strings.Join(scopeList, ", "))
|
||||
fmt.Printf(" tier %s\n", key.QuotaTier)
|
||||
|
||||
if expiresAt != nil {
|
||||
fmt.Printf(" expires %s\n", expiresAt.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
fmt.Printf(" expires never\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Test it with:")
|
||||
fmt.Printf(" curl -H 'Authorization: Bearer %s' http://localhost:8080/v1/whoami\n", gen.Plaintext)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitScopes(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func nullableString(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &s
|
||||
}
|
||||
+248
-29
@@ -1,37 +1,256 @@
|
||||
// cmd/hammyd/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/api"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/config"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
// version is stamped at build time:
|
||||
// go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/hammyd
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
dsn := os.Getenv("HAMMY_DSN")
|
||||
if dsn == "" {
|
||||
log.Fatal("HAMMY_DSN is not set!")
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
log.Fatalf("ping: %v", err)
|
||||
}
|
||||
|
||||
var n int
|
||||
err = pool.QueryRow(ctx, "SELECT count(*) FROM core.users").Scan(&n)
|
||||
if err != nil {
|
||||
log.Fatalf("query: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("connected, %d users", n)
|
||||
// All real work happens in run() so that deferred cleanup actually runs.
|
||||
// os.Exit skips defers, so calling it anywhere but here leaks the pool and
|
||||
// drops in-flight requests.
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "hammyd: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// NotifyContext cancels on SIGINT/SIGTERM, which is what gives the server
|
||||
// a chance to finish in-flight requests instead of being killed mid-write.
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
args := os.Args[1:]
|
||||
|
||||
cmd := "serve"
|
||||
if len(args) > 0 {
|
||||
cmd, args = args[0], args[1:]
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "serve":
|
||||
return runServe(ctx, args)
|
||||
case "keygen":
|
||||
return runKeygen(ctx, args)
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
|
||||
return nil
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
|
||||
return nil
|
||||
default:
|
||||
usage()
|
||||
|
||||
return fmt.Errorf("unknown command %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `hammyd %s
|
||||
|
||||
Usage:
|
||||
hammyd serve Run the API server (default)
|
||||
hammyd keygen -email … Issue an API key
|
||||
hammyd version
|
||||
|
||||
Configuration comes from the environment:
|
||||
HAMMY_DSN Postgres connection string (required)
|
||||
HAMMY_ADDR Listen address (default 127.0.0.1:8080)
|
||||
HAMMY_REDIS_ADDR Redis for shared rate limiting (optional)
|
||||
HAMMY_MAX_CONNS Postgres pool size (default 10)
|
||||
HAMMY_LOG_LEVEL debug, info, warn, error (default info)
|
||||
HAMMY_LOG_JSON true for structured logs
|
||||
HAMMY_ENDUSER_HEADER Header carrying the end-user id (default X-Hammy-User)
|
||||
`, version)
|
||||
}
|
||||
|
||||
func runServe(ctx context.Context, _ []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := newLogger(cfg)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
logger.Info("starting", "version", version, "config", cfg.Redacted())
|
||||
|
||||
// ---- Postgres ---------------------------------------------------------
|
||||
|
||||
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing DSN: %w", err)
|
||||
}
|
||||
|
||||
// Postgres forks a backend process per connection, so the pool size is a
|
||||
// real resource on the server, not just a client-side knob.
|
||||
poolCfg.MaxConns = cfg.MaxConns
|
||||
poolCfg.MaxConnLifetime = time.Hour
|
||||
poolCfg.MaxConnIdleTime = 15 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to postgres: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Fail at startup rather than on the first request. A backend that boots
|
||||
// happily and 500s on everything is much harder to diagnose.
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
return fmt.Errorf("pinging postgres: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("postgres connected", "max_conns", cfg.MaxConns)
|
||||
|
||||
// ---- Redis, or not ----------------------------------------------------
|
||||
|
||||
var limiter ratelimit.Limiter
|
||||
|
||||
if cfg.RedisAddr == "" {
|
||||
// A single self-hosted instance does not need Redis to have working
|
||||
// rate limits. Several instances do: each process would keep its own
|
||||
// counters, so N processes allow N times the limit.
|
||||
logger.Warn("HAMMY_REDIS_ADDR is unset, using an in-process limiter. " +
|
||||
"Correct for one instance, wrong for several.")
|
||||
|
||||
mem := ratelimit.NewMemory()
|
||||
limiter = mem
|
||||
|
||||
go sweepPeriodically(ctx, mem, time.Minute, logger)
|
||||
} else {
|
||||
rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, DB: cfg.RedisDB})
|
||||
defer rdb.Close()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("connecting to redis: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("redis connected", "addr", cfg.RedisAddr)
|
||||
|
||||
limiter = ratelimit.NewRedis(ratelimit.GoRedis{Client: rdb})
|
||||
}
|
||||
|
||||
// ---- HTTP -------------------------------------------------------------
|
||||
|
||||
queries := db.New(pool)
|
||||
|
||||
router := &api.Router{
|
||||
Auth: &api.Authenticator{
|
||||
Keys: api.PgKeyStore{Q: queries},
|
||||
Limiter: limiter,
|
||||
Logger: logger,
|
||||
EndUserHeader: cfg.EndUserHeader,
|
||||
},
|
||||
Logger: logger,
|
||||
Version: version,
|
||||
Ready: func() error {
|
||||
// Readiness checks dependencies; liveness deliberately does not.
|
||||
c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return pool.Ping(c)
|
||||
},
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: router.Handler(),
|
||||
|
||||
// ReadHeaderTimeout is the one that matters: without it, a client can
|
||||
// hold a connection open by dribbling headers forever (Slowloris).
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
|
||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
logger.Info("listening", "addr", cfg.Addr)
|
||||
|
||||
// ErrServerClosed is the normal result of Shutdown, not a failure.
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("listening: %w", err)
|
||||
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received, draining")
|
||||
}
|
||||
|
||||
// A FRESH context: ctx is already cancelled, and passing it to Shutdown
|
||||
// would abort in-flight requests immediately, which is the opposite of
|
||||
// what a graceful shutdown is for.
|
||||
shutdownCtx, cancelShutdown := context.WithTimeout(
|
||||
context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancelShutdown()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("stopped cleanly")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweepPeriodically drops expired windows from the in-process limiter. Without
|
||||
// it the map grows for every key and end user ever seen.
|
||||
func sweepPeriodically(ctx context.Context, m *ratelimit.Memory, every time.Duration, logger *slog.Logger) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n := m.Sweep(); n > 0 {
|
||||
logger.Debug("swept rate limit windows", "removed", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newLogger(cfg config.Config) *slog.Logger {
|
||||
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
|
||||
|
||||
if cfg.LogJSON {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
// Scope names. These must match the keys_scopes_known CHECK in
|
||||
// migrations/003_api.sql; the database is the source of truth and will reject
|
||||
// anything not listed there.
|
||||
const (
|
||||
ScopeReference = "reference"
|
||||
ScopeCallsign = "callsign"
|
||||
ScopeSpots = "spots"
|
||||
ScopePropagation = "propagation"
|
||||
ScopeLogbook = "logbook"
|
||||
ScopeNet44 = "net44"
|
||||
ScopeAdmin = "admin"
|
||||
)
|
||||
|
||||
// ErrKeyNotFound is what a KeyStore returns when no active key matches.
|
||||
var ErrKeyNotFound = errors.New("api: key not found")
|
||||
|
||||
// KeyRecord is what authentication needs from the database. Deliberately not
|
||||
// the sqlc-generated type.
|
||||
type KeyRecord struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
Scopes []string
|
||||
QuotaTier string
|
||||
Status string
|
||||
OwnerStatus string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// KeyStore is the database slice this middleware needs.
|
||||
type KeyStore interface {
|
||||
KeyByHash(ctx context.Context, hash []byte) (KeyRecord, error)
|
||||
}
|
||||
|
||||
// Principal is the authenticated caller, attached to the request context.
|
||||
type Principal struct {
|
||||
KeyID int64
|
||||
OwnerID int64
|
||||
Scopes []string
|
||||
QuotaTier string
|
||||
|
||||
// EndUser is the caller's own identifier for whoever triggered the
|
||||
// request - for the bot, a hashed Discord user ID. Optional, but without
|
||||
// it a single user can consume a whole instance's quota, and the
|
||||
// per-user counter is the enumeration signal worth watching.
|
||||
EndUser string
|
||||
}
|
||||
|
||||
// Has reports whether the principal holds a scope. Admin is NOT a wildcard -
|
||||
// making it one means a single compromised admin key reaches everything, and
|
||||
// makes it impossible to tell from the database what a key can actually do.
|
||||
func (p *Principal) Has(scope string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, s := range p.Scopes {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
var principalKey contextKey
|
||||
|
||||
// PrincipalFrom returns the authenticated caller, or nil if the request did not
|
||||
// pass through Authenticate.
|
||||
func PrincipalFrom(ctx context.Context) *Principal {
|
||||
p, _ := ctx.Value(principalKey).(*Principal)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Authenticator resolves a bearer token to a Principal and enforces quota.
|
||||
type Authenticator struct {
|
||||
Keys KeyStore
|
||||
Limiter ratelimit.Limiter
|
||||
Logger *slog.Logger
|
||||
|
||||
// Now is injectable so expiry can be tested without waiting.
|
||||
Now func() time.Time
|
||||
|
||||
// EndUserHeader carries the caller's identifier for the person on whose
|
||||
// behalf the request is made. Empty disables per-user limiting.
|
||||
EndUserHeader string
|
||||
}
|
||||
|
||||
func (a *Authenticator) now() time.Time {
|
||||
if a.Now != nil {
|
||||
return a.Now()
|
||||
}
|
||||
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (a *Authenticator) log() *slog.Logger {
|
||||
if a.Logger != nil {
|
||||
return a.Logger
|
||||
}
|
||||
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// bearer pulls the token out of an Authorization header.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Scheme is case-insensitive per RFC 7235, and clients get this wrong.
|
||||
const prefix = "bearer "
|
||||
if len(h) < len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
|
||||
// Authenticate is the middleware. Every route that touches user data or an
|
||||
// upstream feed must sit behind it.
|
||||
//
|
||||
// The bot is NOT a trusted client: it is GPL, so anyone can fork it, strip its
|
||||
// checks and point it at this API. Scope and quota decisions therefore live
|
||||
// here, on the server, and the bot's versions of them are a UX nice-ty that
|
||||
// saves a round trip.
|
||||
func (a *Authenticator) Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
WriteError(w, http.StatusUnauthorized, CodeMissingKey,
|
||||
"Provide an API key as 'Authorization: Bearer hmy_live_...'.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Structure and checksum first: a flood of garbage headers costs no
|
||||
// database queries.
|
||||
if _, err := apikey.Validate(token); err != nil {
|
||||
WriteError(w, http.StatusUnauthorized, CodeInvalidKey,
|
||||
"That does not look like a Hammy API key.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := a.Keys.KeyByHash(r.Context(), apikey.Hash(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrKeyNotFound) {
|
||||
WriteError(w, http.StatusUnauthorized, CodeInvalidKey,
|
||||
"Unknown API key.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.log().Error("key lookup failed", "err", err, "key", apikey.Redact(token))
|
||||
WriteError(w, http.StatusInternalServerError, CodeInternal,
|
||||
"Could not verify that key. Try again shortly.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if code, msg, ok := checkUsable(&rec, a.now()); !ok {
|
||||
status := http.StatusUnauthorized
|
||||
if code == CodeOwnerBlocked {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
|
||||
WriteError(w, status, code, msg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
principal := &Principal{
|
||||
KeyID: rec.ID,
|
||||
OwnerID: rec.OwnerID,
|
||||
Scopes: rec.Scopes,
|
||||
QuotaTier: rec.QuotaTier,
|
||||
}
|
||||
|
||||
if a.EndUserHeader != "" {
|
||||
principal.EndUser = r.Header.Get(a.EndUserHeader)
|
||||
}
|
||||
|
||||
if !a.allow(w, r, principal) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), principalKey, principal)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// checkUsable applies the status and expiry rules. Split out so it can be
|
||||
// tested directly and so the reasons stay in one readable place.
|
||||
func checkUsable(rec *KeyRecord, now time.Time) (code, message string, ok bool) {
|
||||
switch rec.Status {
|
||||
case "revoked":
|
||||
return CodeKeyRevoked, "This key has been revoked.", false
|
||||
case "suspended":
|
||||
return CodeKeySuspended, "This key is suspended. Check your email for the reason.", false
|
||||
case "active":
|
||||
// fall through
|
||||
default:
|
||||
return CodeInvalidKey, "This key is not usable.", false
|
||||
}
|
||||
|
||||
if rec.OwnerStatus != "active" {
|
||||
return CodeOwnerBlocked, "This account is not active.", false
|
||||
}
|
||||
|
||||
if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) {
|
||||
return CodeKeyExpired, "This key expired. Issue a new one and rotate.", false
|
||||
}
|
||||
|
||||
return "", "", true
|
||||
}
|
||||
|
||||
// allow enforces both ceilings and writes the 429 itself. Returns false if the
|
||||
// request must stop.
|
||||
func (a *Authenticator) allow(w http.ResponseWriter, r *http.Request, p *Principal) bool {
|
||||
if a.Limiter == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
quota := ratelimit.QuotaFor(p.QuotaTier)
|
||||
|
||||
res, err := a.Limiter.Allow(r.Context(),
|
||||
"key:"+strconv.FormatInt(p.KeyID, 10), quota.KeyLimit, quota.Window)
|
||||
if err != nil {
|
||||
// Fail OPEN. A Redis outage taking the whole API down with it is a
|
||||
// worse outcome than briefly unmetered traffic - the limiter protects
|
||||
// against abuse, it is not an authentication control.
|
||||
a.log().Error("rate limiter unavailable, allowing", "err", err, "key_id", p.KeyID)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
writeLimitHeaders(w, res)
|
||||
|
||||
if !res.Allowed {
|
||||
WriteError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"Rate limit exceeded for this API key.")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-user ceiling, if the caller told us who this is for.
|
||||
if p.EndUser != "" && quota.UserLimit > 0 {
|
||||
userRes, err := a.Limiter.Allow(r.Context(),
|
||||
"user:"+strconv.FormatInt(p.KeyID, 10)+":"+p.EndUser,
|
||||
quota.UserLimit, quota.Window)
|
||||
if err != nil {
|
||||
a.log().Error("per-user rate limiter unavailable, allowing", "err", err)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if !userRes.Allowed {
|
||||
writeLimitHeaders(w, userRes)
|
||||
WriteError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"Rate limit exceeded for this user.")
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func writeLimitHeaders(w http.ResponseWriter, res ratelimit.Result) {
|
||||
if res.Limit <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(res.Limit))
|
||||
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining))
|
||||
|
||||
if !res.Allowed {
|
||||
secs := int(res.RetryAfter.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
}
|
||||
}
|
||||
|
||||
// RequireScope wraps a handler so it only runs for principals holding the
|
||||
// scope. Authorisation is separate from authentication on purpose - a key being
|
||||
// valid says nothing about what it may do.
|
||||
func RequireScope(scope string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
if !p.Has(scope) {
|
||||
WriteError(w, http.StatusForbidden, CodeForbidden,
|
||||
"This key does not have the '"+scope+"' scope.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrip(t *testing.T) {
|
||||
for _, env := range []Environment{Live, Test} {
|
||||
g, err := Generate(env)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", env, err)
|
||||
}
|
||||
|
||||
got, err := Validate(g.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(%q): %v", g.Plaintext, err)
|
||||
}
|
||||
|
||||
if got != env {
|
||||
t.Errorf("environment round trip: got %q want %q", got, env)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(g.Plaintext, Issuer+"_"+string(env)+"_") {
|
||||
t.Errorf("prefix wrong: %q", g.Plaintext)
|
||||
}
|
||||
|
||||
if len(g.Hash) != 32 {
|
||||
t.Errorf("hash length %d, want 32 (the api.keys CHECK requires it)", len(g.Hash))
|
||||
}
|
||||
|
||||
if !bytes.Equal(g.Hash, Hash(g.Plaintext)) {
|
||||
t.Error("Generate's hash differs from Hash of its own plaintext")
|
||||
}
|
||||
|
||||
if len(g.Display) != displayLen {
|
||||
t.Errorf("display length %d, want %d", len(g.Display), displayLen)
|
||||
}
|
||||
|
||||
if strings.Contains(g.Plaintext[len(g.Display):], "") && g.Display == g.Plaintext {
|
||||
t.Error("display prefix is the whole key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIsUnique(t *testing.T) {
|
||||
const n = 2000
|
||||
|
||||
seen := make(map[string]bool, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
|
||||
if seen[g.Plaintext] {
|
||||
t.Fatalf("duplicate key after %d generations", i)
|
||||
}
|
||||
|
||||
seen[g.Plaintext] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownEnvironment(t *testing.T) {
|
||||
if _, err := Generate(Environment("prod")); !errors.Is(err, ErrEnvironment) {
|
||||
t.Errorf("got %v, want ErrEnvironment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformed(t *testing.T) {
|
||||
good, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrMalformed},
|
||||
{"no separators", "notakey", ErrMalformed},
|
||||
{"one separator", "hmy_live", ErrMalformed},
|
||||
{"wrong issuer", "xxx_live_" + good.Plaintext[9:], ErrMalformed},
|
||||
{"unknown env", "hmy_prod_" + good.Plaintext[9:], ErrEnvironment},
|
||||
{"body too short", "hmy_live_abc", ErrMalformed},
|
||||
{"body too long", good.Plaintext + "X", ErrMalformed},
|
||||
{"body truncated by one", good.Plaintext[:len(good.Plaintext)-1], ErrMalformed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := Validate(c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("Validate(%q) = %v, want %v", c.key, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the checksum: a key with a typo is rejected without a database
|
||||
// round trip.
|
||||
func TestValidateCatchesTampering(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := []byte(g.Plaintext)
|
||||
caught := 0
|
||||
|
||||
// Flip one character at each position in the secret and confirm the
|
||||
// checksum notices.
|
||||
for i := len("hmy_live_"); i < len(body)-checksumLen; i++ {
|
||||
orig := body[i]
|
||||
|
||||
if orig == 'A' {
|
||||
body[i] = 'B'
|
||||
} else {
|
||||
body[i] = 'A'
|
||||
}
|
||||
|
||||
if _, err := Validate(string(body)); errors.Is(err, ErrBadChecksum) {
|
||||
caught++
|
||||
}
|
||||
|
||||
body[i] = orig
|
||||
}
|
||||
|
||||
total := len(body) - checksumLen - len("hmy_live_")
|
||||
|
||||
if caught != total {
|
||||
t.Errorf("checksum caught %d/%d single-character corruptions", caught, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsStable(t *testing.T) {
|
||||
const key = "hmy_live_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGabcdef"
|
||||
|
||||
a, b := Hash(key), Hash(key)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Error("Hash is not deterministic")
|
||||
}
|
||||
|
||||
if bytes.Equal(a, Hash(key+"x")) {
|
||||
t.Error("Hash collided on different input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Redact(g.Plaintext)
|
||||
|
||||
if strings.Contains(g.Plaintext, r) && len(r) >= len(g.Plaintext) {
|
||||
t.Error("Redact returned the whole key")
|
||||
}
|
||||
|
||||
if len(r) > displayLen+3 {
|
||||
t.Errorf("Redact returned %d chars, too much", len(r))
|
||||
}
|
||||
|
||||
// A secret must never survive redaction.
|
||||
secret := g.Plaintext[len("hmy_live_"):]
|
||||
if strings.Contains(r, secret) {
|
||||
t.Error("Redact leaked the secret")
|
||||
}
|
||||
|
||||
if got := Redact("short"); got != "hmy_***" {
|
||||
t.Errorf("Redact(short) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error is the single shape every failure takes. Clients get a stable machine
|
||||
// readable code plus a human message; a self-hosted bot instance can branch on
|
||||
// the code without parsing prose.
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
const (
|
||||
CodeMissingKey = "missing_key"
|
||||
CodeInvalidKey = "invalid_key"
|
||||
CodeKeyRevoked = "key_revoked"
|
||||
CodeKeySuspended = "key_suspended"
|
||||
CodeKeyExpired = "key_expired"
|
||||
CodeOwnerBlocked = "owner_blocked"
|
||||
CodeForbidden = "insufficient_scope"
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
)
|
||||
|
||||
// WriteError sends a JSON error. Deliberately specific about WHY authentication
|
||||
// failed rather than a uniform "unauthorized".
|
||||
//
|
||||
// The usual argument for being vague is to prevent enumeration, but a key is
|
||||
// 256 bits from crypto/rand - there is nothing to enumerate. Telling a
|
||||
// developer their key expired, rather than making them guess, is worth far more
|
||||
// than the non-existent secrecy gained.
|
||||
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(errorEnvelope{Error{Code: code, Message: message}}); err != nil {
|
||||
slog.Error("writing error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeErrorBody writes just the envelope, for callers that have already sent
|
||||
// their own status code and headers.
|
||||
func writeErrorBody(w http.ResponseWriter, code, message string) error {
|
||||
return json.NewEncoder(w).Encode(errorEnvelope{Error{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// WriteJSON sends a success payload.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("writing response", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
// RequestIDFrom returns the id assigned by WithRequestID, or "".
|
||||
func RequestIDFrom(ctx context.Context) string {
|
||||
id, _ := ctx.Value(requestIDKey{}).(string)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// WithRequestID tags each request so a log line can be tied to a client report.
|
||||
// Honours an inbound X-Request-Id so a self-hosted bot can correlate its own
|
||||
// logs with the backend's.
|
||||
func WithRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-Id")
|
||||
|
||||
if id == "" || len(id) > 64 {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
id = hex.EncodeToString(b[:])
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the status code, which http.ResponseWriter does not
|
||||
// expose after the fact.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||
if s.status == 0 {
|
||||
s.status = http.StatusOK
|
||||
}
|
||||
|
||||
n, err := s.ResponseWriter.Write(b)
|
||||
s.bytes += n
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// WithLogging records one line per request.
|
||||
//
|
||||
// Deliberately does NOT log the Authorization header, the query string or the
|
||||
// body. Logs get shipped, grepped and retained far longer than anyone intends,
|
||||
// and the query string is where callsigns and grid squares live.
|
||||
func WithLogging(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
if rec.status == 0 {
|
||||
rec.status = http.StatusOK
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFrom(r.Context()),
|
||||
}
|
||||
|
||||
// Key id rather than the key, and only once authentication has
|
||||
// resolved one.
|
||||
if p := PrincipalFrom(r.Context()); p != nil {
|
||||
attrs = append(attrs, "key_id", p.KeyID, "owner_id", p.OwnerID)
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
if rec.status >= 500 {
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
logger.Log(r.Context(), level, "request", attrs...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithRecovery turns a panic in a handler into a 500 rather than a dead
|
||||
// process. Go's default is to kill the whole server on an unrecovered panic in
|
||||
// a handler goroutine.
|
||||
func WithRecovery(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A closed connection surfaces as a panic with this value and
|
||||
// is not a bug; re-panic so net/http handles it as it expects.
|
||||
if rec == http.ErrAbortHandler {
|
||||
panic(rec)
|
||||
}
|
||||
|
||||
logger.Error("panic in handler",
|
||||
"err", rec,
|
||||
"path", r.URL.Path,
|
||||
"request_id", RequestIDFrom(r.Context()),
|
||||
"stack", string(debug.Stack()))
|
||||
|
||||
WriteError(w, http.StatusInternalServerError, CodeInternal,
|
||||
"Something went wrong. The incident has been logged.")
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Chain applies middleware so that the first argument is the outermost, which
|
||||
// reads in the order requests actually traverse them.
|
||||
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Router builds the HTTP surface. Kept out of main.go so it can be exercised in
|
||||
// tests with a fake KeyStore, which is where the auth wiring actually gets
|
||||
// verified.
|
||||
type Router struct {
|
||||
Auth *Authenticator
|
||||
Logger *slog.Logger
|
||||
Version string
|
||||
|
||||
// Ready reports whether dependencies are healthy. Nil means always ready.
|
||||
Ready func() error
|
||||
}
|
||||
|
||||
func (rt *Router) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Unauthenticated. Liveness must not touch the database: a health check
|
||||
// that fails when Postgres blips will get the process killed by whatever
|
||||
// supervises it, which turns a database wobble into an outage.
|
||||
mux.Handle("GET /healthz", rt.healthz())
|
||||
|
||||
// Readiness DOES check dependencies. That is the difference.
|
||||
mux.Handle("GET /readyz", rt.readyz())
|
||||
|
||||
// Everything below needs a key.
|
||||
authed := func(scope string, h http.Handler) http.Handler {
|
||||
return rt.Auth.Authenticate(RequireScope(scope, h))
|
||||
}
|
||||
|
||||
mux.Handle("GET /v1/whoami", rt.Auth.Authenticate(Whoami()))
|
||||
mux.Handle("GET /v1/reference/ping", authed(ScopeReference, rt.stub("reference")))
|
||||
|
||||
// Deliberately NO catch-all mux.Handle("/", ...) here.
|
||||
//
|
||||
// A "/" pattern matches every path, so it would also swallow method
|
||||
// mismatches: POST /healthz would match "/" and return 404 instead of the
|
||||
// 405 that Go's ServeMux produces on its own. jsonifyErrors below rewrites
|
||||
// ServeMux's plain-text 404 and 405 bodies into the standard envelope,
|
||||
// which keeps both correct status codes and uniform JSON errors.
|
||||
return Chain(mux,
|
||||
WithRequestID,
|
||||
WithRecovery(rt.Logger),
|
||||
WithLogging(rt.Logger),
|
||||
jsonifyErrors,
|
||||
)
|
||||
}
|
||||
|
||||
func (rt *Router) healthz() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"version": rt.Version,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (rt *Router) readyz() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if rt.Ready != nil {
|
||||
if err := rt.Ready(); err != nil {
|
||||
rt.Logger.Warn("readiness check failed", "err", err)
|
||||
WriteError(w, http.StatusServiceUnavailable, "not_ready", "Dependencies are unavailable.")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
}
|
||||
|
||||
// jsonifyErrors converts net/http's built-in plain-text 404 and 405 responses
|
||||
// into the same JSON envelope every other error uses, so a client never has to
|
||||
// branch on Content-Type to read an error.
|
||||
func jsonifyErrors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rw := &errorRewriter{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
rw.finish()
|
||||
})
|
||||
}
|
||||
|
||||
type errorRewriter struct {
|
||||
http.ResponseWriter
|
||||
intercepted bool
|
||||
wroteHeader bool
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *errorRewriter) WriteHeader(code int) {
|
||||
if e.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
e.wroteHeader = true
|
||||
e.status = code
|
||||
|
||||
// Only touch responses net/http generated itself.
|
||||
//
|
||||
// Checking for an EMPTY Content-Type does not work: http.NotFound sets
|
||||
// "text/plain; charset=utf-8" before calling WriteHeader, so by the time we
|
||||
// see it the header is already populated. Test for "not already JSON"
|
||||
// instead - every handler in this package writes JSON, so anything else at
|
||||
// a 404 or 405 came from net/http.
|
||||
if code == http.StatusNotFound || code == http.StatusMethodNotAllowed {
|
||||
if !strings.HasPrefix(e.Header().Get("Content-Type"), "application/json") {
|
||||
e.intercepted = true
|
||||
e.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
e.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (e *errorRewriter) Write(b []byte) (int, error) {
|
||||
if !e.wroteHeader {
|
||||
e.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Swallow the plain-text body; finish writes the JSON one.
|
||||
if e.intercepted {
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
return e.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (e *errorRewriter) finish() {
|
||||
if !e.intercepted {
|
||||
return
|
||||
}
|
||||
|
||||
code, msg := "not_found", "No such endpoint."
|
||||
if e.status == http.StatusMethodNotAllowed {
|
||||
code, msg = "method_not_allowed", "That method is not allowed on this endpoint."
|
||||
}
|
||||
|
||||
_ = writeErrorBody(e.ResponseWriter, code, msg)
|
||||
}
|
||||
|
||||
func (rt *Router) stub(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"endpoint": name, "status": "not implemented yet"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
func testRouter(t *testing.T, ready func() error) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
rt := &Router{
|
||||
Auth: &Authenticator{
|
||||
Keys: &fakeStore{rec: activeRecord()},
|
||||
Limiter: ratelimit.NewMemory(),
|
||||
Logger: logger,
|
||||
},
|
||||
Logger: logger,
|
||||
Version: "test",
|
||||
Ready: ready,
|
||||
}
|
||||
|
||||
return rt.Handler()
|
||||
}
|
||||
|
||||
func TestHealthzNeedsNoKey(t *testing.T) {
|
||||
h := testRouter(t, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
if id := w.Header().Get("X-Request-Id"); id == "" {
|
||||
t.Error("no X-Request-Id on the response")
|
||||
}
|
||||
}
|
||||
|
||||
// Liveness must not depend on Postgres. If it did, a database blip would get
|
||||
// the process killed by its supervisor and turn a wobble into an outage.
|
||||
func TestHealthzIgnoresDependencies(t *testing.T) {
|
||||
h := testRouter(t, func() error { return errors.New("postgres down") })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("healthz status %d with a failing dependency, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzReflectsDependencies(t *testing.T) {
|
||||
h := testRouter(t, func() error { return errors.New("postgres down") })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("readyz status %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
ok := testRouter(t, func() error { return nil })
|
||||
w = httptest.NewRecorder()
|
||||
ok.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("healthy readyz status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1RequiresKey(t *testing.T) {
|
||||
h := testRouter(t, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/whoami", nil))
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoamiEndToEnd(t *testing.T) {
|
||||
h := testRouter(t, nil)
|
||||
|
||||
g, err := apikey.Generate(apikey.Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/whoami", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+g.Plaintext)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got struct {
|
||||
KeyID int64 `json:"key_id"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got.KeyID != 42 || got.OwnerID != 7 {
|
||||
t.Errorf("principal not propagated: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeEnforcedOnRoute(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
rec := activeRecord()
|
||||
rec.Scopes = []string{ScopeLogbook} // deliberately not reference
|
||||
|
||||
rt := &Router{
|
||||
Auth: &Authenticator{
|
||||
Keys: &fakeStore{rec: rec},
|
||||
Limiter: ratelimit.NewMemory(),
|
||||
Logger: logger,
|
||||
},
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
g, _ := apikey.Generate(apikey.Live)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/reference/ping", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+g.Plaintext)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
rt.Handler().ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathIsJSON404(t *testing.T) {
|
||||
h := testRouter(t, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/nope", nil))
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); ct[:16] != "application/json" {
|
||||
t.Errorf("Content-Type %q, want JSON so clients can parse errors uniformly", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong method on a real path must 405, not 404.
|
||||
func TestMethodMismatch(t *testing.T) {
|
||||
h := testRouter(t, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/healthz", nil))
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A panicking handler must produce a 500, not kill the process.
|
||||
func TestRecoveryMiddleware(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("kaboom")
|
||||
})
|
||||
|
||||
h := Chain(boom, WithRequestID, WithRecovery(logger))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status %d, want 500", w.Code)
|
||||
}
|
||||
|
||||
if got := bodyCode(t, w); got != CodeInternal {
|
||||
t.Errorf("code %q, want %q", got, CodeInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDHonoursInbound(t *testing.T) {
|
||||
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := RequestIDFrom(r.Context()); got != "abc123" {
|
||||
t.Errorf("request id %q, want abc123", got)
|
||||
}
|
||||
}), WithRequestID)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Request-Id", "abc123")
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
|
||||
)
|
||||
|
||||
// PgKeyStore adapts the sqlc-generated queries to the KeyStore interface.
|
||||
//
|
||||
// The indirection earns its keep twice: the middleware is testable without a
|
||||
// database, and adding an unrelated column to api.keys does not ripple into the
|
||||
// auth package.
|
||||
type PgKeyStore struct {
|
||||
Q *db.Queries
|
||||
}
|
||||
|
||||
func (s PgKeyStore) KeyByHash(ctx context.Context, hash []byte) (KeyRecord, error) {
|
||||
row, err := s.Q.KeyByHash(ctx, hash)
|
||||
if err != nil {
|
||||
// pgx returns ErrNoRows for an empty :one result. Translate it so the
|
||||
// middleware never has to know which driver is underneath.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return KeyRecord{}, ErrKeyNotFound
|
||||
}
|
||||
|
||||
return KeyRecord{}, err
|
||||
}
|
||||
|
||||
rec := KeyRecord{
|
||||
ID: row.ID,
|
||||
OwnerID: row.OwnerID,
|
||||
Scopes: row.Scopes,
|
||||
QuotaTier: row.QuotaTier,
|
||||
Status: row.Status,
|
||||
OwnerStatus: row.OwnerStatus,
|
||||
}
|
||||
|
||||
// pgx maps a nullable timestamptz to pgtype.Timestamptz, which carries its
|
||||
// own validity flag rather than being nil. db.Time does the conversion so
|
||||
// pgtype never leaks past this adapter.
|
||||
rec.ExpiresAt = db.Time(row.ExpiresAt)
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Whoami echoes the resolved principal. The cheapest possible end-to-end test
|
||||
// of the auth chain: if this returns your key id and scopes, then the bearer
|
||||
// header, the checksum, the hash lookup, the status checks and the rate limiter
|
||||
// are all working.
|
||||
func Whoami() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"key_id": p.KeyID,
|
||||
"owner_id": p.OwnerID,
|
||||
"scopes": p.Scopes,
|
||||
"quota_tier": p.QuotaTier,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Key structure: hmy_live_<43 secret><6 checksum>
|
||||
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
Issuer = "hmy"
|
||||
secretBytes = 32 // 256 bit
|
||||
secretLen = 43
|
||||
checksumLen = 6
|
||||
bodyLen = secretLen + checksumLen
|
||||
|
||||
// Enough to identify, not enough to brute-force
|
||||
displayLen = 16
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMalformed = errors.New("apikey: malformed key")
|
||||
ErrBadChecksum = errors.New("apikey: checksum mismatch")
|
||||
ErrEnvironment = errors.New("apikey: unknown environment")
|
||||
)
|
||||
|
||||
// Environment for live and test keys
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Live Environment = "live"
|
||||
Test Environment = "test"
|
||||
)
|
||||
|
||||
func (e Environment) valid() bool {
|
||||
return e == Live || e == Test
|
||||
}
|
||||
|
||||
// Generated is the result of issuing a key.
|
||||
// Plaintext shown to owner and never stored.
|
||||
type Generated struct {
|
||||
Plaintext string
|
||||
Hash []byte
|
||||
Display string
|
||||
Env Environment
|
||||
}
|
||||
|
||||
// Castagnoli instead of IEEE table
|
||||
var crcTable = crc32.MakeTable(crc32.Castagnoli)
|
||||
|
||||
func checksum(secret string) string {
|
||||
sum := crc32.Checksum([]byte(secret), crcTable)
|
||||
|
||||
b := []byte{
|
||||
byte(sum >> 24),
|
||||
byte(sum >> 16),
|
||||
byte(sum >> 8),
|
||||
byte(sum),
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Generate issues a ney key. Store Hash and Display, show Plaintext once
|
||||
func Generate(env Environment) (Generated, error) {
|
||||
if !env.valid() {
|
||||
return Generated{}, ErrEnvironment
|
||||
}
|
||||
|
||||
buf := make([]byte, secretBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return Generated{}, fmt.Errorf("apikey: reading entropy: %w", err)
|
||||
}
|
||||
|
||||
secret := base64.RawURLEncoding.EncodeToString(buf)
|
||||
plaintext := Issuer + "_" + string(env) + "_" + secret + checksum(secret)
|
||||
|
||||
display := plaintext
|
||||
if len(display) > displayLen {
|
||||
display = display[:displayLen]
|
||||
}
|
||||
|
||||
return Generated{
|
||||
Plaintext: plaintext,
|
||||
Hash: Hash(plaintext),
|
||||
Display: display,
|
||||
Env: env,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Hash is what is stored and looked up
|
||||
func Hash(key string) []byte {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Validate checks the structure and checksum. Call before hashing.
|
||||
func Validate(key string) (Environment, error) {
|
||||
parts := strings.SplitN(key, "_", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", ErrMalformed
|
||||
}
|
||||
|
||||
if parts[0] != Issuer {
|
||||
return "", ErrMalformed
|
||||
}
|
||||
|
||||
env := Environment(parts[1])
|
||||
if !env.valid() {
|
||||
return "", ErrEnvironment
|
||||
}
|
||||
|
||||
body := parts[2]
|
||||
if len(body) != bodyLen {
|
||||
return "", ErrMalformed
|
||||
}
|
||||
|
||||
secret, want := body[:secretLen], body[secretLen:]
|
||||
|
||||
if _, err := base64.RawURLEncoding.DecodeString(secret); err != nil {
|
||||
return "", ErrMalformed
|
||||
}
|
||||
|
||||
if checksum(secret) != want {
|
||||
return "", ErrBadChecksum
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// Redact makes the key safe for logging
|
||||
func Redact(key string) string {
|
||||
if len(key) <= displayLen {
|
||||
return Issuer + "_***"
|
||||
}
|
||||
|
||||
return key[:displayLen] + "_***"
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrip(t *testing.T) {
|
||||
for _, env := range []Environment{Live, Test} {
|
||||
g, err := Generate(env)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", env, err)
|
||||
}
|
||||
|
||||
got, err := Validate(g.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(%q): %v", g.Plaintext, err)
|
||||
}
|
||||
|
||||
if got != env {
|
||||
t.Errorf("environment round trip: got %q want %q", got, env)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(g.Plaintext, Issuer+"_"+string(env)+"_") {
|
||||
t.Errorf("prefix wrong: %q", g.Plaintext)
|
||||
}
|
||||
|
||||
if len(g.Hash) != 32 {
|
||||
t.Errorf("hash length %d, want 32 (the api.keys CHECK requires it)", len(g.Hash))
|
||||
}
|
||||
|
||||
if !bytes.Equal(g.Hash, Hash(g.Plaintext)) {
|
||||
t.Error("Generate's hash differs from Hash of its own plaintext")
|
||||
}
|
||||
|
||||
if len(g.Display) != displayLen {
|
||||
t.Errorf("display length %d, want %d", len(g.Display), displayLen)
|
||||
}
|
||||
|
||||
if strings.Contains(g.Plaintext[len(g.Display):], "") && g.Display == g.Plaintext {
|
||||
t.Error("display prefix is the whole key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIsUnique(t *testing.T) {
|
||||
const n = 2000
|
||||
|
||||
seen := make(map[string]bool, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
|
||||
if seen[g.Plaintext] {
|
||||
t.Fatalf("duplicate key after %d generations", i)
|
||||
}
|
||||
|
||||
seen[g.Plaintext] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownEnvironment(t *testing.T) {
|
||||
if _, err := Generate(Environment("prod")); !errors.Is(err, ErrEnvironment) {
|
||||
t.Errorf("got %v, want ErrEnvironment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformed(t *testing.T) {
|
||||
good, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrMalformed},
|
||||
{"no separators", "notakey", ErrMalformed},
|
||||
{"one separator", "hmy_live", ErrMalformed},
|
||||
{"wrong issuer", "xxx_live_" + good.Plaintext[9:], ErrMalformed},
|
||||
{"unknown env", "hmy_prod_" + good.Plaintext[9:], ErrEnvironment},
|
||||
{"body too short", "hmy_live_abc", ErrMalformed},
|
||||
{"body too long", good.Plaintext + "X", ErrMalformed},
|
||||
{"body truncated by one", good.Plaintext[:len(good.Plaintext)-1], ErrMalformed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := Validate(c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("Validate(%q) = %v, want %v", c.key, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the checksum: a key with a typo is rejected without a database
|
||||
// round trip.
|
||||
func TestValidateCatchesTampering(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := []byte(g.Plaintext)
|
||||
caught := 0
|
||||
|
||||
// Flip one character at each position in the secret and confirm the
|
||||
// checksum notices.
|
||||
for i := len("hmy_live_"); i < len(body)-checksumLen; i++ {
|
||||
orig := body[i]
|
||||
|
||||
if orig == 'A' {
|
||||
body[i] = 'B'
|
||||
} else {
|
||||
body[i] = 'A'
|
||||
}
|
||||
|
||||
if _, err := Validate(string(body)); errors.Is(err, ErrBadChecksum) {
|
||||
caught++
|
||||
}
|
||||
|
||||
body[i] = orig
|
||||
}
|
||||
|
||||
total := len(body) - checksumLen - len("hmy_live_")
|
||||
|
||||
if caught != total {
|
||||
t.Errorf("checksum caught %d/%d single-character corruptions", caught, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsStable(t *testing.T) {
|
||||
const key = "hmy_live_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGabcdef"
|
||||
|
||||
a, b := Hash(key), Hash(key)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Error("Hash is not deterministic")
|
||||
}
|
||||
|
||||
if bytes.Equal(a, Hash(key+"x")) {
|
||||
t.Error("Hash collided on different input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Redact(g.Plaintext)
|
||||
|
||||
if strings.Contains(g.Plaintext, r) && len(r) >= len(g.Plaintext) {
|
||||
t.Error("Redact returned the whole key")
|
||||
}
|
||||
|
||||
if len(r) > displayLen+3 {
|
||||
t.Errorf("Redact returned %d chars, too much", len(r))
|
||||
}
|
||||
|
||||
// A secret must never survive redaction.
|
||||
secret := g.Plaintext[len("hmy_live_"):]
|
||||
if strings.Contains(r, secret) {
|
||||
t.Error("Redact leaked the secret")
|
||||
}
|
||||
|
||||
if got := Redact("short"); got != "hmy_***" {
|
||||
t.Errorf("Redact(short) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package config loads runtime settings from the environment.
|
||||
//
|
||||
// Environment variables rather than a config file, for two reasons: the DSN
|
||||
// differs per machine and you are syncing the repo across two, and a file with
|
||||
// credentials in it is one `git add .` away from being public. The bot uses
|
||||
// config.json because its token is the only secret; the backend holds database
|
||||
// credentials and key material.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Addr is the listen address. Bind to localhost in production and put a
|
||||
// reverse proxy in front for TLS.
|
||||
Addr string
|
||||
|
||||
// DSN for Postgres. Required.
|
||||
DSN string
|
||||
|
||||
// MaxConns caps the pgx pool. Postgres forks a backend per connection, so
|
||||
// this is a real resource, not a client-side nicety.
|
||||
MaxConns int32
|
||||
|
||||
// RedisAddr enables the shared rate limiter. Empty falls back to an
|
||||
// in-process limiter, which is correct for a single instance and wrong for
|
||||
// several - each would keep its own counters.
|
||||
RedisAddr string
|
||||
RedisDB int
|
||||
|
||||
// EndUserHeader carries the caller's identifier for whoever triggered the
|
||||
// request, for per-user rate limiting. Empty disables it.
|
||||
EndUserHeader string
|
||||
|
||||
LogLevel slog.Level
|
||||
LogJSON bool
|
||||
|
||||
ReadHeaderTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
Addr: env("HAMMY_ADDR", "127.0.0.1:8080"),
|
||||
DSN: os.Getenv("HAMMY_DSN"),
|
||||
RedisAddr: os.Getenv("HAMMY_REDIS_ADDR"),
|
||||
EndUserHeader: env("HAMMY_ENDUSER_HEADER", "X-Hammy-User"),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ShutdownTimeout: 20 * time.Second,
|
||||
LogJSON: boolEnv("HAMMY_LOG_JSON", false),
|
||||
}
|
||||
|
||||
if c.DSN == "" {
|
||||
return Config{}, errors.New("config: HAMMY_DSN is not set")
|
||||
}
|
||||
|
||||
n, err := intEnv("HAMMY_MAX_CONNS", 10)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.MaxConns = int32(n)
|
||||
|
||||
if c.RedisDB, err = intEnv("HAMMY_REDIS_DB", 0); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if c.LogLevel, err = levelEnv("HAMMY_LOG_LEVEL", slog.LevelInfo); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if c.ReadHeaderTimeout, err = durEnv("HAMMY_READ_HEADER_TIMEOUT", c.ReadHeaderTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if c.ShutdownTimeout, err = durEnv("HAMMY_SHUTDOWN_TIMEOUT", c.ShutdownTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Redacted is safe to log at startup. The DSN carries a password.
|
||||
func (c Config) Redacted() string {
|
||||
redis := c.RedisAddr
|
||||
if redis == "" {
|
||||
redis = "(in-process limiter)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("addr=%s db=%s redis=%s max_conns=%d level=%s",
|
||||
c.Addr, redactDSN(c.DSN), redis, c.MaxConns, c.LogLevel)
|
||||
}
|
||||
|
||||
// redactDSN keeps the shape of the connection string and drops the password.
|
||||
func redactDSN(dsn string) string {
|
||||
at := strings.LastIndex(dsn, "@")
|
||||
if at < 0 {
|
||||
return "(set)"
|
||||
}
|
||||
|
||||
scheme := strings.Index(dsn, "://")
|
||||
if scheme < 0 {
|
||||
return "(set)"
|
||||
}
|
||||
|
||||
creds := dsn[scheme+3 : at]
|
||||
|
||||
user := creds
|
||||
if colon := strings.Index(creds, ":"); colon >= 0 {
|
||||
user = creds[:colon]
|
||||
}
|
||||
|
||||
return dsn[:scheme+3] + user + ":***" + dsn[at:]
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
func intEnv(key string, def int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func boolEnv(key string, def bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func durEnv(key string, def time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func levelEnv(key string, def slog.Level) (slog.Level, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
|
||||
var l slog.Level
|
||||
if err := l.UnmarshalText([]byte(v)); err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadRequiresDSN(t *testing.T) {
|
||||
t.Setenv("HAMMY_DSN", "")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected an error when HAMMY_DSN is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.Addr != "127.0.0.1:8080" {
|
||||
t.Errorf("Addr %q, want localhost by default - binding 0.0.0.0 should be deliberate", c.Addr)
|
||||
}
|
||||
if c.MaxConns != 10 {
|
||||
t.Errorf("MaxConns %d, want 10", c.MaxConns)
|
||||
}
|
||||
if c.LogLevel != slog.LevelInfo {
|
||||
t.Errorf("LogLevel %v, want info", c.LogLevel)
|
||||
}
|
||||
if c.RedisAddr != "" {
|
||||
t.Errorf("RedisAddr %q, want empty", c.RedisAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverrides(t *testing.T) {
|
||||
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
|
||||
t.Setenv("HAMMY_ADDR", ":9000")
|
||||
t.Setenv("HAMMY_MAX_CONNS", "25")
|
||||
t.Setenv("HAMMY_LOG_LEVEL", "debug")
|
||||
t.Setenv("HAMMY_SHUTDOWN_TIMEOUT", "45s")
|
||||
t.Setenv("HAMMY_REDIS_ADDR", "localhost:6379")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.Addr != ":9000" || c.MaxConns != 25 || c.LogLevel != slog.LevelDebug {
|
||||
t.Errorf("overrides not applied: %+v", c)
|
||||
}
|
||||
if c.ShutdownTimeout != 45*time.Second {
|
||||
t.Errorf("ShutdownTimeout %v, want 45s", c.ShutdownTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsBadValues(t *testing.T) {
|
||||
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
|
||||
|
||||
for _, c := range []struct{ key, val string }{
|
||||
{"HAMMY_MAX_CONNS", "lots"},
|
||||
{"HAMMY_LOG_LEVEL", "verbose"},
|
||||
{"HAMMY_SHUTDOWN_TIMEOUT", "soon"},
|
||||
} {
|
||||
t.Run(c.key, func(t *testing.T) {
|
||||
t.Setenv(c.key, c.val)
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Errorf("%s=%q accepted", c.key, c.val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A DSN in a startup log is a password in a log.
|
||||
func TestRedactedHidesPassword(t *testing.T) {
|
||||
cases := []string{
|
||||
"postgres://hammy_app:[email protected]:5432/hammy",
|
||||
"postgresql://hammy_app:p%40ss@localhost/hammy?sslmode=verify-full",
|
||||
}
|
||||
|
||||
for _, dsn := range cases {
|
||||
c := Config{DSN: dsn, Addr: ":8080", MaxConns: 10}
|
||||
out := c.Redacted()
|
||||
|
||||
if strings.Contains(out, "hunter2") || strings.Contains(out, "p%40ss") {
|
||||
t.Errorf("password leaked: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "hammy_app") {
|
||||
t.Errorf("username should survive for diagnosis: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A DSN with no credentials must not panic or produce nonsense.
|
||||
c := Config{DSN: "postgres:///hammy?host=/var/run/postgresql"}
|
||||
if out := c.Redacted(); strings.Contains(out, "***") && !strings.Contains(out, "(set)") {
|
||||
t.Logf("socket DSN rendered as: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Conversions between pgtype and plain Go types.
|
||||
// sqlc with sql_package: pgx/v5 maps a nullable timestamptz to
|
||||
// pgtype.Timestamptz regardless of emit_pointers_for_null_types, and the
|
||||
// db_type override for it is fiddly to get right across sqlc versions. Doing
|
||||
// the conversion here is two lines, always works, and keeps pgtype out of
|
||||
// internal/api and cmd/ entirely.
|
||||
// This file is hand-written and is NOT regenerated by sqlc. It lives in the db
|
||||
// package so the pgtype import stays in one place.
|
||||
|
||||
// Time converts a nullable timestamptz to *time.Time. NULL becomes nil.
|
||||
func Time(t pgtype.Timestamptz) *time.Time {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := t.Time
|
||||
|
||||
return &v
|
||||
}
|
||||
|
||||
// Timestamptz converts *time.Time back for a query parameter. nil becomes NULL.
|
||||
func Timestamptz(t *time.Time) pgtype.Timestamptz {
|
||||
if t == nil {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
|
||||
return pgtype.Timestamptz{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
// Str converts a nullable text column to *string.
|
||||
func Str(t pgtype.Text) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := t.String
|
||||
|
||||
return &v
|
||||
}
|
||||
|
||||
// Text converts a string for a nullable text parameter. An empty string becomes
|
||||
// NULL: an absent label or callsign is absence, not a value, and the
|
||||
// core.callsign domain would reject "" anyway.
|
||||
func Text(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
+219
-10
@@ -11,15 +11,102 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getKeyByHash = `-- name: GetKeyByHash :one
|
||||
SELECT k.id, k.owner_id, k.scopes, k.quota_tier, k.status, k.expires_at,
|
||||
o.status AS owner_status
|
||||
FROM api.keys k
|
||||
JOIN api.owners o ON o.id = k.owner_id
|
||||
WHERE k.key_hash = $1 AND k.status = 'active'
|
||||
const createKey = `-- name: CreateKey :one
|
||||
INSERT INTO api.keys (owner_id, key_hash, key_prefix, label, scopes, quota_tier, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, key_prefix, scopes, quota_tier, status, created_at, expires_at
|
||||
`
|
||||
|
||||
type GetKeyByHashRow struct {
|
||||
type CreateKeyParams struct {
|
||||
OwnerID int64
|
||||
KeyHash []byte
|
||||
KeyPrefix string
|
||||
Label *string
|
||||
Scopes []string
|
||||
QuotaTier string
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CreateKeyRow struct {
|
||||
ID int64
|
||||
KeyPrefix string
|
||||
Scopes []string
|
||||
QuotaTier string
|
||||
Status string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) CreateKey(ctx context.Context, arg CreateKeyParams) (CreateKeyRow, error) {
|
||||
row := q.db.QueryRow(ctx, createKey,
|
||||
arg.OwnerID,
|
||||
arg.KeyHash,
|
||||
arg.KeyPrefix,
|
||||
arg.Label,
|
||||
arg.Scopes,
|
||||
arg.QuotaTier,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i CreateKeyRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.KeyPrefix,
|
||||
&i.Scopes,
|
||||
&i.QuotaTier,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createOwner = `-- name: CreateOwner :one
|
||||
INSERT INTO api.owners (email, callsign)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, callsign, status, created_at
|
||||
`
|
||||
|
||||
type CreateOwnerParams struct {
|
||||
Email string
|
||||
Callsign interface{}
|
||||
}
|
||||
|
||||
type CreateOwnerRow struct {
|
||||
ID int64
|
||||
Email string
|
||||
Callsign interface{}
|
||||
Status string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOwner(ctx context.Context, arg CreateOwnerParams) (CreateOwnerRow, error) {
|
||||
row := q.db.QueryRow(ctx, createOwner, arg.Email, arg.Callsign)
|
||||
var i CreateOwnerRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Callsign,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const keyByHash = `-- name: KeyByHash :one
|
||||
SELECT
|
||||
k.id,
|
||||
k.owner_id,
|
||||
k.scopes,
|
||||
k.quota_tier,
|
||||
k.status,
|
||||
k.expires_at,
|
||||
o.status AS owner_status
|
||||
FROM api.keys k
|
||||
JOIN api.owners o ON o.id = k.owner_id
|
||||
WHERE k.key_hash = $1
|
||||
`
|
||||
|
||||
type KeyByHashRow struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
Scopes []string
|
||||
@@ -29,9 +116,15 @@ type GetKeyByHashRow struct {
|
||||
OwnerStatus string
|
||||
}
|
||||
|
||||
func (q *Queries) GetKeyByHash(ctx context.Context, keyHash []byte) (GetKeyByHashRow, error) {
|
||||
row := q.db.QueryRow(ctx, getKeyByHash, keyHash)
|
||||
var i GetKeyByHashRow
|
||||
// The authentication hot path. Joins owners so one round trip settles both the
|
||||
// key's status and the account's.
|
||||
//
|
||||
// No filter on status here: the middleware needs to distinguish "revoked" from
|
||||
// "expired" from "unknown" to give a useful error, and it cannot do that if the
|
||||
// query has already hidden the row.
|
||||
func (q *Queries) KeyByHash(ctx context.Context, keyHash []byte) (KeyByHashRow, error) {
|
||||
row := q.db.QueryRow(ctx, keyByHash, keyHash)
|
||||
var i KeyByHashRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
@@ -43,3 +136,119 @@ func (q *Queries) GetKeyByHash(ctx context.Context, keyHash []byte) (GetKeyByHas
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listKeysForOwner = `-- name: ListKeysForOwner :many
|
||||
SELECT id, key_prefix, label, scopes, quota_tier, status,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api.keys
|
||||
WHERE owner_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
type ListKeysForOwnerRow struct {
|
||||
ID int64
|
||||
KeyPrefix string
|
||||
Label *string
|
||||
Scopes []string
|
||||
QuotaTier string
|
||||
Status string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
LastUsedAt pgtype.Timestamptz
|
||||
RevokedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) ListKeysForOwner(ctx context.Context, ownerID int64) ([]ListKeysForOwnerRow, error) {
|
||||
rows, err := q.db.Query(ctx, listKeysForOwner, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListKeysForOwnerRow{}
|
||||
for rows.Next() {
|
||||
var i ListKeysForOwnerRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.KeyPrefix,
|
||||
&i.Label,
|
||||
&i.Scopes,
|
||||
&i.QuotaTier,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ownerByEmail = `-- name: OwnerByEmail :one
|
||||
SELECT id, email, email_verified_at, callsign, callsign_verified_at, status, created_at
|
||||
FROM api.owners
|
||||
WHERE email = $1
|
||||
`
|
||||
|
||||
type OwnerByEmailRow struct {
|
||||
ID int64
|
||||
Email string
|
||||
EmailVerifiedAt pgtype.Timestamptz
|
||||
Callsign interface{}
|
||||
CallsignVerifiedAt pgtype.Timestamptz
|
||||
Status string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) OwnerByEmail(ctx context.Context, email string) (OwnerByEmailRow, error) {
|
||||
row := q.db.QueryRow(ctx, ownerByEmail, email)
|
||||
var i OwnerByEmailRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.EmailVerifiedAt,
|
||||
&i.Callsign,
|
||||
&i.CallsignVerifiedAt,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const revokeKey = `-- name: RevokeKey :exec
|
||||
UPDATE api.keys
|
||||
SET status = 'revoked', revoked_at = now(), revoked_reason = $2
|
||||
WHERE id = $1
|
||||
AND status <> 'revoked'
|
||||
`
|
||||
|
||||
type RevokeKeyParams struct {
|
||||
ID int64
|
||||
RevokedReason *string
|
||||
}
|
||||
|
||||
// The keys_revoked_consistent CHECK requires status and revoked_at to move
|
||||
// together, so both are set here.
|
||||
func (q *Queries) RevokeKey(ctx context.Context, arg RevokeKeyParams) error {
|
||||
_, err := q.db.Exec(ctx, revokeKey, arg.ID, arg.RevokedReason)
|
||||
return err
|
||||
}
|
||||
|
||||
const touchKeyLastUsed = `-- name: TouchKeyLastUsed :exec
|
||||
UPDATE api.keys
|
||||
SET last_used_at = now()
|
||||
WHERE id = $1
|
||||
AND (last_used_at IS NULL OR last_used_at < now() - interval '5 minutes')
|
||||
`
|
||||
|
||||
// Debounced by the caller: writing on every request would be one UPDATE per
|
||||
// request for a field nobody reads in real time.
|
||||
func (q *Queries) TouchKeyLastUsed(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, touchKeyLastUsed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type ApiKey struct {
|
||||
// Registrants for API keys. Holds PII from an international user base, so the deletion path in 005_privacy.sql applies here too, not just to Discord users.
|
||||
type ApiOwner struct {
|
||||
ID int64
|
||||
Email interface{}
|
||||
Email string
|
||||
EmailVerifiedAt pgtype.Timestamptz
|
||||
Callsign interface{}
|
||||
CallsignVerifiedAt pgtype.Timestamptz
|
||||
@@ -75,7 +75,7 @@ type CoreVerification struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Method string
|
||||
Callsign interface{}
|
||||
Callsign string
|
||||
Status string
|
||||
ChallengeHash []byte
|
||||
Detail []byte
|
||||
@@ -103,7 +103,7 @@ type LogbookQso struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
ImportID *int64
|
||||
Callsign interface{}
|
||||
Callsign string
|
||||
QsoAt pgtype.Timestamptz
|
||||
// Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at.
|
||||
QsoMinute pgtype.Timestamp
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is a fixed-window limiter held in process. Useful for tests and for a
|
||||
// single-instance self-hoster who would rather not run Redis.
|
||||
//
|
||||
// Not suitable for more than one backend process: each would keep its own
|
||||
// counters, so N processes allow N times the limit.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
windows map[string]*window
|
||||
Now func() time.Time // injectable so tests need not sleep
|
||||
}
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
windows: make(map[string]*window),
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Memory) Allow(_ context.Context, key string, limit int, dur time.Duration) (Result, error) {
|
||||
if limit <= 0 {
|
||||
return Result{Allowed: true, Limit: 0, Remaining: 0}, nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := m.Now()
|
||||
|
||||
w, ok := m.windows[key]
|
||||
if !ok || now.After(w.expires) {
|
||||
w = &window{expires: now.Add(dur)}
|
||||
m.windows[key] = w
|
||||
}
|
||||
|
||||
w.count++
|
||||
|
||||
remaining := limit - w.count
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
return Result{
|
||||
Allowed: w.count <= limit,
|
||||
Limit: limit,
|
||||
Remaining: remaining,
|
||||
RetryAfter: w.expires.Sub(now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sweep drops expired windows. Without it the map grows for every key ever
|
||||
// seen, which for per-user keys is unbounded. Call it periodically.
|
||||
func (m *Memory) Sweep() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := m.Now()
|
||||
removed := 0
|
||||
|
||||
for k, w := range m.windows {
|
||||
if now.After(w.expires) {
|
||||
delete(m.windows, k)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package ratelimit enforces per-key and per-end-user request ceilings.
|
||||
//
|
||||
// Redis is authoritative for the live window; api.usage_daily holds completed
|
||||
// days. A Redis flush therefore loses at most the window in progress, which for
|
||||
// abuse detection is an acceptable trade.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is what a limiter says about one request.
|
||||
type Result struct {
|
||||
Allowed bool
|
||||
Limit int
|
||||
Remaining int
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
// Limiter is deliberately narrow so the middleware can be tested without Redis,
|
||||
// and so a self-hoster running a single instance could drop in an in-process
|
||||
// implementation instead of standing up Redis at all.
|
||||
type Limiter interface {
|
||||
// Allow records one request against key and reports whether it may
|
||||
// proceed. Implementations must be atomic: a check-then-increment race
|
||||
// under load is exactly when the limit matters most.
|
||||
Allow(ctx context.Context, key string, limit int, window time.Duration) (Result, error)
|
||||
}
|
||||
|
||||
// Quota is the ceiling for one tier, from api.keys.quota_tier.
|
||||
type Quota struct {
|
||||
// Requests allowed per window for the key as a whole.
|
||||
KeyLimit int
|
||||
|
||||
// Requests allowed per window for a single end user within that key. One
|
||||
// Discord user should not be able to consume a whole self-hosted
|
||||
// instance's allowance, and a key whose traffic is 90% one user is the
|
||||
// enumeration signal worth alerting on.
|
||||
UserLimit int
|
||||
|
||||
Window time.Duration
|
||||
}
|
||||
|
||||
// Tiers maps api.keys.quota_tier to its ceiling. Values are a starting point,
|
||||
// not a considered policy: watch real usage before hardening them.
|
||||
var Tiers = map[string]Quota{
|
||||
"default": {KeyLimit: 600, UserLimit: 120, Window: time.Minute},
|
||||
"verified": {KeyLimit: 3000, UserLimit: 600, Window: time.Minute},
|
||||
"trusted": {KeyLimit: 12000, UserLimit: 2400, Window: time.Minute},
|
||||
"internal": {KeyLimit: 0, UserLimit: 0, Window: time.Minute}, // 0 = unlimited
|
||||
}
|
||||
|
||||
// QuotaFor falls back to the tightest tier for an unknown name, so a typo in
|
||||
// the database cannot accidentally grant unlimited access.
|
||||
func QuotaFor(tier string) Quota {
|
||||
if q, ok := Tiers[tier]; ok {
|
||||
return q
|
||||
}
|
||||
|
||||
return Tiers["default"]
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryWindow(t *testing.T) {
|
||||
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||
m := NewMemory()
|
||||
m.Now = func() time.Time { return now }
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
res, err := m.Allow(ctx, "k", 3, time.Minute)
|
||||
if err != nil || !res.Allowed {
|
||||
t.Fatalf("request %d: allowed=%v err=%v", i, res.Allowed, err)
|
||||
}
|
||||
if res.Remaining != 3-i {
|
||||
t.Errorf("request %d: remaining %d, want %d", i, res.Remaining, 3-i)
|
||||
}
|
||||
}
|
||||
|
||||
res, _ := m.Allow(ctx, "k", 3, time.Minute)
|
||||
if res.Allowed {
|
||||
t.Error("4th request allowed past a limit of 3")
|
||||
}
|
||||
if res.RetryAfter <= 0 {
|
||||
t.Error("RetryAfter not set on a rejection")
|
||||
}
|
||||
|
||||
// Window rolls over.
|
||||
now = now.Add(time.Minute + time.Second)
|
||||
if res, _ := m.Allow(ctx, "k", 3, time.Minute); !res.Allowed {
|
||||
t.Error("request rejected after the window expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryKeysAreIndependent(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
m.Allow(ctx, "a", 5, time.Minute)
|
||||
}
|
||||
|
||||
if res, _ := m.Allow(ctx, "a", 5, time.Minute); res.Allowed {
|
||||
t.Error("key a not limited")
|
||||
}
|
||||
if res, _ := m.Allow(ctx, "b", 5, time.Minute); !res.Allowed {
|
||||
t.Error("key b affected by key a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryZeroLimitIsUnlimited(t *testing.T) {
|
||||
m := NewMemory()
|
||||
for i := 0; i < 1000; i++ {
|
||||
if res, _ := m.Allow(context.Background(), "k", 0, time.Minute); !res.Allowed {
|
||||
t.Fatalf("zero limit rejected at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySweep(t *testing.T) {
|
||||
now := time.Now()
|
||||
m := NewMemory()
|
||||
m.Now = func() time.Time { return now }
|
||||
|
||||
m.Allow(context.Background(), "a", 5, time.Minute)
|
||||
m.Allow(context.Background(), "b", 5, time.Hour)
|
||||
|
||||
if n := m.Sweep(); n != 0 {
|
||||
t.Errorf("swept %d live windows", n)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Minute)
|
||||
|
||||
if n := m.Sweep(); n != 1 {
|
||||
t.Errorf("swept %d, want 1 (a expired, b did not)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryIsConcurrencySafe(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
|
||||
const goroutines, each = 20, 50
|
||||
|
||||
done := make(chan int, goroutines)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
go func() {
|
||||
allowed := 0
|
||||
for i := 0; i < each; i++ {
|
||||
if res, _ := m.Allow(ctx, "shared", 100, time.Minute); res.Allowed {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
done <- allowed
|
||||
}()
|
||||
}
|
||||
|
||||
total := 0
|
||||
for g := 0; g < goroutines; g++ {
|
||||
total += <-done
|
||||
}
|
||||
|
||||
// Exactly 100 of the 1000 attempts may pass. A check-then-increment race
|
||||
// would let more through.
|
||||
if total != 100 {
|
||||
t.Errorf("%d requests allowed under concurrency, want exactly 100", total)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunner stands in for Redis so the script result handling can be tested.
|
||||
type fakeRunner struct {
|
||||
ret any
|
||||
err error
|
||||
keys []string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Eval(_ context.Context, _ string, keys []string, args ...any) (any, error) {
|
||||
f.keys, f.args = keys, args
|
||||
return f.ret, f.err
|
||||
}
|
||||
|
||||
func TestRedisResultParsing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("under limit", func(t *testing.T) {
|
||||
f := &fakeRunner{ret: []any{int64(3), int64(45000)}}
|
||||
r := NewRedis(f)
|
||||
|
||||
res, err := r.Allow(ctx, "key:1", 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Allowed || res.Remaining != 7 {
|
||||
t.Errorf("allowed=%v remaining=%d, want true/7", res.Allowed, res.Remaining)
|
||||
}
|
||||
if res.RetryAfter != 45*time.Second {
|
||||
t.Errorf("RetryAfter %v, want 45s", res.RetryAfter)
|
||||
}
|
||||
if len(f.keys) != 1 || f.keys[0] != "rl:key:1" {
|
||||
t.Errorf("keys %v, want [rl:key:1]", f.keys)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("over limit", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{ret: []any{int64(11), int64(1000)}})
|
||||
res, _ := r.Allow(ctx, "k", 10, time.Minute)
|
||||
if res.Allowed || res.Remaining != 0 {
|
||||
t.Errorf("allowed=%v remaining=%d, want false/0", res.Allowed, res.Remaining)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly at limit is allowed", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{ret: []any{int64(10), int64(1000)}})
|
||||
if res, _ := r.Allow(ctx, "k", 10, time.Minute); !res.Allowed {
|
||||
t.Error("request at exactly the limit was rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain int accepted", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{ret: []any{1, 1000}})
|
||||
if _, err := r.Allow(ctx, "k", 10, time.Minute); err != nil {
|
||||
t.Errorf("int result rejected: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ttl falls back to the window", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{ret: []any{int64(11), int64(-1)}})
|
||||
res, _ := r.Allow(ctx, "k", 10, time.Minute)
|
||||
if res.RetryAfter != time.Minute {
|
||||
t.Errorf("RetryAfter %v, want 1m", res.RetryAfter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("eval error propagates", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{err: errors.New("nope")})
|
||||
if _, err := r.Allow(ctx, "k", 10, time.Minute); err == nil {
|
||||
t.Error("expected an error so the caller can fail open")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("garbage result is an error not a panic", func(t *testing.T) {
|
||||
r := NewRedis(&fakeRunner{ret: "not a list"})
|
||||
if _, err := r.Allow(ctx, "k", 10, time.Minute); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero limit skips redis entirely", func(t *testing.T) {
|
||||
f := &fakeRunner{ret: []any{int64(1), int64(1)}}
|
||||
r := NewRedis(f)
|
||||
res, _ := r.Allow(ctx, "k", 0, time.Minute)
|
||||
if !res.Allowed {
|
||||
t.Error("zero limit rejected")
|
||||
}
|
||||
if f.keys != nil {
|
||||
t.Error("redis was called for an unlimited tier")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScriptRunner is the slice of a Redis client this package needs. Narrow on
|
||||
// purpose: it keeps go-redis out of this file's imports, which means the window
|
||||
// logic below is testable without a Redis server. See redis_adapter.go for the
|
||||
// ten-line bridge to *redis.Client.
|
||||
type ScriptRunner interface {
|
||||
Eval(ctx context.Context, script string, keys []string, args ...any) (any, error)
|
||||
}
|
||||
|
||||
// INCR and EXPIRE must be one atomic step. Done as two commands, a crash
|
||||
// between them leaves a counter with no TTL, which silently becomes a permanent
|
||||
// ban for that key.
|
||||
//
|
||||
// Returns {count, ttl_ms}.
|
||||
const allowScript = `
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return {current, redis.call('PTTL', KEYS[1])}
|
||||
`
|
||||
|
||||
// Redis is a fixed-window limiter.
|
||||
//
|
||||
// Fixed window has a known flaw: a client can send `limit` requests at the end
|
||||
// of one window and `limit` more at the start of the next, briefly achieving
|
||||
// twice the rate. A sliding window or token bucket avoids it at the cost of
|
||||
// more state. For protecting a backend from a runaway bot instance the simple
|
||||
// version is enough, and the burst is bounded at 2x rather than unbounded.
|
||||
type Redis struct {
|
||||
Client ScriptRunner
|
||||
Prefix string // namespace, so SCAN can find these keys when debugging
|
||||
}
|
||||
|
||||
func NewRedis(c ScriptRunner) *Redis {
|
||||
return &Redis{Client: c, Prefix: "rl:"}
|
||||
}
|
||||
|
||||
func (r *Redis) Allow(ctx context.Context, key string, limit int, dur time.Duration) (Result, error) {
|
||||
if limit <= 0 {
|
||||
return Result{Allowed: true}, nil
|
||||
}
|
||||
|
||||
raw, err := r.Client.Eval(ctx, allowScript,
|
||||
[]string{r.Prefix + key}, dur.Milliseconds())
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ratelimit: eval: %w", err)
|
||||
}
|
||||
|
||||
vals, ok := raw.([]any)
|
||||
if !ok || len(vals) != 2 {
|
||||
return Result{}, fmt.Errorf("ratelimit: unexpected script result %T", raw)
|
||||
}
|
||||
|
||||
count, ok1 := toInt64(vals[0])
|
||||
ttlMS, ok2 := toInt64(vals[1])
|
||||
|
||||
if !ok1 || !ok2 {
|
||||
return Result{}, fmt.Errorf("ratelimit: non-integer script result")
|
||||
}
|
||||
|
||||
remaining := limit - int(count)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
retry := time.Duration(ttlMS) * time.Millisecond
|
||||
if ttlMS < 0 {
|
||||
retry = dur
|
||||
}
|
||||
|
||||
return Result{
|
||||
Allowed: int(count) <= limit,
|
||||
Limit: limit,
|
||||
Remaining: remaining,
|
||||
RetryAfter: retry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Redis integers arrive as int64 through go-redis, but a fake or a future
|
||||
// client version may hand back int. Accept both rather than panicking.
|
||||
func toInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// GoRedis bridges *redis.Client to the ScriptRunner interface.
|
||||
//
|
||||
// Kept in its own file, and as thin as possible, because it is the one piece
|
||||
// here that cannot be exercised without a live Redis: everything in redis.go is
|
||||
// tested against a fake.
|
||||
type GoRedis struct {
|
||||
Client *redis.Client
|
||||
}
|
||||
|
||||
func (g GoRedis) Eval(ctx context.Context, script string, keys []string, args ...any) (any, error) {
|
||||
return g.Client.Eval(ctx, script, keys, args...).Result()
|
||||
}
|
||||
+55
-4
@@ -1,6 +1,57 @@
|
||||
-- name: GetKeyByHash :one
|
||||
SELECT k.id, k.owner_id, k.scopes, k.quota_tier, k.status, k.expires_at,
|
||||
o.status AS owner_status
|
||||
-- name: KeyByHash :one
|
||||
-- The authentication hot path. Joins owners so one round trip settles both the
|
||||
-- key's status and the account's.
|
||||
--
|
||||
-- No filter on status here: the middleware needs to distinguish "revoked" from
|
||||
-- "expired" from "unknown" to give a useful error, and it cannot do that if the
|
||||
-- query has already hidden the row.
|
||||
SELECT
|
||||
k.id,
|
||||
k.owner_id,
|
||||
k.scopes,
|
||||
k.quota_tier,
|
||||
k.status,
|
||||
k.expires_at,
|
||||
o.status AS owner_status
|
||||
FROM api.keys k
|
||||
JOIN api.owners o ON o.id = k.owner_id
|
||||
WHERE k.key_hash = $1 AND k.status = 'active';
|
||||
WHERE k.key_hash = $1;
|
||||
|
||||
-- name: TouchKeyLastUsed :exec
|
||||
-- Debounced by the caller: writing on every request would be one UPDATE per
|
||||
-- request for a field nobody reads in real time.
|
||||
UPDATE api.keys
|
||||
SET last_used_at = now()
|
||||
WHERE id = $1
|
||||
AND (last_used_at IS NULL OR last_used_at < now() - interval '5 minutes');
|
||||
|
||||
-- name: CreateOwner :one
|
||||
INSERT INTO api.owners (email, callsign)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, callsign, status, created_at;
|
||||
|
||||
-- name: OwnerByEmail :one
|
||||
SELECT id, email, email_verified_at, callsign, callsign_verified_at, status, created_at
|
||||
FROM api.owners
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: CreateKey :one
|
||||
INSERT INTO api.keys (owner_id, key_hash, key_prefix, label, scopes, quota_tier, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, key_prefix, scopes, quota_tier, status, created_at, expires_at;
|
||||
|
||||
-- name: ListKeysForOwner :many
|
||||
SELECT id, key_prefix, label, scopes, quota_tier, status,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api.keys
|
||||
WHERE owner_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: RevokeKey :exec
|
||||
-- The keys_revoked_consistent CHECK requires status and revoked_at to move
|
||||
-- together, so both are set here.
|
||||
UPDATE api.keys
|
||||
SET status = 'revoked', revoked_at = now(), revoked_reason = $2
|
||||
WHERE id = $1
|
||||
AND status <> 'revoked';
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
# sqlc.yaml
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "queries"
|
||||
|
||||
# Only the in-database DDL. scripts/bootstrap.sql is deliberately absent:
|
||||
# sqlc parses SQL to learn the schema and cannot handle CREATE ROLE,
|
||||
# ALTER DEFAULT PRIVILEGES or DO $$ blocks.
|
||||
schema:
|
||||
- "migrations/001_types.sql"
|
||||
- "migrations/002_core.sql"
|
||||
- "migrations/003_api.sql"
|
||||
- "migrations/004_logbook.sql"
|
||||
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "internal/db"
|
||||
sql_package: "pgx/v5"
|
||||
|
||||
# Nullable columns become *T rather than sql.NullT, which is what
|
||||
# internal/api/store.go expects for expires_at.
|
||||
emit_pointers_for_null_types: true
|
||||
emit_empty_slices: true
|
||||
|
||||
overrides:
|
||||
# The domains are text underneath; sqlc should not invent a type.
|
||||
- db_type: "core.callsign"
|
||||
go_type: "string"
|
||||
- db_type: "core.email"
|
||||
go_type: "string"
|
||||
- db_type: "core.gridsquare"
|
||||
go_type: "string"
|
||||
Reference in New Issue
Block a user