2820823b36
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
131 lines
3.7 KiB
Go
131 lines
3.7 KiB
Go
package adminstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
|
)
|
|
|
|
// APIKeyRow is an admin-facing view of an issued API key (never the secret).
|
|
type APIKeyRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
KeyPrefix string `json:"key_prefix"`
|
|
OwnerEmail *string `json:"owner_email"`
|
|
Tier string `json:"tier"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min"`
|
|
RevokedAt *string `json:"revoked_at"`
|
|
CreatedBy *string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// APIKeyInput holds the fields accepted when issuing a key.
|
|
type APIKeyInput struct {
|
|
Name string `json:"name"`
|
|
OwnerEmail string `json:"owner_email"`
|
|
Tier string `json:"tier"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min"`
|
|
}
|
|
|
|
// CreateAPIKey issues a new key, returning the one-time plaintext alongside the
|
|
// stored row. Only the SHA-256 hash and a short display prefix are persisted.
|
|
func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy string) (plaintext string, row APIKeyRow, err error) {
|
|
tier := in.Tier
|
|
if tier == "" {
|
|
tier = "free"
|
|
}
|
|
rate := in.RateLimitPerMin
|
|
if rate <= 0 {
|
|
rate = 120
|
|
}
|
|
var owner *string
|
|
if e := strings.TrimSpace(in.OwnerEmail); e != "" {
|
|
owner = &e
|
|
}
|
|
|
|
key, hash, prefix, err := apikey.Generate()
|
|
if err != nil {
|
|
return "", row, err
|
|
}
|
|
|
|
var revoked, created *time.Time
|
|
var createdByOut *string
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at`,
|
|
strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, createdBy,
|
|
).Scan(&row.ID, &row.Name, &row.KeyPrefix, &row.OwnerEmail, &row.Tier,
|
|
&row.RateLimitPerMin, &revoked, &createdByOut, &created)
|
|
if err != nil {
|
|
return "", row, err
|
|
}
|
|
row.CreatedBy = createdByOut
|
|
if created != nil {
|
|
row.CreatedAt = created.Format(time.RFC3339)
|
|
}
|
|
return key, row, nil
|
|
}
|
|
|
|
// ListAPIKeys returns all keys (active first, newest first).
|
|
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKeyRow, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at
|
|
FROM api_key
|
|
ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []APIKeyRow{}
|
|
for rows.Next() {
|
|
var r APIKeyRow
|
|
var revoked, created *time.Time
|
|
if err := rows.Scan(&r.ID, &r.Name, &r.KeyPrefix, &r.OwnerEmail, &r.Tier,
|
|
&r.RateLimitPerMin, &revoked, &r.CreatedBy, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
if revoked != nil {
|
|
v := revoked.Format(time.RFC3339)
|
|
r.RevokedAt = &v
|
|
}
|
|
if created != nil {
|
|
r.CreatedAt = created.Format(time.RFC3339)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeAPIKey marks a key revoked. Revoking an already-revoked or missing key
|
|
// returns ErrNotFound.
|
|
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error {
|
|
tag, err := s.pool.Exec(ctx,
|
|
"UPDATE api_key SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", id)
|
|
if err != nil {
|
|
if isInvalidUUID(err) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isInvalidUUID reports whether err is a Postgres invalid-UUID-text error,
|
|
// which happens when a non-UUID id is supplied.
|
|
func isInvalidUUID(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
return pgErr.Code == "22P02"
|
|
}
|
|
return false
|
|
}
|