From 76fb57adc2c72d32d9570d8e8818ec0795a22427 Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 1/8] feat(keys): add key generation, parsing and hashing package New keys use the format __: a 16 hex char key_id (64 bits) and a 48 hex char secret (192 bits) from crypto/rand. Keys are parsed from the right, so prefixes may contain single underscores and an empty prefix is allowed. Keys are stored as SHA-256 digests and compared in constant time. With 192 bits of entropy a slow hash adds nothing, and bcrypt both costs ~80ms per verify and silently truncates input past 72 bytes. Legacy bcrypt hashes are still accepted so existing keys keep working. --- internal/keys/keys.go | 143 +++++++++++++++++++++++++++++++++++++ internal/keys/keys_test.go | 114 +++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 internal/keys/keys.go create mode 100644 internal/keys/keys_test.go diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..f807d49 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,143 @@ +// Package keys generates, parses and hashes AuthCell API keys. +// +// Key format: __ +// +// - prefix: optional, letters/digits with single underscores ("live", "my_app") +// - key_id: 16 hex chars (64 bits), indexed for O(1) lookup +// - secret: 48 hex chars (192 bits) from crypto/rand +// +// Keys without a prefix are just _. +package keys + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "regexp" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const ( + KeyIDBytes = 8 + SecretBytes = 24 + MaxPrefixLen = 32 + MaxKeyLength = 256 + sha256HashPref = "sha256:" +) + +var ( + ErrMalformed = errors.New("malformed api key") + ErrInvalidPrefix = errors.New("key_prefix may only contain letters, digits and single underscores, up to 32 characters") + + prefixPattern = regexp.MustCompile(`^[A-Za-z0-9]+(_[A-Za-z0-9]+)*$`) + keyIDPattern = regexp.MustCompile(`^[a-f0-9]{8,32}$`) +) + +// Generated is a freshly minted key. Plaintext must be shown once and never stored. +type Generated struct { + Plaintext string + KeyID string + Prefix string + Hash string +} + +// NormalizePrefix trims whitespace and trailing underscores ("demo_" -> "demo") +// and validates the remaining characters. +func NormalizePrefix(p string) (string, error) { + p = strings.TrimRight(strings.TrimSpace(p), "_") + if p == "" { + return "", nil + } + if len(p) > MaxPrefixLen || !prefixPattern.MatchString(p) { + return "", ErrInvalidPrefix + } + return p, nil +} + +// Generate creates a new key with the given (already normalized) prefix. +func Generate(prefix string) (Generated, error) { + keyID, err := randomHex(KeyIDBytes) + if err != nil { + return Generated{}, err + } + secret, err := randomHex(SecretBytes) + if err != nil { + return Generated{}, err + } + plain := keyID + "_" + secret + if prefix != "" { + plain = prefix + "_" + plain + } + return Generated{Plaintext: plain, KeyID: keyID, Prefix: prefix, Hash: Hash(plain)}, nil +} + +// Parsed holds the public parts of a presented key. +type Parsed struct { + Prefix string + KeyID string +} + +// Parse splits a presented key. The secret is the last segment and the key_id +// the one before it, so prefixes may contain underscores. Legacy keys +// (prefix + 8-char id + UUID) parse the same way. +func Parse(key string) (Parsed, error) { + key = strings.TrimSpace(key) + if key == "" || len(key) > MaxKeyLength { + return Parsed{}, ErrMalformed + } + last := strings.LastIndexByte(key, '_') + if last <= 0 || last == len(key)-1 { + return Parsed{}, ErrMalformed + } + head := key[:last] + var p Parsed + if i := strings.LastIndexByte(head, '_'); i >= 0 { + p.Prefix, p.KeyID = head[:i], head[i+1:] + } else { + p.KeyID = head + } + if !keyIDPattern.MatchString(p.KeyID) { + return Parsed{}, ErrMalformed + } + return p, nil +} + +// PrefixMatches compares a parsed prefix with a stored one. Older versions stored +// prefixes with a trailing underscore ("demo_"), so both sides are normalized. +func PrefixMatches(presented, stored string) bool { + return strings.TrimRight(presented, "_") == strings.TrimRight(stored, "_") +} + +// Hash returns the storage form of a key. API keys carry 192 bits of entropy, +// so a fast SHA-256 digest is safe and avoids bcrypt's per-request CPU cost +// (and its silent 72-byte input limit). +func Hash(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return sha256HashPref + hex.EncodeToString(sum[:]) +} + +// Compare checks a presented key against a stored hash in constant time. +// legacy is true when the stored hash is bcrypt and should be upgraded. +func Compare(storedHash, plain string) (ok bool, legacy bool) { + if strings.HasPrefix(storedHash, sha256HashPref) { + want := []byte(storedHash) + got := []byte(Hash(plain)) + return subtle.ConstantTimeCompare(want, got) == 1, false + } + if strings.HasPrefix(storedHash, "$2") { + return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil, true + } + return false, false +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go new file mode 100644 index 0000000..feefb89 --- /dev/null +++ b/internal/keys/keys_test.go @@ -0,0 +1,114 @@ +package keys + +import ( + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestNormalizePrefix(t *testing.T) { + cases := map[string]struct { + want string + wantErr bool + }{ + "": {"", false}, + "demo": {"demo", false}, + "demo_": {"demo", false}, + " live__ ": {"live", false}, + "my_app": {"my_app", false}, + "my__app": {"", true}, + "bad-char": {"", true}, + "sp ace": {"", true}, + "_leading": {"", true}, + strings.Repeat("a", 33): {"", true}, + } + for in, tc := range cases { + got, err := NormalizePrefix(in) + if (err != nil) != tc.wantErr || got != tc.want { + t.Errorf("NormalizePrefix(%q) = %q, %v; want %q, err=%v", in, got, err, tc.want, tc.wantErr) + } + } +} + +func TestGenerateParseRoundTrip(t *testing.T) { + for _, prefix := range []string{"", "demo", "my_app_live"} { + g, err := Generate(prefix) + if err != nil { + t.Fatal(err) + } + if len(g.KeyID) != KeyIDBytes*2 { + t.Fatalf("key id length %d", len(g.KeyID)) + } + p, err := Parse(g.Plaintext) + if err != nil { + t.Fatalf("Parse(%q): %v", g.Plaintext, err) + } + if p.KeyID != g.KeyID || p.Prefix != prefix { + t.Fatalf("parsed %+v from %q, want prefix %q id %q", p, g.Plaintext, prefix, g.KeyID) + } + if ok, legacy := Compare(g.Hash, g.Plaintext); !ok || legacy { + t.Fatalf("Compare failed for fresh key") + } + if ok, _ := Compare(g.Hash, g.Plaintext+"x"); ok { + t.Fatalf("Compare accepted a wrong key") + } + } +} + +func TestGenerateIsUnique(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 1000; i++ { + g, err := Generate("t") + if err != nil { + t.Fatal(err) + } + if seen[g.KeyID] { + t.Fatalf("duplicate key id %s", g.KeyID) + } + seen[g.KeyID] = true + } +} + +func TestParseLegacyFormat(t *testing.T) { + // Keys minted by the original version: "" + 8 hex + "_" + uuid. + p, err := Parse("demo_4d83e91f_98d9c77a-b067-4723-9325-70f59dfd448b") + if err != nil { + t.Fatal(err) + } + if p.Prefix != "demo" || p.KeyID != "4d83e91f" { + t.Fatalf("got %+v", p) + } + if !PrefixMatches(p.Prefix, "demo_") { + t.Fatal("legacy stored prefix should match") + } +} + +func TestParseRejectsGarbage(t *testing.T) { + for _, in := range []string{ + "", "nounderscore", "_", "abc_", "_abc", "demo_NOTHEX123_secret", + "demo_abc_secret", strings.Repeat("a", MaxKeyLength) + "_deadbeef_x", + } { + if _, err := Parse(in); err == nil { + t.Errorf("Parse(%q) should fail", in) + } + } +} + +func TestCompareLegacyBcrypt(t *testing.T) { + key := "demo_4d83e91f_98d9c77a-b067-4723-9325-70f59dfd448b" + h, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + ok, legacy := Compare(string(h), key) + if !ok || !legacy { + t.Fatalf("legacy compare: ok=%v legacy=%v", ok, legacy) + } + if ok, _ := Compare(string(h), key+"x"); ok { + t.Fatal("legacy compare accepted wrong key") + } + if ok, _ := Compare("", key); ok { + t.Fatal("empty hash must never match") + } +} From 9ec983a617a9597c814c9839e5a6004a761b86ed Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 2/8] feat(config): validate configuration at startup Load every setting from the environment in one place and fail fast with a combined error listing what is wrong. AUTHCELL_ADMIN_TOKEN is required, must be at least 32 characters, and may not be the .env.example placeholder. The database URL is built with net/url so passwords containing special characters are escaped, and DATABASE_URL can override the DB_* values. PORT now defaults to 8080 instead of binding a random port when unset. --- internal/config/config.go | 164 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 48 ++++++++++ 2 files changed, 212 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b6c460a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,164 @@ +// Package config loads AuthCell settings from environment variables. +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// MinTokenLength is the shortest admin/verify token AuthCell will accept. +const MinTokenLength = 32 + +type Config struct { + Env string + Port int + + // DatabaseURL is a full postgres:// connection string. Built from the + // DB_* variables when DATABASE_URL is not set. + DatabaseURL string + + // AdminToken guards every management endpoint (create, list, revoke...). + AdminToken string + // VerifyToken, when set, must be sent to POST /v1/keys/verify. + VerifyToken string + + // RateLimitWindow is the fixed window used for per-key rate limits. + RateLimitWindow time.Duration + // UsageRetention controls how long api_usage rows are kept. 0 keeps forever. + UsageRetention time.Duration + + CORSOrigins string + TrustedProxies []string + + DBMaxConns int + DBMinConns int + DBConnMaxLifetime time.Duration + RequestTimeout time.Duration +} + +// Load reads configuration from the environment and validates it. +func Load() (Config, error) { + cfg := Config{ + Env: getenv("APP_ENV", "production"), + AdminToken: strings.TrimSpace(os.Getenv("AUTHCELL_ADMIN_TOKEN")), + VerifyToken: strings.TrimSpace(os.Getenv("AUTHCELL_VERIFY_TOKEN")), + CORSOrigins: strings.TrimSpace(os.Getenv("CORS_ORIGINS")), + TrustedProxies: splitList(os.Getenv("TRUSTED_PROXIES")), + DBConnMaxLifetime: 30 * time.Minute, + } + + var errs []error + var err error + + if cfg.Port, err = intEnv("PORT", 8080); err != nil { + errs = append(errs, err) + } else if cfg.Port < 1 || cfg.Port > 65535 { + errs = append(errs, fmt.Errorf("PORT must be between 1 and 65535, got %d", cfg.Port)) + } + if cfg.DBMaxConns, err = intEnv("DB_MAX_CONNS", 25); err != nil { + errs = append(errs, err) + } + if cfg.DBMinConns, err = intEnv("DB_MIN_CONNS", 2); err != nil { + errs = append(errs, err) + } + if cfg.RateLimitWindow, err = durationEnv("RATE_LIMIT_WINDOW", time.Minute); err != nil { + errs = append(errs, err) + } else if cfg.RateLimitWindow < time.Second { + errs = append(errs, errors.New("RATE_LIMIT_WINDOW must be at least 1s")) + } + if cfg.RequestTimeout, err = durationEnv("REQUEST_TIMEOUT", 10*time.Second); err != nil { + errs = append(errs, err) + } + retentionDays, err := intEnv("USAGE_RETENTION_DAYS", 90) + if err != nil { + errs = append(errs, err) + } else if retentionDays < 0 { + errs = append(errs, errors.New("USAGE_RETENTION_DAYS cannot be negative")) + } + cfg.UsageRetention = time.Duration(retentionDays) * 24 * time.Hour + + if cfg.AdminToken == "" { + errs = append(errs, errors.New("AUTHCELL_ADMIN_TOKEN is required (generate one with: openssl rand -hex 32)")) + } else if strings.Contains(strings.ToLower(cfg.AdminToken), "change-me") { + errs = append(errs, errors.New("AUTHCELL_ADMIN_TOKEN still has the placeholder value from .env.example")) + } else if len(cfg.AdminToken) < MinTokenLength { + errs = append(errs, fmt.Errorf("AUTHCELL_ADMIN_TOKEN must be at least %d characters", MinTokenLength)) + } + if cfg.VerifyToken != "" && len(cfg.VerifyToken) < MinTokenLength { + errs = append(errs, fmt.Errorf("AUTHCELL_VERIFY_TOKEN must be at least %d characters", MinTokenLength)) + } + if cfg.VerifyToken != "" && cfg.VerifyToken == cfg.AdminToken { + errs = append(errs, errors.New("AUTHCELL_VERIFY_TOKEN must differ from AUTHCELL_ADMIN_TOKEN")) + } + + cfg.DatabaseURL = strings.TrimSpace(os.Getenv("DATABASE_URL")) + if cfg.DatabaseURL == "" { + cfg.DatabaseURL = buildDatabaseURL() + } + + return cfg, errors.Join(errs...) +} + +// buildDatabaseURL assembles a connection string from DB_* variables. +// url.URL takes care of escaping passwords with special characters. +func buildDatabaseURL() string { + q := url.Values{} + q.Set("sslmode", getenv("DB_SSLMODE", "disable")) + if schema := getenv("DB_SCHEMA", "public"); schema != "" { + q.Set("search_path", schema) + } + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(getenv("DB_USERNAME", "postgres"), os.Getenv("DB_PASSWORD")), + Host: getenv("DB_HOST", "localhost") + ":" + getenv("DB_PORT", "5432"), + Path: "/" + getenv("DB_DATABASE", "postgres"), + RawQuery: q.Encode(), + } + return u.String() +} + +func getenv(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func intEnv(key string, fallback int) (int, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be an integer, got %q", key, v) + } + return n, nil +} + +func durationEnv(key string, fallback time.Duration) (time.Duration, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("%s must be a duration like 30s or 1m, got %q", key, v) + } + return d, nil +} + +func splitList(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..15a1963 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "strings" + "testing" +) + +func TestLoadRequiresAdminToken(t *testing.T) { + t.Setenv("AUTHCELL_ADMIN_TOKEN", "") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "AUTHCELL_ADMIN_TOKEN") { + t.Fatalf("expected admin token error, got %v", err) + } + t.Setenv("AUTHCELL_ADMIN_TOKEN", "short") + if _, err := Load(); err == nil { + t.Fatal("short admin token should be rejected") + } +} + +func TestLoadDefaultsAndEscaping(t *testing.T) { + t.Setenv("AUTHCELL_ADMIN_TOKEN", strings.Repeat("a", 40)) + t.Setenv("DATABASE_URL", "") + t.Setenv("PORT", "") + t.Setenv("DB_PASSWORD", "p@ss:w/rd#1") + t.Setenv("DB_HOST", "db") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Port != 8080 { + t.Fatalf("default port = %d", cfg.Port) + } + if !strings.Contains(cfg.DatabaseURL, "p%40ss%3Aw%2Frd%231@db:5432") { + t.Fatalf("password not escaped: %s", cfg.DatabaseURL) + } +} + +func TestLoadRejectsBadValues(t *testing.T) { + t.Setenv("AUTHCELL_ADMIN_TOKEN", strings.Repeat("a", 40)) + t.Setenv("PORT", "abc") + if _, err := Load(); err == nil { + t.Fatal("bad PORT should fail") + } + t.Setenv("PORT", "8080") + t.Setenv("AUTHCELL_VERIFY_TOKEN", strings.Repeat("a", 40)) + if _, err := Load(); err == nil { + t.Fatal("verify token equal to admin token should fail") + } +} From 9607e2c3a9d81dcd2e7e072dabac2f8f2af07de0 Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 3/8] feat!: authenticate admin API, enforce rate limits, add rotate/update/list Security - Require "Authorization: Bearer " on every management route. Previously anyone who could reach the port could create, list and revoke keys. - Optionally protect /v1/keys/verify with AUTHCELL_VERIFY_TOKEN. - Never serialize key_hash (GET /v1/keys/:id returned it). - Return generic messages to clients and log the real error server side. - Only reveal revoked/expired/rate_limited after the caller proves they hold the key; everything else is "invalid". - CORS is opt-in via CORS_ORIGINS. Fixes - /v1/health no longer calls log.Fatalf when the database is down; it returns 503. - Key listing queried a nonexistent "api_keys" table and was never routed. - Revoking an unknown id returned 200; it now returns 404 (400 for a malformed id). - Usage rows for unknown keys inserted "" into a uuid column and failed. - Rows with NULL columns were silently dropped from listings. - Empty listings returned null instead of []. - Key id collisions surfaced as 500s; ids are now 64 bits with a retry. Features - GET /v1/keys with limit/offset/active/prefix filters. - PATCH /v1/keys/:id for name, metadata, expires_at (null clears) and rate_limit. - POST /v1/keys/:id/rotate issues a replacement and revokes the old key. - Per-key rate limits over RATE_LIMIT_WINDOW, done in one atomic UPDATE so they hold across instances, with X-RateLimit-* and Retry-After. - Verify accepts X-API-Key and returns key id, prefix, name, metadata, expiry and remaining quota. - Legacy bcrypt hashes are upgraded to SHA-256 on first successful use. - Cursor pagination for usage and audit logs; event_type filter. - Hourly pruning of usage rows past USAGE_RETENTION_DAYS (default 90). Internals - Switch from database/sql to pgxpool; all queries take a request context with REQUEST_TIMEOUT. - Versioned migrations under a Postgres advisory lock. Migration 1 is the original schema (IF NOT EXISTS) so v0.0.x databases upgrade in place. - slog JSON logs with request ids, recover middleware, 64KB body limit, security headers and JSON 404s. - Graceful shutdown closes the pool; -version and -healthcheck flags. - Target Go 1.24 and drop testcontainers; integration tests run against AUTHCELL_TEST_DATABASE_URL and skip when it is unset. BREAKING CHANGE: management endpoints require the admin bearer token, rate_limit is now enforced, revoke returns the key record, and audit_log.actor_id changes from uuid to text. --- cmd/api/healthcheck.go | 28 ++ cmd/api/main.go | 127 +++++--- go.mod | 55 +--- internal/app/fiber_server.go | 30 -- internal/database/api_key.go | 165 +++++++---- internal/database/api_usage.go | 29 -- internal/database/audit.go | 37 --- internal/database/database.go | 269 ++++++++--------- internal/database/database_test.go | 166 ++++++----- internal/database/logs.go | 83 ++++++ internal/database/migrations.go | 132 +++++++++ internal/database/models.go | 79 ++++- internal/server/handler/audit_handler.go | 17 -- internal/server/handler/handler.go | 90 ++++++ internal/server/handler/health_handler.go | 11 - internal/server/handler/key_handler.go | 188 ------------ internal/server/handler/keys.go | 300 +++++++++++++++++++ internal/server/handler/logs.go | 81 ++++++ internal/server/handler/usage_handler.go | 19 -- internal/server/handler/verify.go | 148 ++++++++++ internal/server/response/response.go | 38 ++- internal/server/routes.go | 41 --- internal/server/routes_test.go | 9 - internal/server/server.go | 159 ++++++++++ internal/server/server_test.go | 335 ++++++++++++++++++++++ 25 files changed, 1877 insertions(+), 759 deletions(-) create mode 100644 cmd/api/healthcheck.go delete mode 100644 internal/app/fiber_server.go delete mode 100644 internal/database/api_usage.go delete mode 100644 internal/database/audit.go create mode 100644 internal/database/logs.go create mode 100644 internal/database/migrations.go delete mode 100644 internal/server/handler/audit_handler.go create mode 100644 internal/server/handler/handler.go delete mode 100644 internal/server/handler/health_handler.go delete mode 100644 internal/server/handler/key_handler.go create mode 100644 internal/server/handler/keys.go create mode 100644 internal/server/handler/logs.go delete mode 100644 internal/server/handler/usage_handler.go create mode 100644 internal/server/handler/verify.go delete mode 100644 internal/server/routes.go delete mode 100644 internal/server/routes_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/cmd/api/healthcheck.go b/cmd/api/healthcheck.go new file mode 100644 index 0000000..cf3bf46 --- /dev/null +++ b/cmd/api/healthcheck.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" +) + +// runHealthcheck lets the distroless image check itself without curl/wget. +func runHealthcheck() int { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + client := http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%s/v1/health", port)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, "unhealthy:", resp.Status) + return 1 + } + return 0 +} diff --git a/cmd/api/main.go b/cmd/api/main.go index ba97291..3f22ee2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,66 +1,121 @@ package main import ( - "authcell/internal/app" - "authcell/internal/server" "context" + "errors" + "flag" "fmt" - "log" + "log/slog" "os" "os/signal" "strconv" "syscall" "time" + "authcell/internal/config" + "authcell/internal/database" + "authcell/internal/server" + _ "github.com/joho/godotenv/autoload" ) -func gracefulShutdown(fiberServer *app.FiberServer, done chan bool) { - // Create context that listens for the interrupt signal from the OS. - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - // Listen for the interrupt signal. - <-ctx.Done() +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" - log.Println("shutting down gracefully, press Ctrl+C again to force") - stop() // Allow Ctrl+C to force shutdown +func main() { + showVersion := flag.Bool("version", false, "print version and exit") + healthcheck := flag.Bool("healthcheck", false, "probe /v1/health on PORT and exit (for container health checks)") + flag.Parse() - // The context is used to inform the server it has 5 seconds to finish - // the request it is currently handling - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := fiberServer.ShutdownWithContext(ctx); err != nil { - log.Printf("Server forced to shutdown with error: %v", err) + if *showVersion { + fmt.Println(version) + return + } + if *healthcheck { + os.Exit(runHealthcheck()) } - log.Println("Server exiting") + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel()}))) - // Notify the main goroutine that the shutdown is complete - done <- true + if err := run(); err != nil { + slog.Error("authcell stopped", "error", err) + os.Exit(1) + } } -func main() { +func run() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("invalid configuration:\n%w", err) + } - serverApp := app.NewFiberServer() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() - server.RegisterFiberRoutes(serverApp) + connectCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + db, err := database.Connect(connectCtx, cfg.DatabaseURL, database.Options{ + MaxConns: cfg.DBMaxConns, MinConns: cfg.DBMinConns, ConnMaxLifetime: cfg.DBConnMaxLifetime, + }) + cancel() + if err != nil { + return err + } + defer db.Close() - // Create a done channel to signal when the shutdown is complete - done := make(chan bool, 1) + if cfg.UsageRetention > 0 { + go pruneUsage(ctx, db, cfg.UsageRetention) + } + + app := server.New(cfg, db, version) + listenErr := make(chan error, 1) go func() { - port, _ := strconv.Atoi(os.Getenv("PORT")) - err := serverApp.Listen(fmt.Sprintf(":%d", port)) - if err != nil { - panic(fmt.Sprintf("http server error: %s", err)) - } + addr := ":" + strconv.Itoa(cfg.Port) + slog.Info("authcell listening", "addr", addr, "version", version, "env", cfg.Env, + "verify_protected", cfg.VerifyToken != "") + listenErr <- app.Listen(addr) }() - // Run graceful shutdown in a separate goroutine - go gracefulShutdown(serverApp, done) + select { + case err := <-listenErr: + return err + case <-ctx.Done(): + } + + slog.Info("shutting down") + stop() // a second Ctrl+C now kills the process + if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { + slog.Warn("forced shutdown", "error", err) + } + slog.Info("shutdown complete") + return nil +} - // Wait for the graceful shutdown to complete - <-done - log.Println("Graceful shutdown complete.") +// pruneUsage deletes old usage rows once an hour so the table doesn't grow forever. +func pruneUsage(ctx context.Context, db database.Service, retention time.Duration) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + pctx, cancel := context.WithTimeout(ctx, time.Minute) + n, err := db.PruneUsage(pctx, time.Now().Add(-retention)) + cancel() + if err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("usage prune failed", "error", err) + } else if n > 0 { + slog.Info("pruned usage rows", "rows", n) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func logLevel() slog.Level { + var l slog.Level + if err := l.UnmarshalText([]byte(os.Getenv("LOG_LEVEL"))); err != nil { + return slog.LevelInfo + } + return l } diff --git a/go.mod b/go.mod index cc74b72..9309ad3 100644 --- a/go.mod +++ b/go.mod @@ -1,81 +1,28 @@ module authcell -go 1.25.3 +go 1.24.0 require ( github.com/gofiber/fiber/v2 v2.52.9 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 - github.com/testcontainers/testcontainers-go v0.39.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0 golang.org/x/crypto v0.43.0 ) require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/brotli v1.2.0 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - github.com/containerd/errdefs v1.0.0 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.3.3+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.8.4 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.1.0 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect - github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/morikuni/aec v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shirou/gopsutil/v4 v4.25.6 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/testify v1.10.0 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.67.0 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/app/fiber_server.go b/internal/app/fiber_server.go deleted file mode 100644 index b16fa4a..0000000 --- a/internal/app/fiber_server.go +++ /dev/null @@ -1,30 +0,0 @@ -package app - -import ( - "authcell/internal/database" - - "github.com/gofiber/fiber/v2" -) - -type FiberServer struct { - *fiber.App - Db database.Service -} - -func NewFiberServer() *FiberServer { - server := &FiberServer{ - App: fiber.New(fiber.Config{ - ServerHeader: "authcell", - AppName: "authcell", - }), - - Db: database.New(), - } - - // Ensure tables/schema exist at startup - if err := server.Db.EnsureSchema(); err != nil { - panic("database schema creation failed: " + err.Error()) - } - - return server -} diff --git a/internal/database/api_key.go b/internal/database/api_key.go index c636e01..f7d8ed8 100644 --- a/internal/database/api_key.go +++ b/internal/database/api_key.go @@ -1,68 +1,137 @@ package database -import "context" +import ( + "context" + "strconv" + "strings" + "time" -func (s *service) GetAllAPIKeys() ([]APIKey, error) { - rows, err := s.db.QueryContext(context.Background(), - `SELECT id, key_hash, key_prefix, key_id, is_active, created_at, expires_at, rate_limit, usage_count - FROM api_keys ORDER BY created_at DESC`) + "github.com/jackc/pgx/v5" +) + +const keyColumns = `id::text, key_id, key_hash, key_prefix, name, metadata, is_active, + created_at, expires_at, revoked_at, last_used_at, rate_limit, usage_count` + +func scanKey(row pgx.Row) (APIKey, error) { + var k APIKey + var meta []byte + err := row.Scan(&k.ID, &k.KeyID, &k.KeyHash, &k.KeyPrefix, &k.Name, &meta, &k.IsActive, + &k.CreatedAt, &k.ExpiresAt, &k.RevokedAt, &k.LastUsedAt, &k.RateLimit, &k.UsageCount) + if err != nil { + return APIKey{}, mapErr(err) + } + k.Metadata = meta + return k, nil +} + +func (s *service) CreateAPIKey(ctx context.Context, n NewAPIKey) (APIKey, error) { + row := s.pool.QueryRow(ctx, ` + INSERT INTO api_key (key_id, key_hash, key_prefix, name, metadata, expires_at, rate_limit) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+keyColumns, + n.KeyID, n.KeyHash, n.KeyPrefix, n.Name, jsonOrEmpty(n.Metadata), n.ExpiresAt, n.RateLimit) + return scanKey(row) +} + +func (s *service) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) { + return scanKey(s.pool.QueryRow(ctx, `SELECT `+keyColumns+` FROM api_key WHERE id = $1`, id)) +} + +func (s *service) GetAPIKeyByKeyID(ctx context.Context, keyID string) (APIKey, error) { + return scanKey(s.pool.QueryRow(ctx, `SELECT `+keyColumns+` FROM api_key WHERE key_id = $1`, keyID)) +} + +func (s *service) ListAPIKeys(ctx context.Context, f KeyFilter) ([]APIKey, int, error) { + var where []string + var args []any + if f.Active != nil { + args = append(args, *f.Active) + where = append(where, "is_active = $1") + } + if f.Prefix != "" { + args = append(args, strings.TrimRight(f.Prefix, "_")) + where = append(where, "rtrim(key_prefix, '_') = $"+strconv.Itoa(len(args))) + } + clause := "" + if len(where) > 0 { + clause = " WHERE " + strings.Join(where, " AND ") + } + + var total int + if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM api_key`+clause, args...).Scan(&total); err != nil { + return nil, 0, err + } + + args = append(args, f.Limit, f.Offset) + rows, err := s.pool.Query(ctx, `SELECT `+keyColumns+` FROM api_key`+clause+ + ` ORDER BY created_at DESC, id LIMIT $`+strconv.Itoa(len(args)-1)+` OFFSET $`+strconv.Itoa(len(args)), args...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() - var keys []APIKey + out := make([]APIKey, 0, f.Limit) for rows.Next() { - var k APIKey - if err := rows.Scan(&k.ID, &k.KeyHash, &k.KeyPrefix, &k.KeyID, - &k.IsActive, &k.CreatedAt, &k.ExpiresAt, &k.RateLimit, &k.UsageCount); err == nil { - keys = append(keys, k) + k, err := scanKey(rows) + if err != nil { + return nil, 0, err } + out = append(out, k) } - return keys, nil + return out, total, rows.Err() } -func (s *service) CreateAPIKey(key APIKey) error { - _, err := s.db.ExecContext(context.Background(), - `INSERT INTO api_key - (id, key_hash, key_prefix, key_id, is_active, created_at, expires_at, rate_limit, usage_count) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, - key.ID, key.KeyHash, key.KeyPrefix, key.KeyID, key.IsActive, key.CreatedAt, key.ExpiresAt, key.RateLimit, key.UsageCount) - return err -} - -func (s *service) GetAPIKeyByID(id string) (APIKey, error) { - var key APIKey - row := s.db.QueryRowContext(context.Background(), - `SELECT id, key_hash, key_prefix, is_active, created_at, expires_at, rate_limit, usage_count - FROM api_key WHERE id=$1`, id) - err := row.Scan(&key.ID, &key.KeyHash, &key.KeyPrefix, &key.IsActive, - &key.CreatedAt, &key.ExpiresAt, &key.RateLimit, &key.UsageCount) - return key, err +func (s *service) UpdateAPIKey(ctx context.Context, id string, u APIKeyUpdate) (APIKey, error) { + var meta any + if len(u.Metadata) > 0 { + meta = []byte(u.Metadata) + } + row := s.pool.QueryRow(ctx, ` + UPDATE api_key SET + name = COALESCE($2, name), + metadata = COALESCE($3::jsonb, metadata), + expires_at = CASE WHEN $4 THEN NULL ELSE COALESCE($5, expires_at) END, + rate_limit = COALESCE($6, rate_limit) + WHERE id = $1 + RETURNING `+keyColumns, + id, u.Name, meta, u.ClearExpires, u.ExpiresAt, u.RateLimit) + return scanKey(row) } -func (s *service) GetAPIKeyByPrefix(prefix string) (APIKey, error) { - var key APIKey - row := s.db.QueryRowContext(context.Background(), - `SELECT id, key_hash, key_prefix, is_active, created_at, expires_at, rate_limit, usage_count - FROM api_key WHERE key_prefix=$1 LIMIT 1`, prefix) - err := row.Scan(&key.ID, &key.KeyHash, &key.KeyPrefix, &key.IsActive, - &key.CreatedAt, &key.ExpiresAt, &key.RateLimit, &key.UsageCount) - return key, err +// RevokeAPIKey deactivates a key. Revoking an already revoked key is a no-op +// that still returns the key, so the call is idempotent. +func (s *service) RevokeAPIKey(ctx context.Context, id string) (APIKey, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE api_key SET is_active = false, revoked_at = COALESCE(revoked_at, now()) + WHERE id = $1 + RETURNING `+keyColumns, id) + return scanKey(row) } -func (s *service) RevokeAPIKey(id string) error { - _, err := s.db.ExecContext(context.Background(), - `UPDATE api_key SET is_active=false WHERE id=$1`, id) +func (s *service) UpgradeKeyHash(ctx context.Context, id, hash string) error { + _, err := s.pool.Exec(ctx, `UPDATE api_key SET key_hash = $2 WHERE id = $1`, id, hash) return err } -func (s *service) GetAPIKeyByKeyID(keyID string) (APIKey, error) { - var key APIKey - row := s.db.QueryRowContext(context.Background(), - `SELECT id, key_id, key_hash, key_prefix, is_active, created_at, expires_at, rate_limit, usage_count - FROM api_key WHERE key_id=$1`, keyID) - err := row.Scan(&key.ID, &key.KeyID, &key.KeyHash, &key.KeyPrefix, &key.IsActive, - &key.CreatedAt, &key.ExpiresAt, &key.RateLimit, &key.UsageCount) - return key, err +// RecordUse bumps usage counters and the fixed rate-limit window in one atomic +// statement, so limits hold across multiple AuthCell instances. +func (s *service) RecordUse(ctx context.Context, id string, window time.Duration) (UseResult, error) { + var r UseResult + var start time.Time + err := s.pool.QueryRow(ctx, ` + UPDATE api_key SET + usage_count = usage_count + 1, + last_used_at = now(), + window_count = CASE WHEN window_start IS NOT NULL AND window_start > now() - $2::interval + THEN window_count + 1 ELSE 1 END, + window_start = CASE WHEN window_start IS NOT NULL AND window_start > now() - $2::interval + THEN window_start ELSE now() END + WHERE id = $1 + RETURNING window_count, rate_limit, window_start`, + id, window).Scan(&r.WindowCount, &r.RateLimit, &start) + if err != nil { + return UseResult{}, mapErr(err) + } + r.WindowReset = start.Add(window) + return r, nil } diff --git a/internal/database/api_usage.go b/internal/database/api_usage.go deleted file mode 100644 index 95cb160..0000000 --- a/internal/database/api_usage.go +++ /dev/null @@ -1,29 +0,0 @@ -package database - -import "context" - -func (s *service) TrackUsage(keyID, endpoint, ip string, status int) error { - _, err := s.db.ExecContext(context.Background(), - `INSERT INTO api_usage (api_key_id, endpoint, remote_ip, status) - VALUES ($1, $2, $3, $4)`, keyID, endpoint, ip, status) - return err -} - -func (s *service) ListUsage(keyID string) ([]APIUsage, error) { - rows, err := s.db.QueryContext(context.Background(), - `SELECT id, api_key_id, endpoint, used_at, status, remote_ip - FROM api_usage WHERE api_key_id=$1 ORDER BY used_at DESC LIMIT 100`, keyID) - if err != nil { - return nil, err - } - defer rows.Close() - - var usages []APIUsage - for rows.Next() { - var u APIUsage - if err := rows.Scan(&u.ID, &u.APIKeyID, &u.Endpoint, &u.UsedAt, &u.Status, &u.RemoteIP); err == nil { - usages = append(usages, u) - } - } - return usages, nil -} diff --git a/internal/database/audit.go b/internal/database/audit.go deleted file mode 100644 index 8074aaa..0000000 --- a/internal/database/audit.go +++ /dev/null @@ -1,37 +0,0 @@ -package database - -import ( - "context" - "encoding/json" - "fmt" -) - -func (s *service) TrackAudit(actorType, eventType, ip string, data map[string]any) error { - payload, _ := json.Marshal(data) - _, err := s.db.ExecContext(context.Background(), - `INSERT INTO audit_log (actor_id, actor_type, event_type, event_data, remote_ip) - VALUES (gen_random_uuid(), $1, $2, $3, $4)`, - actorType, eventType, payload, ip) - return err -} - -func (s *service) ListAuditLog() ([]AuditLog, error) { - rows, err := s.db.QueryContext(context.Background(), - `SELECT id, actor_id, actor_type, event_type, event_data, event_time, remote_ip - FROM audit_log ORDER BY event_time DESC LIMIT 100`) - if err != nil { - return nil, err - } - defer rows.Close() - - var logs []AuditLog - for rows.Next() { - var l AuditLog - if err := rows.Scan(&l.ID, &l.ActorID, &l.ActorType, &l.EventType, &l.EventData, &l.EventTime, &l.RemoteIP); err != nil { - fmt.Printf("Audit scan error: %v\n", err) - continue - } - logs = append(logs, l) - } - return logs, nil -} diff --git a/internal/database/database.go b/internal/database/database.go index 69492ed..cb6d9b0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,193 +1,154 @@ +// Package database is the PostgreSQL storage layer for AuthCell. package database import ( "context" - "database/sql" + "encoding/json" + "errors" "fmt" - "log" - "os" - "strconv" + "net" "time" - _ "github.com/jackc/pgx/v5/stdlib" - _ "github.com/joho/godotenv/autoload" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" ) -// Service represents a service that interacts with a database. +// Service is everything the HTTP layer needs from storage. type Service interface { - // Health returns a map of health status information. - // The keys and values in the map are service-specific. - Health() map[string]string - - // Close terminates the database connection. - // It returns an error if the connection cannot be closed. - Close() error - - EnsureSchema() error - - GetAllAPIKeys() ([]APIKey, error) - CreateAPIKey(key APIKey) error - GetAPIKeyByID(id string) (APIKey, error) - GetAPIKeyByPrefix(prefix string) (APIKey, error) - GetAPIKeyByKeyID(prefix string) (APIKey, error) - RevokeAPIKey(id string) error - ListUsage(keyID string) ([]APIUsage, error) - ListAuditLog() ([]AuditLog, error) - TrackUsage(keyID, endpoint, ip string, status int) error - TrackAudit(actorType, eventType, ip string, data map[string]any) error + Ping(ctx context.Context) error + Stats() map[string]any + Close() + + CreateAPIKey(ctx context.Context, k NewAPIKey) (APIKey, error) + GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + GetAPIKeyByKeyID(ctx context.Context, keyID string) (APIKey, error) + ListAPIKeys(ctx context.Context, f KeyFilter) ([]APIKey, int, error) + UpdateAPIKey(ctx context.Context, id string, u APIKeyUpdate) (APIKey, error) + RevokeAPIKey(ctx context.Context, id string) (APIKey, error) + UpgradeKeyHash(ctx context.Context, id, hash string) error + RecordUse(ctx context.Context, id string, window time.Duration) (UseResult, error) + + TrackUsage(ctx context.Context, apiKeyID, endpoint, ip string, status int) error + ListUsage(ctx context.Context, apiKeyID string, before int64, limit int) ([]APIUsage, error) + PruneUsage(ctx context.Context, olderThan time.Time) (int64, error) + + TrackAudit(ctx context.Context, actorType, actorID, eventType, ip string, data map[string]any) error + ListAuditLog(ctx context.Context, f AuditFilter) ([]AuditLog, error) } -type service struct { - db *sql.DB +// Options configures the connection pool. +type Options struct { + MaxConns int + MinConns int + ConnMaxLifetime time.Duration } -var ( - database = os.Getenv("DB_DATABASE") - password = os.Getenv("DB_PASSWORD") - username = os.Getenv("DB_USERNAME") - port = os.Getenv("DB_PORT") - host = os.Getenv("DB_HOST") - schema = os.Getenv("DB_SCHEMA") - dbInstance *service -) +type service struct { + pool *pgxpool.Pool +} -func New() Service { - // Reuse Connection - if dbInstance != nil { - return dbInstance - } - connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable&search_path=%s", username, password, host, port, database, schema) - db, err := sql.Open("pgx", connStr) +// Connect opens a pool, verifies connectivity and runs migrations. +func Connect(ctx context.Context, dsn string, opts Options) (Service, error) { + cfg, err := pgxpool.ParseConfig(dsn) if err != nil { - log.Fatal(err) + return nil, fmt.Errorf("parse database url: %w", err) } - dbInstance = &service{ - db: db, + if opts.MaxConns > 0 { + cfg.MaxConns = int32(opts.MaxConns) + } + if opts.MinConns > 0 && opts.MinConns <= int(cfg.MaxConns) { + cfg.MinConns = int32(opts.MinConns) + } + if opts.ConnMaxLifetime > 0 { + cfg.MaxConnLifetime = opts.ConnMaxLifetime } - return dbInstance -} - -// Health checks the health of the database connection by pinging the database. -// It returns a map with keys indicating various health statistics. -func (s *service) Health() map[string]string { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - stats := make(map[string]string) - // Ping the database - err := s.db.PingContext(ctx) + pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { - stats["status"] = "down" - stats["error"] = fmt.Sprintf("db down: %v", err) - log.Fatalf("db down: %v", err) // Log the error and terminate the program - return stats + return nil, fmt.Errorf("open pool: %w", err) } - // Database is up, add more statistics - stats["status"] = "up" - stats["message"] = "It's healthy" - - // Get database stats (like open connections, in use, idle, etc.) - dbStats := s.db.Stats() - stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections) - stats["in_use"] = strconv.Itoa(dbStats.InUse) - stats["idle"] = strconv.Itoa(dbStats.Idle) - stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10) - stats["wait_duration"] = dbStats.WaitDuration.String() - stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10) - stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10) - - // Evaluate stats to provide a health message - if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example - stats["message"] = "The database is experiencing heavy load." + // Postgres often needs a few seconds to come up in compose; retry briefly. + var pingErr error + for attempt := 0; attempt < 10; attempt++ { + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + pingErr = pool.Ping(pingCtx) + cancel() + if pingErr == nil { + break + } + select { + case <-ctx.Done(): + pool.Close() + return nil, ctx.Err() + case <-time.After(time.Duration(attempt+1) * 500 * time.Millisecond): + } } - - if dbStats.WaitCount > 1000 { - stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." + if pingErr != nil { + pool.Close() + return nil, fmt.Errorf("connect to database: %w", pingErr) } - if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." + if err := migrate(ctx, pool); err != nil { + pool.Close() + return nil, fmt.Errorf("migrate: %w", err) } + return &service{pool: pool}, nil +} - if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." - } +func (s *service) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } - return stats -} +func (s *service) Close() { s.pool.Close() } -// Close closes the database connection. -// It logs a message indicating the disconnection from the specific database. -// If the connection is successfully closed, it returns nil. -// If an error occurs while closing the connection, it returns the error. -func (s *service) Close() error { - log.Printf("Disconnected from database: %s", database) - return s.db.Close() +func (s *service) Stats() map[string]any { + st := s.pool.Stat() + return map[string]any{ + "total_conns": st.TotalConns(), + "idle_conns": st.IdleConns(), + "acquired_conns": st.AcquiredConns(), + "max_conns": st.MaxConns(), + } } -func (s *service) EnsureSchema() error { - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err +// mapErr turns driver errors into package sentinels. +func mapErr(err error) error { + if err == nil { + return nil } - defer tx.Rollback() - - // Create api_key table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS api_key ( - id uuid NOT NULL DEFAULT gen_random_uuid (), - key_id text NOT NULL UNIQUE, - key_hash text NOT NULL, - key_prefix text NOT NULL, - is_active boolean NULL DEFAULT true, - created_at timestamp with time zone NULL DEFAULT now(), - expires_at timestamp with time zone NULL, - rate_limit integer NULL DEFAULT 1000, - usage_count integer NULL DEFAULT 0, - PRIMARY KEY (id) - ); - - -- Add index on key_id for fast lookups - CREATE INDEX IF NOT EXISTS idx_key_id ON api_key (key_id);`) - if err != nil { - return err + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23505": + return ErrDuplicate + case "22P02": // invalid_text_representation, e.g. a bad uuid + return ErrNotFound + } } + return err +} - // Create api_usage table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS api_usage ( - id bigserial NOT NULL, - api_key_id uuid NULL, - endpoint text NULL, - used_at timestamp with time zone NULL DEFAULT now(), - status integer NULL, - remote_ip inet NULL, - PRIMARY KEY (id) - ); - `) - if err != nil { - return err +func nullable(s string) *string { + if s == "" { + return nil } + return &s +} - // Create audit_log table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS audit_log ( - id bigserial NOT NULL, - actor_id uuid NULL, - actor_type text NULL, - event_type text NULL, - event_data jsonb NULL, - event_time timestamp with time zone NULL DEFAULT now(), - remote_ip inet NULL, - PRIMARY KEY (id) - ); - `) - if err != nil { - return err +func jsonOrEmpty(raw json.RawMessage) []byte { + if len(raw) == 0 { + return []byte("{}") } + return raw +} - return tx.Commit() +// validIP returns ip if it parses, else "", so bad input never breaks an insert. +func validIP(ip string) string { + if net.ParseIP(ip) == nil { + return "" + } + return ip } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index c36b626..e250177 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -2,99 +2,133 @@ package database import ( "context" - "log" + "fmt" + "os" + "strings" "testing" "time" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/postgres" - "github.com/testcontainers/testcontainers-go/wait" + "github.com/jackc/pgx/v5" ) -func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { - var ( - dbName = "database" - dbPwd = "password" - dbUser = "user" - ) - - dbContainer, err := postgres.Run( - context.Background(), - "postgres:latest", - postgres.WithDatabase(dbName), - postgres.WithUsername(dbUser), - postgres.WithPassword(dbPwd), - testcontainers.WithWaitStrategy( - wait.ForLog("database system is ready to accept connections"). - WithOccurrence(2). - WithStartupTimeout(5*time.Second)), - ) +// freshSchema creates an isolated schema and returns a DSN pointing at it. +func freshSchema(t *testing.T) (string, *pgx.Conn, string) { + t.Helper() + dsn := os.Getenv("AUTHCELL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("AUTHCELL_TEST_DATABASE_URL not set") + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) if err != nil { - return nil, err + t.Fatal(err) + } + schema := fmt.Sprintf("authcell_dbtest_%d", time.Now().UnixNano()) + if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) } + t.Cleanup(func() { + _, _ = conn.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") + conn.Close(context.Background()) + }) + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + return dsn + sep + "search_path=" + schema, conn, schema +} - database = dbName - password = dbPwd - username = dbUser +func TestMigrationsAreIdempotent(t *testing.T) { + dsn, _, _ := freshSchema(t) + for i := 0; i < 2; i++ { + db, err := Connect(context.Background(), dsn, Options{}) + if err != nil { + t.Fatalf("connect #%d: %v", i, err) + } + db.Close() + } +} - dbHost, err := dbContainer.Host(context.Background()) +// TestUpgradeFromLegacySchema builds the schema the original AuthCell created, +// inserts a row the old code would have written, and checks it survives. +func TestUpgradeFromLegacySchema(t *testing.T) { + dsn, conn, schema := freshSchema(t) + ctx := context.Background() + _, err := conn.Exec(ctx, `SET search_path=`+schema+`;`+migrations[0]+` + CREATE INDEX IF NOT EXISTS idx_key_id ON api_key (key_id); + INSERT INTO api_key (key_id, key_hash, key_prefix, is_active, rate_limit, usage_count) + VALUES ('4d83e91f', '$2a$10$abcdefghijklmnopqrstuv', 'demo_', NULL, NULL, NULL); + INSERT INTO audit_log (actor_id, actor_type, event_type) VALUES (gen_random_uuid(), 'system', 'create_key'); + RESET search_path;`) if err != nil { - return dbContainer.Terminate, err + t.Fatal(err) } - dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp") + db, err := Connect(ctx, dsn, Options{}) if err != nil { - return dbContainer.Terminate, err + t.Fatalf("upgrade: %v", err) } + defer db.Close() - host = dbHost - port = dbPort.Port() - - return dbContainer.Terminate, err -} - -func TestMain(m *testing.M) { - teardown, err := mustStartPostgresContainer() + k, err := db.GetAPIKeyByKeyID(ctx, "4d83e91f") if err != nil { - log.Fatalf("could not start postgres container: %v", err) + t.Fatal(err) } - - m.Run() - - if teardown != nil && teardown(context.Background()) != nil { - log.Fatalf("could not teardown postgres container: %v", err) + if !k.IsActive || k.RateLimit != 0 || k.UsageCount != 0 || string(k.Metadata) != "{}" { + t.Fatalf("legacy row not normalized: %+v", k) } -} - -func TestNew(t *testing.T) { - srv := New() - if srv == nil { - t.Fatal("New() returned nil") + logs, err := db.ListAuditLog(ctx, AuditFilter{Limit: 10}) + if err != nil || len(logs) != 1 { + t.Fatalf("legacy audit rows: %v %d", err, len(logs)) } } -func TestHealth(t *testing.T) { - srv := New() - - stats := srv.Health() - - if stats["status"] != "up" { - t.Fatalf("expected status to be up, got %s", stats["status"]) +func TestRecordUseWindow(t *testing.T) { + dsn, _, _ := freshSchema(t) + ctx := context.Background() + db, err := Connect(ctx, dsn, Options{}) + if err != nil { + t.Fatal(err) } + defer db.Close() - if _, ok := stats["error"]; ok { - t.Fatalf("expected error not to be present") + k, err := db.CreateAPIKey(ctx, NewAPIKey{KeyID: "aaaaaaaaaaaaaaaa", KeyHash: "sha256:x", RateLimit: 2}) + if err != nil { + t.Fatal(err) + } + if _, err := db.CreateAPIKey(ctx, NewAPIKey{KeyID: "aaaaaaaaaaaaaaaa", KeyHash: "sha256:y"}); err != ErrDuplicate { + t.Fatalf("expected ErrDuplicate, got %v", err) } - if stats["message"] != "It's healthy" { - t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) + window := time.Second + for want := 1; want <= 3; want++ { + r, err := db.RecordUse(ctx, k.ID, window) + if err != nil { + t.Fatal(err) + } + if r.WindowCount != want { + t.Fatalf("window count %d, want %d", r.WindowCount, want) + } + } + time.Sleep(1100 * time.Millisecond) + r, err := db.RecordUse(ctx, k.ID, window) + if err != nil || r.WindowCount != 1 { + t.Fatalf("window did not reset: %+v %v", r, err) + } + got, _ := db.GetAPIKeyByID(ctx, k.ID) + if got.UsageCount != 4 || got.LastUsedAt == nil { + t.Fatalf("usage not tracked: %+v", got) } -} -func TestClose(t *testing.T) { - srv := New() + if _, err := db.GetAPIKeyByID(ctx, "not-a-uuid"); err != ErrNotFound { + t.Fatalf("bad uuid should be ErrNotFound, got %v", err) + } - if srv.Close() != nil { - t.Fatalf("expected Close() to return nil") + // Usage with no key and a junk IP must not error. + if err := db.TrackUsage(ctx, "", "/v1/keys/verify", "not-an-ip", 401); err != nil { + t.Fatal(err) + } + if n, err := db.PruneUsage(ctx, time.Now().Add(time.Hour)); err != nil || n != 1 { + t.Fatalf("prune: %d %v", n, err) } } diff --git a/internal/database/logs.go b/internal/database/logs.go new file mode 100644 index 0000000..637ca7b --- /dev/null +++ b/internal/database/logs.go @@ -0,0 +1,83 @@ +package database + +import ( + "context" + "encoding/json" + "time" +) + +// TrackUsage records one verification attempt. apiKeyID and ip may be empty. +func (s *service) TrackUsage(ctx context.Context, apiKeyID, endpoint, ip string, status int) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO api_usage (api_key_id, endpoint, remote_ip, status) + VALUES ($1::uuid, $2, $3::inet, $4)`, + nullable(apiKeyID), endpoint, nullable(validIP(ip)), status) + return err +} + +func (s *service) ListUsage(ctx context.Context, apiKeyID string, before int64, limit int) ([]APIUsage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, api_key_id::text, COALESCE(endpoint, ''), used_at, COALESCE(status, 0), host(remote_ip) + FROM api_usage + WHERE api_key_id = $1 AND ($2 = 0 OR id < $2) + ORDER BY id DESC LIMIT $3`, apiKeyID, before, limit) + if err != nil { + return nil, mapErr(err) + } + defer rows.Close() + + out := make([]APIUsage, 0, limit) + for rows.Next() { + var u APIUsage + if err := rows.Scan(&u.ID, &u.APIKeyID, &u.Endpoint, &u.UsedAt, &u.Status, &u.RemoteIP); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *service) PruneUsage(ctx context.Context, olderThan time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM api_usage WHERE used_at < $1`, olderThan) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +func (s *service) TrackAudit(ctx context.Context, actorType, actorID, eventType, ip string, data map[string]any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO audit_log (actor_type, actor_id, event_type, event_data, remote_ip) + VALUES ($1, $2, $3, $4, $5::inet)`, + actorType, nullable(actorID), eventType, payload, nullable(validIP(ip))) + return err +} + +func (s *service) ListAuditLog(ctx context.Context, f AuditFilter) ([]AuditLog, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, actor_id, COALESCE(actor_type, ''), COALESCE(event_type, ''), + COALESCE(event_data, '{}'::jsonb), event_time, host(remote_ip) + FROM audit_log + WHERE ($1 = '' OR event_type = $1) AND ($2 = 0 OR id < $2) + ORDER BY id DESC LIMIT $3`, f.EventType, f.Before, f.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]AuditLog, 0, f.Limit) + for rows.Next() { + var l AuditLog + var data []byte + if err := rows.Scan(&l.ID, &l.ActorID, &l.ActorType, &l.EventType, &data, &l.EventTime, &l.RemoteIP); err != nil { + return nil, err + } + l.EventData = data + out = append(out, l) + } + return out, rows.Err() +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..ea04efb --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,132 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// migrations run in order, once each, inside a transaction. Never edit a +// migration that has shipped; append a new one instead. +var migrations = []string{ + // 1: original schema. IF NOT EXISTS keeps it safe for databases created by + // earlier AuthCell versions that had no migration table. + ` + CREATE TABLE IF NOT EXISTS api_key ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + key_id text NOT NULL UNIQUE, + key_hash text NOT NULL, + key_prefix text NOT NULL, + is_active boolean NULL DEFAULT true, + created_at timestamptz NULL DEFAULT now(), + expires_at timestamptz NULL, + rate_limit integer NULL DEFAULT 1000, + usage_count integer NULL DEFAULT 0, + PRIMARY KEY (id) + ); + CREATE TABLE IF NOT EXISTS api_usage ( + id bigserial NOT NULL, + api_key_id uuid NULL, + endpoint text NULL, + used_at timestamptz NULL DEFAULT now(), + status integer NULL, + remote_ip inet NULL, + PRIMARY KEY (id) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id bigserial NOT NULL, + actor_id uuid NULL, + actor_type text NULL, + event_type text NULL, + event_data jsonb NULL, + event_time timestamptz NULL DEFAULT now(), + remote_ip inet NULL, + PRIMARY KEY (id) + ); + `, + + // 2: tighten nullability, add key metadata, rate-limit window, indexes. + ` + UPDATE api_key SET is_active = COALESCE(is_active, true), + created_at = COALESCE(created_at, now()), + rate_limit = COALESCE(rate_limit, 0), + usage_count = COALESCE(usage_count, 0); + ALTER TABLE api_key + ALTER COLUMN is_active SET NOT NULL, + ALTER COLUMN created_at SET NOT NULL, + ALTER COLUMN rate_limit SET NOT NULL, + ALTER COLUMN rate_limit SET DEFAULT 0, + ALTER COLUMN usage_count TYPE bigint, + ALTER COLUMN usage_count SET NOT NULL, + ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS revoked_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS last_used_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_start timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_count integer NOT NULL DEFAULT 0; + ALTER TABLE api_key ADD CONSTRAINT api_key_rate_limit_nonneg CHECK (rate_limit >= 0); + -- key_id already has a UNIQUE index; the extra one was redundant. + DROP INDEX IF EXISTS idx_key_id; + CREATE INDEX IF NOT EXISTS idx_api_key_created_at ON api_key (created_at DESC); + + UPDATE api_usage SET used_at = COALESCE(used_at, now()); + ALTER TABLE api_usage ALTER COLUMN used_at SET NOT NULL; + CREATE INDEX IF NOT EXISTS idx_api_usage_key_id ON api_usage (api_key_id, id DESC); + CREATE INDEX IF NOT EXISTS idx_api_usage_used_at ON api_usage (used_at); + + -- actor_id used to be a random uuid per event, which carried no meaning. + ALTER TABLE audit_log ALTER COLUMN actor_id TYPE text USING actor_id::text; + UPDATE audit_log SET event_time = COALESCE(event_time, now()); + ALTER TABLE audit_log ALTER COLUMN event_time SET NOT NULL; + CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log (event_type, id DESC); + `, +} + +// lockID is an arbitrary constant so concurrent instances don't race on migrations. +const lockID = 7_446_563_311 + +func migrate(ctx context.Context, pool *pgxpool.Pool) error { + conn, err := pool.Acquire(ctx) + if err != nil { + return err + } + defer conn.Release() + + if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, lockID); err != nil { + return err + } + defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, lockID) //nolint:errcheck + + if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version integer PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`); err != nil { + return err + } + + var current int + if err := conn.QueryRow(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(¤t); err != nil { + return err + } + + for i := current; i < len(migrations); i++ { + version := i + 1 + tx, err := conn.Begin(ctx) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, migrations[i]); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("migration %d: %w", version, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("record migration %d: %w", version, err) + } + if err := tx.Commit(ctx); err != nil { + return err + } + } + return nil +} diff --git a/internal/database/models.go b/internal/database/models.go index f4fa2f0..78864d8 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -2,28 +2,69 @@ package database import ( "encoding/json" + "errors" "time" ) +var ( + // ErrNotFound is returned when a row does not exist. + ErrNotFound = errors.New("not found") + // ErrDuplicate is returned when a unique constraint is violated. + ErrDuplicate = errors.New("duplicate") +) + +// APIKey is a stored key. KeyHash is never serialized. type APIKey struct { - ID string `json:"id"` - KeyHash string `json:"key_hash"` - KeyPrefix string `json:"key_prefix"` - KeyID string `json:"key_id"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt *time.Time `json:"expires_at"` - RateLimit int `json:"rate_limit"` - UsageCount int `json:"usage_count"` + ID string `json:"id"` + KeyID string `json:"key_id"` + KeyHash string `json:"-"` + KeyPrefix string `json:"key_prefix"` + Name string `json:"name"` + Metadata json.RawMessage `json:"metadata"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` + LastUsedAt *time.Time `json:"last_used_at"` + RateLimit int `json:"rate_limit"` + UsageCount int64 `json:"usage_count"` +} + +// NewAPIKey holds the fields needed to insert a key. +type NewAPIKey struct { + KeyID string + KeyHash string + KeyPrefix string + Name string + Metadata json.RawMessage + ExpiresAt *time.Time + RateLimit int +} + +// APIKeyUpdate holds optional changes. Nil fields are left alone. +type APIKeyUpdate struct { + Name *string + Metadata json.RawMessage + ExpiresAt *time.Time + ClearExpires bool + RateLimit *int +} + +// KeyFilter narrows ListAPIKeys. +type KeyFilter struct { + Active *bool + Prefix string + Limit int + Offset int } type APIUsage struct { ID int64 `json:"id"` - APIKeyID string `json:"api_key_id"` + APIKeyID *string `json:"api_key_id"` Endpoint string `json:"endpoint"` UsedAt time.Time `json:"used_at"` Status int `json:"status"` - RemoteIP string `json:"remote_ip"` + RemoteIP *string `json:"remote_ip"` } type AuditLog struct { @@ -33,5 +74,19 @@ type AuditLog struct { EventType string `json:"event_type"` EventData json.RawMessage `json:"event_data"` EventTime time.Time `json:"event_time"` - RemoteIP string `json:"remote_ip"` + RemoteIP *string `json:"remote_ip"` +} + +// AuditFilter narrows ListAuditLog. Before is an id cursor (exclusive). +type AuditFilter struct { + EventType string + Before int64 + Limit int +} + +// UseResult is the outcome of recording a successful verification. +type UseResult struct { + WindowCount int + RateLimit int + WindowReset time.Time } diff --git a/internal/server/handler/audit_handler.go b/internal/server/handler/audit_handler.go deleted file mode 100644 index 964c6ef..0000000 --- a/internal/server/handler/audit_handler.go +++ /dev/null @@ -1,17 +0,0 @@ -package handler - -import ( - "authcell/internal/app" - "authcell/internal/server/response" - - "github.com/gofiber/fiber/v2" -) - -func ListAuditLog(server *app.FiberServer, ctx *fiber.Ctx) error { - logs, err := server.Db.ListAuditLog() - if err != nil { - return response.Error(ctx, 500, "Failed to fetch audit logs", err) - } - - return response.Success(ctx, 200, logs) -} diff --git a/internal/server/handler/handler.go b/internal/server/handler/handler.go new file mode 100644 index 0000000..0af6782 --- /dev/null +++ b/internal/server/handler/handler.go @@ -0,0 +1,90 @@ +// Package handler contains the HTTP handlers for AuthCell. +package handler + +import ( + "context" + "log/slog" + "strconv" + "time" + + "authcell/internal/database" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +const ( + defaultPageSize = 50 + maxPageSize = 500 + + actorAdmin = "admin" + actorAPI = "api" +) + +// Handler holds dependencies shared by all endpoints. +type Handler struct { + DB database.Service + RateLimitWindow time.Duration + RequestTimeout time.Duration + Version string +} + +func New(db database.Service, rateWindow, requestTimeout time.Duration, version string) *Handler { + if rateWindow <= 0 { + rateWindow = time.Minute + } + if requestTimeout <= 0 { + requestTimeout = 10 * time.Second + } + return &Handler{DB: db, RateLimitWindow: rateWindow, RequestTimeout: requestTimeout, Version: version} +} + +// ctx returns a request-scoped context with the configured timeout. +func (h *Handler) ctx(c *fiber.Ctx) (context.Context, context.CancelFunc) { + return context.WithTimeout(c.UserContext(), h.RequestTimeout) +} + +// audit records an event without failing the request if logging fails. +func (h *Handler) audit(ctx context.Context, c *fiber.Ctx, actorType, actorID, event string, data map[string]any) { + if err := h.DB.TrackAudit(ctx, actorType, actorID, event, c.IP(), data); err != nil { + slog.Warn("audit write failed", "event", event, "error", err) + } +} + +func (h *Handler) usage(ctx context.Context, c *fiber.Ctx, apiKeyID string, status int) { + if err := h.DB.TrackUsage(ctx, apiKeyID, c.Path(), c.IP(), status); err != nil { + slog.Warn("usage write failed", "error", err) + } +} + +func validUUID(id string) bool { + _, err := uuid.Parse(id) + return err == nil +} + +func pageSize(c *fiber.Ctx) (int, bool) { + raw := c.Query("limit") + if raw == "" { + return defaultPageSize, true + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return 0, false + } + if n > maxPageSize { + n = maxPageSize + } + return n, true +} + +func nonNegInt64(c *fiber.Ctx, name string) (int64, bool) { + raw := c.Query(name) + if raw == "" { + return 0, true + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 0 { + return 0, false + } + return n, true +} diff --git a/internal/server/handler/health_handler.go b/internal/server/handler/health_handler.go deleted file mode 100644 index 04641b0..0000000 --- a/internal/server/handler/health_handler.go +++ /dev/null @@ -1,11 +0,0 @@ -package handler - -import ( - "authcell/internal/app" - - "github.com/gofiber/fiber/v2" -) - -func HealthHandler(server *app.FiberServer, ctx *fiber.Ctx) error { - return ctx.JSON(server.Db.Health()) -} diff --git a/internal/server/handler/key_handler.go b/internal/server/handler/key_handler.go deleted file mode 100644 index 02565be..0000000 --- a/internal/server/handler/key_handler.go +++ /dev/null @@ -1,188 +0,0 @@ -package handler - -import ( - "authcell/internal/app" - "authcell/internal/database" - "authcell/internal/server/response" - "crypto/rand" - "encoding/hex" - "fmt" - "strings" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" -) - -// Creates a cryptographically secure short identifier for keys -func generateShortKeyID(length int) string { - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - panic("failed to generate key_id: " + err.Error()) - } - return hex.EncodeToString(b)[:length] -} - -func ListAPIKeys(server *app.FiberServer, ctx *fiber.Ctx) error { - keys, err := server.Db.GetAllAPIKeys() - if err != nil { - return response.Error(ctx, 500, "Failed to retrieve API keys", err) - } - - // Hide sensitive data - safeKeys := make([]fiber.Map, 0, len(keys)) - for _, k := range keys { - safeKeys = append(safeKeys, fiber.Map{ - "id": k.ID, - "key_id": k.KeyID, - "key_prefix": k.KeyPrefix, - "is_active": k.IsActive, - "created_at": k.CreatedAt, - "expires_at": k.ExpiresAt, - "rate_limit": k.RateLimit, - "usage_count": k.UsageCount, - }) - } - - return response.Success(ctx, 200, safeKeys) -} - -func CreateAPIKey(server *app.FiberServer, ctx *fiber.Ctx) error { - var req struct { - KeyPrefix string `json:"key_prefix"` - ExpiresAt *time.Time `json:"expires_at"` - RateLimit int `json:"rate_limit"` - } - - if err := ctx.BodyParser(&req); err != nil { - return response.Error(ctx, 400, "Invalid request body", err) - } - - // Generate a short key_id for fast database lookup - // 8 chars ~ 32 bits of randomness - keyID := generateShortKeyID(8) - - // Build the human-visible API key - fullKey := fmt.Sprintf("%s%s_%s", req.KeyPrefix, keyID, uuid.New().String()) - - // Hash full key for secure storage - hashed, _ := bcrypt.GenerateFromPassword([]byte(fullKey), bcrypt.DefaultCost) - - // Create the DB record - apiKey := database.APIKey{ - ID: uuid.New().String(), - KeyID: keyID, - KeyHash: string(hashed), - KeyPrefix: req.KeyPrefix, - IsActive: true, - CreatedAt: time.Now().UTC(), - ExpiresAt: req.ExpiresAt, - RateLimit: req.RateLimit, - UsageCount: 0, - } - - // Store the key - if err := server.Db.CreateAPIKey(apiKey); err != nil { - return response.Error(ctx, 500, "Failed to create API key", err) - } - - // Audit log for tracking - server.Db.TrackAudit("system", "create_key", ctx.IP(), map[string]any{ - "key_record_id": apiKey.ID, - "key_id": apiKey.KeyID, - "prefix": apiKey.KeyPrefix, - }) - - // Respond with non-sensitive details - return response.Success(ctx, 201, fiber.Map{ - // Visible only once to client - "api_key": fullKey, - "id": apiKey.ID, - "key_id": apiKey.KeyID, - }) -} - -func GetAPIKey(server *app.FiberServer, ctx *fiber.Ctx) error { - id := ctx.Params("id") - - apiKey, err := server.Db.GetAPIKeyByID(id) - - if err != nil { - return response.Error(ctx, 404, "API key not found", err) - } - - return response.Success(ctx, 200, apiKey) -} - -func VerifyAPIKey(server *app.FiberServer, ctx *fiber.Ctx) error { - var req struct { - ApiKey string `json:"api_key"` - } - - if err := ctx.BodyParser(&req); err != nil { - return response.Error(ctx, 400, "Invalid request body", err) - } - - apiKeyStr := strings.TrimSpace(req.ApiKey) - if len(apiKeyStr) < 8 { - return response.Error(ctx, 400, "Malformed API key", nil) - } - - // Extract key structure - parts := strings.SplitN(apiKeyStr, "_", 3) - if len(parts) < 3 { - return response.Error(ctx, 400, "Invalid API key format", nil) - } - - prefix := parts[0] + "_" - keyID := parts[1] - - // Lookup key by its unique key_id - apiKey, err := server.Db.GetAPIKeyByKeyID(keyID) - if err != nil { - server.Db.TrackUsage("", ctx.Path(), ctx.IP(), 401) - return response.Error(ctx, 401, "API key not recognized", nil) - } - - // Verify prefix consistency for good measure - if apiKey.KeyPrefix != prefix { - server.Db.TrackUsage(apiKey.ID, ctx.Path(), ctx.IP(), 401) - return response.Error(ctx, 401, "API key prefix mismatch", nil) - } - - // Validate bcrypt hash against supplied key - if err := bcrypt.CompareHashAndPassword([]byte(apiKey.KeyHash), []byte(apiKeyStr)); err != nil { - server.Db.TrackUsage(apiKey.ID, ctx.Path(), ctx.IP(), 401) - return response.Error(ctx, 401, "Invalid API key", nil) - } - - // Check if active and not expired - if !apiKey.IsActive || (apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now())) { - server.Db.TrackUsage(apiKey.ID, ctx.Path(), ctx.IP(), 401) - return response.Error(ctx, 401, "API key expired or inactive", nil) - } - - // Log audit & usage - server.Db.TrackUsage(apiKey.ID, ctx.Path(), ctx.IP(), 200) - server.Db.TrackAudit("api", "verify_key", ctx.IP(), map[string]any{ - "key_record_id": apiKey.ID, - "key_id": apiKey.KeyID, - "prefix": apiKey.KeyPrefix, - "endpoint": ctx.Path(), - }) - - return response.Success(ctx, 200, fiber.Map{ - "valid": true, - }) -} - -func RevokeAPIKey(s *app.FiberServer, c *fiber.Ctx) error { - id := c.Params("id") - if err := s.Db.RevokeAPIKey(id); err != nil { - return response.Error(c, 404, "API key not found", err) - } - - s.Db.TrackAudit("system", "revoke_key", c.IP(), map[string]any{"key_id": id}) - return response.Success(c, 200, nil) -} diff --git a/internal/server/handler/keys.go b/internal/server/handler/keys.go new file mode 100644 index 0000000..29932cc --- /dev/null +++ b/internal/server/handler/keys.go @@ -0,0 +1,300 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "authcell/internal/database" + "authcell/internal/keys" + "authcell/internal/server/response" + + "github.com/gofiber/fiber/v2" +) + +const ( + maxNameLen = 200 + maxMetadataLen = 8 * 1024 + createAttempts = 3 +) + +type createKeyRequest struct { + KeyPrefix string `json:"key_prefix"` + Name string `json:"name"` + Metadata json.RawMessage `json:"metadata"` + ExpiresAt *time.Time `json:"expires_at"` + RateLimit *int `json:"rate_limit"` +} + +// createdKey is returned once, on create and rotate. It is the only time the +// plaintext key is ever available. +type createdKey struct { + database.APIKey + Plaintext string `json:"api_key"` +} + +func validateMetadata(raw json.RawMessage) (json.RawMessage, string) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, "" + } + if len(raw) > maxMetadataLen { + return nil, "metadata must be at most 8KB" + } + if raw[0] != '{' { + return nil, "metadata must be a JSON object" + } + return raw, "" +} + +// CreateAPIKey handles POST /v1/keys. +func (h *Handler) CreateAPIKey(c *fiber.Ctx) error { + var req createKeyRequest + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return response.Error(c, fiber.StatusBadRequest, "Invalid request body") + } + } + + prefix, err := keys.NormalizePrefix(req.KeyPrefix) + if err != nil { + return response.Error(c, fiber.StatusBadRequest, err.Error()) + } + req.Name = strings.TrimSpace(req.Name) + if len(req.Name) > maxNameLen { + return response.Error(c, fiber.StatusBadRequest, "name must be at most 200 characters") + } + meta, msg := validateMetadata(req.Metadata) + if msg != "" { + return response.Error(c, fiber.StatusBadRequest, msg) + } + if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) { + return response.Error(c, fiber.StatusBadRequest, "expires_at must be in the future") + } + rate := 0 + if req.RateLimit != nil { + if *req.RateLimit < 0 { + return response.Error(c, fiber.StatusBadRequest, "rate_limit cannot be negative (0 means unlimited)") + } + rate = *req.RateLimit + } + + ctx, cancel := h.ctx(c) + defer cancel() + + created, plain, err := h.insertKey(c, database.NewAPIKey{ + KeyPrefix: prefix, Name: req.Name, Metadata: meta, ExpiresAt: req.ExpiresAt, RateLimit: rate, + }) + if err != nil { + return response.Internal(c, "Failed to create API key", err) + } + + h.audit(ctx, c, actorAdmin, "", "create_key", map[string]any{ + "key_record_id": created.ID, "key_id": created.KeyID, "prefix": created.KeyPrefix, "name": created.Name, + }) + return response.Success(c, fiber.StatusCreated, createdKey{APIKey: created, Plaintext: plain}) +} + +// insertKey generates a key and stores it, retrying on the (astronomically +// unlikely) event of a key_id collision. +func (h *Handler) insertKey(c *fiber.Ctx, n database.NewAPIKey) (database.APIKey, string, error) { + ctx, cancel := h.ctx(c) + defer cancel() + var lastErr error + for i := 0; i < createAttempts; i++ { + gen, err := keys.Generate(n.KeyPrefix) + if err != nil { + return database.APIKey{}, "", err + } + n.KeyID, n.KeyHash = gen.KeyID, gen.Hash + k, err := h.DB.CreateAPIKey(ctx, n) + if err == nil { + return k, gen.Plaintext, nil + } + if !errors.Is(err, database.ErrDuplicate) { + return database.APIKey{}, "", err + } + lastErr = err + } + return database.APIKey{}, "", lastErr +} + +// ListAPIKeys handles GET /v1/keys?limit=&offset=&active=&prefix=. +func (h *Handler) ListAPIKeys(c *fiber.Ctx) error { + limit, ok := pageSize(c) + if !ok { + return response.Error(c, fiber.StatusBadRequest, "limit must be a positive integer") + } + offset, ok := nonNegInt64(c, "offset") + if !ok { + return response.Error(c, fiber.StatusBadRequest, "offset must be a non-negative integer") + } + f := database.KeyFilter{Limit: limit, Offset: int(offset), Prefix: strings.TrimSpace(c.Query("prefix"))} + if raw := c.Query("active"); raw != "" { + b, err := strconv.ParseBool(raw) + if err != nil { + return response.Error(c, fiber.StatusBadRequest, "active must be true or false") + } + f.Active = &b + } + + ctx, cancel := h.ctx(c) + defer cancel() + list, total, err := h.DB.ListAPIKeys(ctx, f) + if err != nil { + return response.Internal(c, "Failed to list API keys", err) + } + return response.List(c, list, fiber.Map{"total": total, "limit": limit, "offset": offset}) +} + +// GetAPIKey handles GET /v1/keys/:id. +func (h *Handler) GetAPIKey(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return response.Error(c, fiber.StatusBadRequest, "id must be a UUID") + } + ctx, cancel := h.ctx(c) + defer cancel() + k, err := h.DB.GetAPIKeyByID(ctx, id) + if errors.Is(err, database.ErrNotFound) { + return response.Error(c, fiber.StatusNotFound, "API key not found") + } + if err != nil { + return response.Internal(c, "Failed to fetch API key", err) + } + return response.Success(c, fiber.StatusOK, k) +} + +type updateKeyRequest struct { + Name *string `json:"name"` + Metadata json.RawMessage `json:"metadata"` + ExpiresAt json.RawMessage `json:"expires_at"` + RateLimit *int `json:"rate_limit"` +} + +// UpdateAPIKey handles PATCH /v1/keys/:id. Send "expires_at": null to remove expiry. +func (h *Handler) UpdateAPIKey(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return response.Error(c, fiber.StatusBadRequest, "id must be a UUID") + } + var req updateKeyRequest + if err := c.BodyParser(&req); err != nil { + return response.Error(c, fiber.StatusBadRequest, "Invalid request body") + } + + var u database.APIKeyUpdate + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if len(name) > maxNameLen { + return response.Error(c, fiber.StatusBadRequest, "name must be at most 200 characters") + } + u.Name = &name + } + if len(req.Metadata) > 0 { + meta, msg := validateMetadata(req.Metadata) + if msg != "" { + return response.Error(c, fiber.StatusBadRequest, msg) + } + if meta == nil { + meta = json.RawMessage("{}") + } + u.Metadata = meta + } + if raw := bytes.TrimSpace(req.ExpiresAt); len(raw) > 0 { + if bytes.Equal(raw, []byte("null")) { + u.ClearExpires = true + } else { + var t time.Time + if err := json.Unmarshal(raw, &t); err != nil { + return response.Error(c, fiber.StatusBadRequest, "expires_at must be an RFC 3339 timestamp or null") + } + if !t.After(time.Now()) { + return response.Error(c, fiber.StatusBadRequest, "expires_at must be in the future") + } + u.ExpiresAt = &t + } + } + if req.RateLimit != nil { + if *req.RateLimit < 0 { + return response.Error(c, fiber.StatusBadRequest, "rate_limit cannot be negative (0 means unlimited)") + } + u.RateLimit = req.RateLimit + } + + ctx, cancel := h.ctx(c) + defer cancel() + k, err := h.DB.UpdateAPIKey(ctx, id, u) + if errors.Is(err, database.ErrNotFound) { + return response.Error(c, fiber.StatusNotFound, "API key not found") + } + if err != nil { + return response.Internal(c, "Failed to update API key", err) + } + h.audit(ctx, c, actorAdmin, "", "update_key", map[string]any{"key_record_id": k.ID, "key_id": k.KeyID}) + return response.Success(c, fiber.StatusOK, k) +} + +// RevokeAPIKey handles DELETE /v1/keys/:id. +func (h *Handler) RevokeAPIKey(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return response.Error(c, fiber.StatusBadRequest, "id must be a UUID") + } + ctx, cancel := h.ctx(c) + defer cancel() + k, err := h.DB.RevokeAPIKey(ctx, id) + if errors.Is(err, database.ErrNotFound) { + return response.Error(c, fiber.StatusNotFound, "API key not found") + } + if err != nil { + return response.Internal(c, "Failed to revoke API key", err) + } + h.audit(ctx, c, actorAdmin, "", "revoke_key", map[string]any{"key_record_id": k.ID, "key_id": k.KeyID}) + return response.Success(c, fiber.StatusOK, k) +} + +// RotateAPIKey handles POST /v1/keys/:id/rotate. It issues a new key with the +// same settings and revokes the old one. +func (h *Handler) RotateAPIKey(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return response.Error(c, fiber.StatusBadRequest, "id must be a UUID") + } + ctx, cancel := h.ctx(c) + defer cancel() + + old, err := h.DB.GetAPIKeyByID(ctx, id) + if errors.Is(err, database.ErrNotFound) { + return response.Error(c, fiber.StatusNotFound, "API key not found") + } + if err != nil { + return response.Internal(c, "Failed to fetch API key", err) + } + if !old.IsActive { + return response.Error(c, fiber.StatusConflict, "Cannot rotate a revoked key") + } + expires := old.ExpiresAt + if expires != nil && !expires.After(time.Now()) { + expires = nil + } + + created, plain, err := h.insertKey(c, database.NewAPIKey{ + KeyPrefix: strings.TrimRight(old.KeyPrefix, "_"), Name: old.Name, Metadata: old.Metadata, + ExpiresAt: expires, RateLimit: old.RateLimit, + }) + if err != nil { + return response.Internal(c, "Failed to create replacement key", err) + } + if _, err := h.DB.RevokeAPIKey(ctx, old.ID); err != nil { + return response.Internal(c, "Created replacement key but failed to revoke the old one", err) + } + + h.audit(ctx, c, actorAdmin, "", "rotate_key", map[string]any{ + "old_key_record_id": old.ID, "new_key_record_id": created.ID, "key_id": created.KeyID, + }) + return response.Success(c, fiber.StatusCreated, createdKey{APIKey: created, Plaintext: plain}) +} diff --git a/internal/server/handler/logs.go b/internal/server/handler/logs.go new file mode 100644 index 0000000..4be53f2 --- /dev/null +++ b/internal/server/handler/logs.go @@ -0,0 +1,81 @@ +package handler + +import ( + "context" + "strings" + "time" + + "authcell/internal/database" + "authcell/internal/server/response" + + "github.com/gofiber/fiber/v2" +) + +// ListAPIUsage handles GET /v1/usage/:id?limit=&before=. +func (h *Handler) ListAPIUsage(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return response.Error(c, fiber.StatusBadRequest, "id must be a UUID") + } + limit, ok := pageSize(c) + if !ok { + return response.Error(c, fiber.StatusBadRequest, "limit must be a positive integer") + } + before, ok := nonNegInt64(c, "before") + if !ok { + return response.Error(c, fiber.StatusBadRequest, "before must be a non-negative integer") + } + + ctx, cancel := h.ctx(c) + defer cancel() + rows, err := h.DB.ListUsage(ctx, id, before, limit) + if err != nil { + return response.Internal(c, "Failed to fetch usage", err) + } + return response.List(c, rows, cursorMeta(len(rows), limit, func() int64 { return rows[len(rows)-1].ID })) +} + +// ListAuditLog handles GET /v1/audit?limit=&before=&event_type=. +func (h *Handler) ListAuditLog(c *fiber.Ctx) error { + limit, ok := pageSize(c) + if !ok { + return response.Error(c, fiber.StatusBadRequest, "limit must be a positive integer") + } + before, ok := nonNegInt64(c, "before") + if !ok { + return response.Error(c, fiber.StatusBadRequest, "before must be a non-negative integer") + } + + ctx, cancel := h.ctx(c) + defer cancel() + rows, err := h.DB.ListAuditLog(ctx, database.AuditFilter{ + EventType: strings.TrimSpace(c.Query("event_type")), Before: before, Limit: limit, + }) + if err != nil { + return response.Internal(c, "Failed to fetch audit log", err) + } + return response.List(c, rows, cursorMeta(len(rows), limit, func() int64 { return rows[len(rows)-1].ID })) +} + +// cursorMeta returns next_before only when a full page came back. +func cursorMeta(n, limit int, lastID func() int64) fiber.Map { + meta := fiber.Map{"limit": limit, "next_before": nil} + if n == limit && n > 0 { + meta["next_before"] = lastID() + } + return meta +} + +// Health handles GET /v1/health. It returns 503 when the database is down +// instead of crashing the process. +func (h *Handler) Health(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.UserContext(), 2*time.Second) + defer cancel() + if err := h.DB.Ping(ctx); err != nil { + return response.ErrorWithData(c, fiber.StatusServiceUnavailable, "Database unreachable", + fiber.Map{"status": "down", "version": h.Version}) + } + return response.Success(c, fiber.StatusOK, fiber.Map{ + "status": "up", "version": h.Version, "database": h.DB.Stats(), + }) +} diff --git a/internal/server/handler/usage_handler.go b/internal/server/handler/usage_handler.go deleted file mode 100644 index 32c6a12..0000000 --- a/internal/server/handler/usage_handler.go +++ /dev/null @@ -1,19 +0,0 @@ -package handler - -import ( - "authcell/internal/app" - "authcell/internal/server/response" - - "github.com/gofiber/fiber/v2" -) - -func ListAPIUsage(server *app.FiberServer, ctx *fiber.Ctx) error { - id := ctx.Params("id") - - usages, err := server.Db.ListUsage(id) - if err != nil { - return response.Error(ctx, 404, "No usage records found for the given key ID", err) - } - - return response.Success(ctx, 200, usages) -} diff --git a/internal/server/handler/verify.go b/internal/server/handler/verify.go new file mode 100644 index 0000000..80418f8 --- /dev/null +++ b/internal/server/handler/verify.go @@ -0,0 +1,148 @@ +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "strconv" + "strings" + "time" + + "authcell/internal/database" + "authcell/internal/keys" + "authcell/internal/server/response" + + "github.com/gofiber/fiber/v2" +) + +// Verification failure reasons. "invalid" covers every case where the caller +// did not prove they hold the key, so nothing leaks about which keys exist. +const ( + ReasonInvalid = "invalid" + ReasonRevoked = "revoked" + ReasonExpired = "expired" + ReasonRateLimited = "rate_limited" +) + +type verifyFailure struct { + Valid bool `json:"valid"` + Reason string `json:"reason"` +} + +type rateInfo struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset time.Time `json:"reset"` +} + +type verifySuccess struct { + Valid bool `json:"valid"` + ID string `json:"id"` + KeyID string `json:"key_id"` + KeyPrefix string `json:"key_prefix"` + Name string `json:"name"` + Metadata json.RawMessage `json:"metadata"` + ExpiresAt *time.Time `json:"expires_at"` + RateLimit *rateInfo `json:"rate_limit"` +} + +// VerifyAPIKey handles POST /v1/keys/verify. The key may be sent as +// {"api_key": "..."} or in the X-API-Key header. +func (h *Handler) VerifyAPIKey(c *fiber.Ctx) error { + var req struct { + APIKey string `json:"api_key"` + } + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return response.Error(c, fiber.StatusBadRequest, "Invalid request body") + } + } + presented := strings.TrimSpace(req.APIKey) + if presented == "" { + presented = strings.TrimSpace(c.Get("X-API-Key")) + } + if presented == "" { + return response.Error(c, fiber.StatusBadRequest, "api_key is required") + } + + ctx, cancel := h.ctx(c) + defer cancel() + + fail := func(keyRecordID string, status int, reason string) error { + h.usage(ctx, c, keyRecordID, status) + msg := "Invalid API key" + switch reason { + case ReasonRevoked: + msg = "API key has been revoked" + case ReasonExpired: + msg = "API key has expired" + case ReasonRateLimited: + msg = "Rate limit exceeded" + } + return response.ErrorWithData(c, status, msg, verifyFailure{Valid: false, Reason: reason}) + } + + parsed, err := keys.Parse(presented) + if err != nil { + return fail("", fiber.StatusUnauthorized, ReasonInvalid) + } + + k, err := h.DB.GetAPIKeyByKeyID(ctx, parsed.KeyID) + if errors.Is(err, database.ErrNotFound) { + // Hash anyway so a miss costs about the same as a wrong secret. + _ = keys.Hash(presented) + return fail("", fiber.StatusUnauthorized, ReasonInvalid) + } + if err != nil { + return response.Internal(c, "Failed to verify API key", err) + } + + ok, legacy := keys.Compare(k.KeyHash, presented) + if !ok || !keys.PrefixMatches(parsed.Prefix, k.KeyPrefix) { + return fail(k.ID, fiber.StatusUnauthorized, ReasonInvalid) + } + if legacy { + // Move bcrypt hashes from older versions to the fast format. + if err := h.DB.UpgradeKeyHash(ctx, k.ID, keys.Hash(presented)); err != nil { + slog.Warn("hash upgrade failed", "key_id", k.KeyID, "error", err) + } + } + + if !k.IsActive { + return fail(k.ID, fiber.StatusUnauthorized, ReasonRevoked) + } + if k.ExpiresAt != nil && !k.ExpiresAt.After(time.Now()) { + return fail(k.ID, fiber.StatusUnauthorized, ReasonExpired) + } + + use, err := h.DB.RecordUse(ctx, k.ID, h.RateLimitWindow) + if err != nil { + return response.Internal(c, "Failed to record key usage", err) + } + + var rate *rateInfo + if use.RateLimit > 0 { + remaining := use.RateLimit - use.WindowCount + if remaining < 0 { + remaining = 0 + } + rate = &rateInfo{Limit: use.RateLimit, Remaining: remaining, Reset: use.WindowReset.UTC()} + c.Set("X-RateLimit-Limit", strconv.Itoa(use.RateLimit)) + c.Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) + c.Set("X-RateLimit-Reset", strconv.FormatInt(use.WindowReset.Unix(), 10)) + if use.WindowCount > use.RateLimit { + retry := int(time.Until(use.WindowReset).Seconds()) + 1 + if retry < 1 { + retry = 1 + } + c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retry)) + return fail(k.ID, fiber.StatusTooManyRequests, ReasonRateLimited) + } + } + + h.usage(ctx, c, k.ID, fiber.StatusOK) + return response.Success(c, fiber.StatusOK, verifySuccess{ + Valid: true, ID: k.ID, KeyID: k.KeyID, KeyPrefix: strings.TrimRight(k.KeyPrefix, "_"), + Name: k.Name, Metadata: k.Metadata, ExpiresAt: k.ExpiresAt, RateLimit: rate, + }) +} diff --git a/internal/server/response/response.go b/internal/server/response/response.go index 20bbea9..e1297a3 100644 --- a/internal/server/response/response.go +++ b/internal/server/response/response.go @@ -1,6 +1,8 @@ +// Package response defines the JSON envelope every AuthCell endpoint returns. package response import ( + "log/slog" "time" "github.com/gofiber/fiber/v2" @@ -11,11 +13,12 @@ type Response struct { Code int `json:"code"` Message string `json:"message,omitempty"` Data any `json:"data,omitempty"` + Meta any `json:"meta,omitempty"` Error any `json:"error,omitempty"` TimeStamp int64 `json:"timestamp"` } -// Success — standard success response +// Success writes a success envelope. func Success(c *fiber.Ctx, code int, data any) error { return c.Status(code).JSON(Response{ Status: "success", @@ -25,17 +28,36 @@ func Success(c *fiber.Ctx, code int, data any) error { }) } -// Error — standard error handler -func Error(c *fiber.Ctx, code int, message string, err error) error { - var errorMessage any - if err != nil { - errorMessage = err.Error() - } +// List writes a success envelope with pagination metadata. +func List(c *fiber.Ctx, data any, meta any) error { + return c.Status(fiber.StatusOK).JSON(Response{ + Status: "success", + Code: fiber.StatusOK, + Data: data, + Meta: meta, + TimeStamp: time.Now().Unix(), + }) +} + +// Error writes an error envelope. message is shown to clients, so it must not +// contain internal details. +func Error(c *fiber.Ctx, code int, message string) error { + return ErrorWithData(c, code, message, nil) +} + +// ErrorWithData writes an error envelope that also carries a data payload. +func ErrorWithData(c *fiber.Ctx, code int, message string, data any) error { return c.Status(code).JSON(Response{ Status: "error", Code: code, Message: message, - Error: errorMessage, + Data: data, TimeStamp: time.Now().Unix(), }) } + +// Internal logs the real error server side and returns a generic 500. +func Internal(c *fiber.Ctx, msg string, err error) error { + slog.Error(msg, "error", err, "path", c.Path(), "request_id", c.Locals("requestid")) + return Error(c, fiber.StatusInternalServerError, msg) +} diff --git a/internal/server/routes.go b/internal/server/routes.go deleted file mode 100644 index 4c86ab1..0000000 --- a/internal/server/routes.go +++ /dev/null @@ -1,41 +0,0 @@ -package server - -import ( - "authcell/internal/app" - "authcell/internal/server/handler" - "encoding/json" - "fmt" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" -) - -func RegisterFiberRoutes(server *app.FiberServer) { - server.App.Use(cors.New(cors.Config{ - AllowOrigins: "*", - AllowMethods: "GET,POST,PUT,DELETE,OPTIONS,PATCH", - AllowHeaders: "Accept,Authorization,Content-Type", - MaxAge: 300, - })) - - v1 := server.App.Group("/v1") - - v1.Get("/health", func(ctx *fiber.Ctx) error { - return handler.HealthHandler(server, ctx) - }) - - keys := v1.Group("/keys") - keys.Post("/", func(ctx *fiber.Ctx) error { return handler.CreateAPIKey(server, ctx) }) - keys.Get("/:id", func(ctx *fiber.Ctx) error { return handler.GetAPIKey(server, ctx) }) - keys.Post("/verify", func(ctx *fiber.Ctx) error { return handler.VerifyAPIKey(server, ctx) }) - keys.Delete("/:id", func(ctx *fiber.Ctx) error { return handler.RevokeAPIKey(server, ctx) }) - - usage := v1.Group("/usage") - usage.Get("/:id", func(ctx *fiber.Ctx) error { return handler.ListAPIUsage(server, ctx) }) - - audit := v1.Group("/audit") - audit.Get("/", func(ctx *fiber.Ctx) error { return handler.ListAuditLog(server, ctx) }) - - stack, _ := json.MarshalIndent(server.App.Stack(), "", " ") - fmt.Println(string(stack)) -} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go deleted file mode 100644 index 92ad8b0..0000000 --- a/internal/server/routes_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package server - -import ( - "testing" -) - -func TestHandler(t *testing.T) { - -} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..28fa803 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,159 @@ +// Package server wires middleware and routes into a Fiber app. +package server + +import ( + "crypto/sha256" + "crypto/subtle" + "errors" + "log/slog" + "strings" + "time" + + "authcell/internal/config" + "authcell/internal/database" + "authcell/internal/server/handler" + "authcell/internal/server/response" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" +) + +const bodyLimit = 64 * 1024 + +// New builds the HTTP app. +func New(cfg config.Config, db database.Service, version string) *fiber.App { + fcfg := fiber.Config{ + AppName: "authcell", + BodyLimit: bodyLimit, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + DisableStartupMessage: true, + ErrorHandler: errorHandler, + } + if len(cfg.TrustedProxies) > 0 { + fcfg.ProxyHeader = fiber.HeaderXForwardedFor + fcfg.EnableTrustedProxyCheck = true + fcfg.TrustedProxies = cfg.TrustedProxies + } + app := fiber.New(fcfg) + + app.Use(recover.New()) + app.Use(requestid.New()) + app.Use(requestLogger) + app.Use(securityHeaders) + if cfg.CORSOrigins != "" { + app.Use(cors.New(cors.Config{ + AllowOrigins: cfg.CORSOrigins, + AllowMethods: "GET,POST,PATCH,DELETE,OPTIONS", + AllowHeaders: "Accept,Authorization,Content-Type,X-API-Key", + MaxAge: 300, + })) + } + + h := handler.New(db, cfg.RateLimitWindow, cfg.RequestTimeout, version) + admin := bearerAuth(cfg.AdminToken) + + v1 := app.Group("/v1") + v1.Get("/health", h.Health) + + // Verify is the hot path called by your services. It is open unless + // AUTHCELL_VERIFY_TOKEN is set; the admin token is also accepted. + verifyAuth := func(c *fiber.Ctx) error { return c.Next() } + if cfg.VerifyToken != "" { + verifyAuth = bearerAuth(cfg.VerifyToken, cfg.AdminToken) + } + v1.Post("/keys/verify", verifyAuth, h.VerifyAPIKey) + + keys := v1.Group("/keys", admin) + keys.Post("/", h.CreateAPIKey) + keys.Get("/", h.ListAPIKeys) + keys.Get("/:id", h.GetAPIKey) + keys.Patch("/:id", h.UpdateAPIKey) + keys.Delete("/:id", h.RevokeAPIKey) + keys.Post("/:id/rotate", h.RotateAPIKey) + + v1.Get("/usage/:id", admin, h.ListAPIUsage) + v1.Get("/audit", admin, h.ListAuditLog) + + app.Use(func(c *fiber.Ctx) error { + return response.Error(c, fiber.StatusNotFound, "Route not found") + }) + return app +} + +// bearerAuth accepts "Authorization: Bearer " matching any of tokens. +// Tokens are hashed before comparison so length differences don't leak. +func bearerAuth(tokens ...string) fiber.Handler { + var digests [][32]byte + for _, t := range tokens { + if t != "" { + digests = append(digests, sha256.Sum256([]byte(t))) + } + } + return func(c *fiber.Ctx) error { + auth := c.Get(fiber.HeaderAuthorization) + const scheme = "bearer " + if len(auth) <= len(scheme) || !strings.EqualFold(auth[:len(scheme)], scheme) { + c.Set(fiber.HeaderWWWAuthenticate, `Bearer realm="authcell"`) + return response.Error(c, fiber.StatusUnauthorized, "Missing bearer token") + } + got := sha256.Sum256([]byte(strings.TrimSpace(auth[len(scheme):]))) + match := 0 + for _, d := range digests { + match |= subtle.ConstantTimeCompare(got[:], d[:]) + } + if match != 1 { + return response.Error(c, fiber.StatusUnauthorized, "Invalid bearer token") + } + return c.Next() + } +} + +func securityHeaders(c *fiber.Ctx) error { + c.Set("X-Content-Type-Options", "nosniff") + c.Set("Cache-Control", "no-store") + c.Set("Referrer-Policy", "no-referrer") + return c.Next() +} + +func requestLogger(c *fiber.Ctx) error { + start := time.Now() + err := c.Next() + if err != nil { + // Let the error handler write the response so the logged status is right. + if hErr := c.App().ErrorHandler(c, err); hErr != nil { + _ = c.SendStatus(fiber.StatusInternalServerError) + } + err = nil + } + status := c.Response().StatusCode() + level := slog.LevelInfo + if status >= 500 { + level = slog.LevelError + } + slog.Log(c.UserContext(), level, "request", + "method", c.Method(), + "path", c.Path(), + "status", status, + "duration_ms", time.Since(start).Milliseconds(), + "ip", c.IP(), + "request_id", c.Locals("requestid"), + ) + return err +} + +func errorHandler(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + msg := "Internal server error" + var fe *fiber.Error + if errors.As(err, &fe) { + code = fe.Code + msg = fe.Message + } else { + slog.Error("unhandled error", "error", err, "path", c.Path()) + } + return response.Error(c, code, msg) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..7189a9f --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,335 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "authcell/internal/config" + "authcell/internal/database" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +const ( + adminToken = "admin-token-0123456789abcdef0123456789" + verifyToken = "verify-token-0123456789abcdef012345678" +) + +// stubDB satisfies database.Service for tests that never reach storage. +type stubDB struct{ database.Service } + +func (stubDB) Ping(context.Context) error { return nil } +func (stubDB) Stats() map[string]any { return map[string]any{} } + +func testConfig() config.Config { + return config.Config{ + Port: 8080, AdminToken: adminToken, RateLimitWindow: time.Minute, RequestTimeout: 5 * time.Second, + } +} + +type envelope struct { + Status string `json:"status"` + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + Meta json.RawMessage `json:"meta"` +} + +func do(t *testing.T, app *fiber.App, method, path, token string, body any) (int, envelope, http.Header) { + t.Helper() + var r io.Reader + if body != nil { + b, _ := json.Marshal(body) + r = bytes.NewReader(b) + } + req := httptest.NewRequest(method, path, r) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := app.Test(req, 10_000) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("%s %s: non-JSON response %q", method, path, raw) + } + return resp.StatusCode, env, resp.Header +} + +func TestManagementRequiresAdminToken(t *testing.T) { + app := New(testConfig(), stubDB{}, "test") + routes := []struct{ method, path string }{ + {"POST", "/v1/keys"}, + {"GET", "/v1/keys"}, + {"GET", "/v1/keys/00000000-0000-0000-0000-000000000000"}, + {"PATCH", "/v1/keys/00000000-0000-0000-0000-000000000000"}, + {"DELETE", "/v1/keys/00000000-0000-0000-0000-000000000000"}, + {"POST", "/v1/keys/00000000-0000-0000-0000-000000000000/rotate"}, + {"GET", "/v1/usage/00000000-0000-0000-0000-000000000000"}, + {"GET", "/v1/audit"}, + } + for _, r := range routes { + for _, tok := range []string{"", "wrong-token", verifyToken} { + code, env, _ := do(t, app, r.method, r.path, tok, nil) + if code != http.StatusUnauthorized || env.Status != "error" { + t.Errorf("%s %s with token %q: got %d", r.method, r.path, tok, code) + } + } + } +} + +func TestVerifyTokenEnforcedWhenConfigured(t *testing.T) { + cfg := testConfig() + cfg.VerifyToken = verifyToken + app := New(cfg, stubDB{}, "test") + code, _, _ := do(t, app, "POST", "/v1/keys/verify", "", map[string]string{"api_key": "x"}) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 without verify token, got %d", code) + } +} + +func TestHealthAndNotFound(t *testing.T) { + app := New(testConfig(), stubDB{}, "test") + if code, env, _ := do(t, app, "GET", "/v1/health", "", nil); code != 200 || env.Status != "success" { + t.Fatalf("health: %d %+v", code, env) + } + if code, env, _ := do(t, app, "GET", "/nope", "", nil); code != 404 || env.Status != "error" { + t.Fatalf("404: %d %+v", code, env) + } +} + +// --------------------------------------------------------------------------- +// Integration tests: these need a real Postgres. Set AUTHCELL_TEST_DATABASE_URL +// (see Makefile `itest`). Each run uses a fresh schema. + +func integrationApp(t *testing.T, cfg config.Config) *fiber.App { + t.Helper() + dsn := os.Getenv("AUTHCELL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("AUTHCELL_TEST_DATABASE_URL not set") + } + ctx := context.Background() + schema := fmt.Sprintf("authcell_test_%d", time.Now().UnixNano()) + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = conn.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") + conn.Close(context.Background()) + }) + + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + db, err := database.Connect(ctx, dsn+sep+"search_path="+schema, database.Options{MaxConns: 5}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + return New(cfg, db, "test") +} + +type createdKey struct { + APIKey string `json:"api_key"` + ID string `json:"id"` + KeyID string `json:"key_id"` + KeyPrefix string `json:"key_prefix"` + Name string `json:"name"` + Metadata json.RawMessage `json:"metadata"` + IsActive bool `json:"is_active"` + RateLimit int `json:"rate_limit"` + KeyHash *string `json:"key_hash"` +} + +func create(t *testing.T, app *fiber.App, body map[string]any) createdKey { + t.Helper() + code, env, _ := do(t, app, "POST", "/v1/keys", adminToken, body) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, env.Message) + } + var k createdKey + if err := json.Unmarshal(env.Data, &k); err != nil { + t.Fatal(err) + } + return k +} + +type verifyData struct { + Valid bool `json:"valid"` + Reason string `json:"reason"` + ID string `json:"id"` + KeyPrefix string `json:"key_prefix"` + RateLimit *struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + } `json:"rate_limit"` +} + +func verify(t *testing.T, app *fiber.App, key string) (int, verifyData) { + t.Helper() + code, env, _ := do(t, app, "POST", "/v1/keys/verify", "", map[string]string{"api_key": key}) + var v verifyData + if len(env.Data) > 0 { + _ = json.Unmarshal(env.Data, &v) + } + return code, v +} + +func TestKeyLifecycle(t *testing.T) { + app := integrationApp(t, testConfig()) + + k := create(t, app, map[string]any{ + "key_prefix": "demo_", "name": "CI key", "metadata": map[string]any{"team": "core"}, + }) + if !strings.HasPrefix(k.APIKey, "demo_"+k.KeyID+"_") { + t.Fatalf("unexpected key format %q", k.APIKey) + } + if k.KeyHash != nil { + t.Fatal("key_hash must never be returned") + } + + code, v := verify(t, app, k.APIKey) + if code != 200 || !v.Valid || v.ID != k.ID || v.KeyPrefix != "demo" { + t.Fatalf("verify valid: %d %+v", code, v) + } + + // Wrong secret, unknown id, garbage: all generic "invalid". + for _, bad := range []string{k.APIKey[:len(k.APIKey)-1] + "0", "demo_0123456789abcdef_" + strings.Repeat("a", 48), "garbage", "live_" + k.APIKey[5:]} { + if code, v := verify(t, app, bad); code != 401 || v.Reason != "invalid" { + t.Fatalf("verify %q: %d %+v", bad, code, v) + } + } + + // Header form works too. + req := httptest.NewRequest("POST", "/v1/keys/verify", nil) + req.Header.Set("X-API-Key", k.APIKey) + if resp, _ := app.Test(req); resp.StatusCode != 200 { + t.Fatalf("header verify: %d", resp.StatusCode) + } + + // Get, list, patch. + if code, env, _ := do(t, app, "GET", "/v1/keys/"+k.ID, adminToken, nil); code != 200 || strings.Contains(string(env.Data), "key_hash") { + t.Fatalf("get: %d %s", code, env.Data) + } + code, env, _ := do(t, app, "GET", "/v1/keys?active=true&prefix=demo", adminToken, nil) + if code != 200 || !strings.Contains(string(env.Meta), `"total":1`) { + t.Fatalf("list: %d %s", code, env.Meta) + } + code, env, _ = do(t, app, "PATCH", "/v1/keys/"+k.ID, adminToken, map[string]any{"name": "renamed", "rate_limit": 5}) + if code != 200 || !strings.Contains(string(env.Data), `"renamed"`) { + t.Fatalf("patch: %d %s", code, env.Data) + } + + // Usage was recorded. + code, env, _ = do(t, app, "GET", "/v1/usage/"+k.ID, adminToken, nil) + if code != 200 || !strings.Contains(string(env.Data), `"status":200`) { + t.Fatalf("usage: %d %s", code, env.Data) + } + + // Revoke, then verify says revoked. + if code, _, _ := do(t, app, "DELETE", "/v1/keys/"+k.ID, adminToken, nil); code != 200 { + t.Fatalf("revoke: %d", code) + } + if code, v := verify(t, app, k.APIKey); code != 401 || v.Reason != "revoked" { + t.Fatalf("verify revoked: %d %+v", code, v) + } + if code, _, _ := do(t, app, "DELETE", "/v1/keys/00000000-0000-0000-0000-000000000000", adminToken, nil); code != 404 { + t.Fatalf("revoke unknown: %d", code) + } + if code, _, _ := do(t, app, "DELETE", "/v1/keys/not-a-uuid", adminToken, nil); code != 400 { + t.Fatalf("revoke bad id: %d", code) + } + + // Audit trail has the events. + code, env, _ = do(t, app, "GET", "/v1/audit?event_type=revoke_key", adminToken, nil) + if code != 200 || !strings.Contains(string(env.Data), k.ID) { + t.Fatalf("audit: %d %s", code, env.Data) + } +} + +func TestNoPrefixAndValidation(t *testing.T) { + app := integrationApp(t, testConfig()) + k := create(t, app, map[string]any{}) + if code, v := verify(t, app, k.APIKey); code != 200 || !v.Valid { + t.Fatalf("no-prefix verify: %d %+v", code, v) + } + for _, body := range []map[string]any{ + {"key_prefix": "bad-prefix"}, + {"rate_limit": -1}, + {"expires_at": time.Now().Add(-time.Hour).Format(time.RFC3339)}, + {"metadata": []int{1, 2}}, + } { + if code, _, _ := do(t, app, "POST", "/v1/keys", adminToken, body); code != 400 { + t.Errorf("create %v: expected 400, got %d", body, code) + } + } +} + +func TestExpiry(t *testing.T) { + app := integrationApp(t, testConfig()) + k := create(t, app, map[string]any{"expires_at": time.Now().Add(1500 * time.Millisecond).Format(time.RFC3339Nano)}) + if code, _ := verify(t, app, k.APIKey); code != 200 { + t.Fatalf("before expiry: %d", code) + } + time.Sleep(1600 * time.Millisecond) + if code, v := verify(t, app, k.APIKey); code != 401 || v.Reason != "expired" { + t.Fatalf("after expiry: %d %+v", code, v) + } +} + +func TestRateLimit(t *testing.T) { + app := integrationApp(t, testConfig()) + k := create(t, app, map[string]any{"rate_limit": 3}) + for i := 0; i < 3; i++ { + code, v := verify(t, app, k.APIKey) + if code != 200 || v.RateLimit == nil || v.RateLimit.Remaining != 2-i { + t.Fatalf("call %d: %d %+v", i, code, v) + } + } + code, env, hdr := do(t, app, "POST", "/v1/keys/verify", "", map[string]string{"api_key": k.APIKey}) + if code != 429 || hdr.Get("Retry-After") == "" || !strings.Contains(string(env.Data), "rate_limited") { + t.Fatalf("over limit: %d %s", code, env.Data) + } +} + +func TestRotate(t *testing.T) { + app := integrationApp(t, testConfig()) + old := create(t, app, map[string]any{"key_prefix": "live", "name": "svc", "rate_limit": 10}) + code, env, _ := do(t, app, "POST", "/v1/keys/"+old.ID+"/rotate", adminToken, nil) + if code != 201 { + t.Fatalf("rotate: %d %s", code, env.Message) + } + var fresh createdKey + _ = json.Unmarshal(env.Data, &fresh) + if fresh.ID == old.ID || fresh.Name != "svc" || fresh.RateLimit != 10 || !strings.HasPrefix(fresh.APIKey, "live_") { + t.Fatalf("rotated key: %+v", fresh) + } + if code, v := verify(t, app, old.APIKey); code != 401 || v.Reason != "revoked" { + t.Fatalf("old key after rotate: %d %+v", code, v) + } + if code, _ := verify(t, app, fresh.APIKey); code != 200 { + t.Fatalf("new key after rotate: %d", code) + } + if code, _, _ := do(t, app, "POST", "/v1/keys/"+old.ID+"/rotate", adminToken, nil); code != 409 { + t.Fatalf("rotate revoked: %d", code) + } +} From d3acce65445c71ae96b7d1601729ec0caa5c2dcf Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 4/8] feat(clients)!: cover the full API in the Node and Python clients - Send the admin token (AUTHCELL_ADMIN_TOKEN) and optional verify token (AUTHCELL_VERIFY_TOKEN), read from the environment or passed in. - verifyKey / verify_key return { valid, reason } for bad keys instead of throwing, so callers can branch on the result. - Fix revoke always throwing because the response had no data field. - Add listKeys, updateKey, rotateKey and health, with typed responses. - Raise AuthCellError carrying the HTTP status and response body. - Add request timeouts and trim trailing slashes from the base URL. - Node: add package.json and tsconfig.json so the client builds and ships type declarations. BREAKING CHANGE: verify no longer throws on 401/429, and revoke returns the revoked key. --- client/nodejs/.gitignore | 2 + client/nodejs/index.ts | 257 ++++++++++++++++++++------ client/nodejs/package-lock.json | 51 ++++++ client/nodejs/package.json | 19 ++ client/nodejs/tsconfig.json | 13 ++ client/python/authcell.py | 312 ++++++++++++++++++++++---------- 6 files changed, 508 insertions(+), 146 deletions(-) create mode 100644 client/nodejs/.gitignore create mode 100644 client/nodejs/package-lock.json create mode 100644 client/nodejs/package.json create mode 100644 client/nodejs/tsconfig.json diff --git a/client/nodejs/.gitignore b/client/nodejs/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/client/nodejs/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/client/nodejs/index.ts b/client/nodejs/index.ts index 6926def..f3600dd 100644 --- a/client/nodejs/index.ts +++ b/client/nodejs/index.ts @@ -1,91 +1,242 @@ -export interface FiberResponse { +/** + * AuthCell client for Node.js 18+ (uses native fetch, zero dependencies). + * + * Config comes from the constructor or these environment variables: + * AUTHCELL_URL default http://localhost:8080/v1 + * AUTHCELL_ADMIN_TOKEN needed for management calls (create, list, revoke...) + * AUTHCELL_VERIFY_TOKEN only if the server sets one for /keys/verify + */ + +export interface Envelope { status: "success" | "error"; code: number; message?: string; data?: T; - error?: unknown; + meta?: Record; timestamp: number; } -export interface APIKeyCreateResponse { - api_key: string; +export interface APIKey { id: string; key_id: string; + key_prefix: string; + name: string; + metadata: Record; + is_active: boolean; + created_at: string; + expires_at: string | null; + revoked_at: string | null; + last_used_at: string | null; + /** Max verifications per server window. 0 = unlimited. */ + rate_limit: number; + usage_count: number; } -export interface APIKeyVerifyResponse { - valid: boolean; +/** Returned by create and rotate. `api_key` is shown only this once. */ +export interface CreatedAPIKey extends APIKey { + api_key: string; } -export class AuthCellClient { - private baseUrl: string; +export interface CreateKeyOptions { + key_prefix?: string; + name?: string; + metadata?: Record; + expires_at?: string | Date; + rate_limit?: number; +} + +export interface UpdateKeyOptions { + name?: string; + metadata?: Record; + /** Pass null to remove the expiry. */ + expires_at?: string | Date | null; + rate_limit?: number; +} + +export type VerifyFailureReason = "invalid" | "revoked" | "expired" | "rate_limited"; + +export type VerifyResult = + | { + valid: true; + id: string; + key_id: string; + key_prefix: string; + name: string; + metadata: Record; + expires_at: string | null; + rate_limit: { limit: number; remaining: number; reset: string } | null; + } + | { valid: false; reason: VerifyFailureReason }; + +export interface UsageRecord { + id: number; + api_key_id: string | null; + endpoint: string; + used_at: string; + status: number; + remote_ip: string | null; +} + +export interface AuditRecord { + id: number; + actor_id: string | null; + actor_type: string; + event_type: string; + event_data: Record; + event_time: string; + remote_ip: string | null; +} - constructor(baseUrl?: string) { - this.baseUrl = - baseUrl || process.env.AUTHCELL_URL || "http://localhost:8080/v1"; +export interface Page { + data: T[]; + meta: Record; +} + +export interface ClientOptions { + baseUrl?: string; + adminToken?: string; + verifyToken?: string; + /** Request timeout in ms. Default 5000. */ + timeoutMs?: number; +} + +export class AuthCellError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly body?: Envelope, + ) { + super(message); + this.name = "AuthCellError"; } +} - private async __request( - path: string, - method: string, - body?: unknown, - ): Promise { - const headers = { "Content-Type": "application/json" }; - const url = `${this.baseUrl}${path}`; - const res = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); +type Query = Record; + +export class AuthCellClient { + private readonly baseUrl: string; + private readonly adminToken?: string; + private readonly verifyToken?: string; + private readonly timeoutMs: number; + + constructor(opts: ClientOptions | string = {}) { + const o: ClientOptions = typeof opts === "string" ? { baseUrl: opts } : opts; + const env = typeof process !== "undefined" ? process.env : {}; + this.baseUrl = (o.baseUrl || env.AUTHCELL_URL || "http://localhost:8080/v1").replace(/\/+$/, ""); + this.adminToken = o.adminToken || env.AUTHCELL_ADMIN_TOKEN || undefined; + this.verifyToken = o.verifyToken || env.AUTHCELL_VERIFY_TOKEN || undefined; + this.timeoutMs = o.timeoutMs ?? 5000; + } - // Handle top-level HTTP error - if (!res.ok) { - const errText = await res.text(); - throw new Error(`HTTP ${res.status}: ${errText}`); + private async send( + method: string, + path: string, + opts: { body?: unknown; query?: Query; token?: string } = {}, + ): Promise<{ status: number; env: Envelope }> { + const url = new URL(this.baseUrl + path); + for (const [k, v] of Object.entries(opts.query ?? {})) { + if (v !== undefined) url.searchParams.set(k, String(v)); + } + const headers: Record = { Accept: "application/json" }; + if (opts.body !== undefined) headers["Content-Type"] = "application/json"; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + + let res: Response; + try { + res = await fetch(url, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (err) { + throw new AuthCellError(`AuthCell unreachable at ${url.origin}: ${(err as Error).message}`, 0); } - const json: FiberResponse = await res.json(); + const text = await res.text(); + let env: Envelope; + try { + env = JSON.parse(text); + } catch { + throw new AuthCellError(`HTTP ${res.status}: ${text.slice(0, 200)}`, res.status); + } + return { status: res.status, env }; + } - // The server always sends status, code, timestamp. - if (json.status === "error") { - throw new Error(`Error ${json.code}: ${json.message || "unknown"}`); + private async admin(method: string, path: string, body?: unknown, query?: Query): Promise> { + if (!this.adminToken) { + throw new AuthCellError("adminToken (or AUTHCELL_ADMIN_TOKEN) is required for this call", 0); + } + const { status, env } = await this.send(method, path, { body, query, token: this.adminToken }); + if (status >= 400 || env.status === "error") { + throw new AuthCellError(env.message || `HTTP ${status}`, status, env); } + return env; + } - // Return only the Data payload to preserve existing call pattern - if (!json.data) { - throw new Error("No data field in server response"); + /** Verify a key. Never throws for an invalid key; check `result.valid`. */ + async verifyKey(apiKey: string): Promise { + const { status, env } = await this.send("POST", "/keys/verify", { + body: { api_key: apiKey }, + token: this.verifyToken, + }); + if ((status === 401 || status === 429) && env.data && "valid" in env.data) { + return env.data; } + if (status === 400) return { valid: false, reason: "invalid" }; + if (status !== 200 || !env.data) { + throw new AuthCellError(env.message || `HTTP ${status}`, status, env); + } + return env.data; + } - return json.data; + async createKey(opts: CreateKeyOptions = {}): Promise { + const body = { ...opts, expires_at: toISO(opts.expires_at) }; + return (await this.admin("POST", "/keys", body)).data!; } - async createKey(opts: { - key_prefix: string; - expires_at?: string; - rate_limit?: number; - }): Promise { - return this.__request("/keys", "POST", opts); + async listKeys(opts: { limit?: number; offset?: number; active?: boolean; prefix?: string } = {}): Promise> { + const env = await this.admin("GET", "/keys", undefined, opts); + return { data: env.data ?? [], meta: env.meta ?? {} }; } - async verifyKey(api_key: string): Promise { - return this.__request("/keys/verify", "POST", { - api_key, - }); + async getKey(id: string): Promise { + return (await this.admin("GET", `/keys/${encodeURIComponent(id)}`)).data!; + } + + async updateKey(id: string, opts: UpdateKeyOptions): Promise { + const body: Record = { ...opts }; + if (opts.expires_at !== undefined) body.expires_at = opts.expires_at === null ? null : toISO(opts.expires_at); + return (await this.admin("PATCH", `/keys/${encodeURIComponent(id)}`, body)).data!; } - async getKey(id: string) { - return this.__request(`/keys/${id}`, "GET"); + async revokeKey(id: string): Promise { + return (await this.admin("DELETE", `/keys/${encodeURIComponent(id)}`)).data!; } - async revokeKey(id: string) { - return this.__request(`/keys/${id}`, "DELETE"); + /** Issue a replacement key with the same settings and revoke the old one. */ + async rotateKey(id: string): Promise { + return (await this.admin("POST", `/keys/${encodeURIComponent(id)}/rotate`)).data!; } - async listUsage(id: string) { - return this.__request(`/usage/${id}`, "GET"); + async listUsage(id: string, opts: { limit?: number; before?: number } = {}): Promise> { + const env = await this.admin("GET", `/usage/${encodeURIComponent(id)}`, undefined, opts); + return { data: env.data ?? [], meta: env.meta ?? {} }; } - async listAuditLog() { - return this.__request("/audit", "GET"); + async listAuditLog(opts: { limit?: number; before?: number; event_type?: string } = {}): Promise> { + const env = await this.admin("GET", "/audit", undefined, opts); + return { data: env.data ?? [], meta: env.meta ?? {} }; } + + async health(): Promise> { + const { status, env } = await this.send>("GET", "/health"); + if (status !== 200) throw new AuthCellError(env.message || `HTTP ${status}`, status, env); + return env.data ?? {}; + } +} + +function toISO(v: string | Date | undefined): string | undefined { + if (v === undefined) return undefined; + return v instanceof Date ? v.toISOString() : v; } diff --git a/client/nodejs/package-lock.json b/client/nodejs/package-lock.json new file mode 100644 index 0000000..c21cf71 --- /dev/null +++ b/client/nodejs/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "authcell-client", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "authcell-client", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/client/nodejs/package.json b/client/nodejs/package.json new file mode 100644 index 0000000..87e5192 --- /dev/null +++ b/client/nodejs/package.json @@ -0,0 +1,19 @@ +{ + "name": "authcell-client", + "version": "0.1.0", + "description": "Zero-dependency client for the AuthCell API key service", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "engines": { "node": ">=18" }, + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.2" + } +} diff --git a/client/nodejs/tsconfig.json b/client/nodejs/tsconfig.json new file mode 100644 index 0000000..7abf8bf --- /dev/null +++ b/client/nodejs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true + }, + "include": ["index.ts"] +} diff --git a/client/python/authcell.py b/client/python/authcell.py index 2316759..4935594 100644 --- a/client/python/authcell.py +++ b/client/python/authcell.py @@ -1,128 +1,254 @@ +"""AuthCell client for Python 3.8+ (standard library only). + +Config comes from the constructor or these environment variables: + AUTHCELL_URL default http://localhost:8080/v1 + AUTHCELL_ADMIN_TOKEN needed for management calls (create, list, revoke...) + AUTHCELL_VERIFY_TOKEN only if the server sets one for /keys/verify +""" + from __future__ import annotations import json import os +import urllib.error +import urllib.parse import urllib.request -from dataclasses import dataclass -from typing import Any, Dict, Optional, Type, TypeVar +from dataclasses import dataclass, field, fields +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple, Union -T = TypeVar("T") +__all__ = [ + "AuthCellClient", + "AuthCellError", + "APIKey", + "CreatedAPIKey", + "VerifyResult", + "Page", +] +_UNSET: Any = object() + + +class AuthCellError(Exception): + """Raised for transport errors and non-verify API errors.""" + + def __init__(self, message: str, status: int = 0, body: Optional[dict] = None): + super().__init__(message) + self.status = status + self.body = body or {} -@dataclass -class FiberResponse: - status: str - code: int - message: Optional[str] = None - data: Optional[Any] = None - error: Optional[Any] = None - timestamp: Optional[int] = None + +def _from_dict(cls, data: Dict[str, Any]): + """Build a dataclass, ignoring fields the server adds in future versions.""" + names = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in data.items() if k in names}) @dataclass -class APIKeyCreateResponse: - api_key: str +class APIKey: id: str key_id: str + key_prefix: str + name: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + is_active: bool = True + created_at: Optional[str] = None + expires_at: Optional[str] = None + revoked_at: Optional[str] = None + last_used_at: Optional[str] = None + rate_limit: int = 0 + usage_count: int = 0 + + +@dataclass +class CreatedAPIKey(APIKey): + #: The plaintext key. Returned only once; store it securely. + api_key: str = "" @dataclass -class APIKeyVerifyResponse: +class VerifyResult: valid: bool + reason: Optional[str] = None + id: Optional[str] = None + key_id: Optional[str] = None + key_prefix: Optional[str] = None + name: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + expires_at: Optional[str] = None + rate_limit: Optional[Dict[str, Any]] = None + + def __bool__(self) -> bool: + return self.valid -# ---------------------- Client ---------------------- +@dataclass +class Page: + data: List[Dict[str, Any]] + meta: Dict[str, Any] + + +def _iso(v: Union[str, datetime, None]) -> Optional[str]: + if isinstance(v, datetime): + if v.tzinfo is None: + raise ValueError("expires_at datetime must be timezone-aware") + return v.isoformat() + return v class AuthCellClient: - def __init__(self, base_url: Optional[str] = None) -> None: - """Initialize client with base URL or autodetect from ENV.""" + def __init__( + self, + base_url: Optional[str] = None, + admin_token: Optional[str] = None, + verify_token: Optional[str] = None, + timeout: float = 5.0, + ) -> None: self.base_url = ( base_url or os.getenv("AUTHCELL_URL") or "http://localhost:8080/v1" - ) + ).rstrip("/") + self.admin_token = admin_token or os.getenv("AUTHCELL_ADMIN_TOKEN") or None + self.verify_token = verify_token or os.getenv("AUTHCELL_VERIFY_TOKEN") or None + self.timeout = timeout - def _request( + # ------------------------------------------------------------------ core + + def _send( self, - path: str, method: str, + path: str, body: Optional[Dict[str, Any]] = None, - response_type: Optional[Type[T]] = None, - ) -> T: - """Core request handler for AuthCell.""" - url = f"{self.base_url}{path}" - data = json.dumps(body).encode("utf-8") if body else None - - req = urllib.request.Request( - url, - data=data, - headers={"Content-Type": "application/json"}, - method=method, - ) - + query: Optional[Dict[str, Any]] = None, + token: Optional[str] = None, + ) -> Tuple[int, Dict[str, Any]]: + url = self.base_url + path + if query: + q = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in query.items() if v is not None} + if q: + url += "?" + urllib.parse.urlencode(q) + + headers = {"Accept": "application/json"} + data = None + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=5) as resp: - raw = resp.read().decode() - if resp.status not in (200, 201): - raise RuntimeError(f"HTTP {resp.status}: {raw}") - - # Parse standard response structure - parsed = json.loads(raw) - fiber_resp = FiberResponse(**parsed) - - if fiber_resp.status == "error": - raise RuntimeError( - f"ServerError {fiber_resp.code}: {fiber_resp.message or fiber_resp.error}" - ) - - if not fiber_resp.data: - raise RuntimeError("Response missing 'data' field") - - # Deserialize into response dataclass if type provided - data_part = fiber_resp.data - if response_type is not None: - # Ensure it's a dict before unpacking - if isinstance(data_part, dict): - return response_type(**data_part) - elif isinstance(data_part, list): - return data_part # raw list for endpoints like audits - return data_part + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + status, raw = resp.status, resp.read() except urllib.error.HTTPError as e: - error_msg = e.read().decode() - raise RuntimeError(f"HTTPError {e.code}: {error_msg}") - except urllib.error.URLError as e: - raise RuntimeError(f"ConnectionError: {str(e)}") + status, raw = e.code, e.read() + except (urllib.error.URLError, TimeoutError, OSError) as e: + raise AuthCellError(f"AuthCell unreachable at {self.base_url}: {e}") from e - # ---------------------- API Methods ---------------------- + try: + env = json.loads(raw.decode("utf-8") or "{}") + except ValueError: + raise AuthCellError(f"HTTP {status}: {raw[:200]!r}", status) + return status, env + + def _admin(self, method: str, path: str, body=None, query=None) -> Dict[str, Any]: + if not self.admin_token: + raise AuthCellError("admin_token (or AUTHCELL_ADMIN_TOKEN) is required for this call") + status, env = self._send(method, path, body, query, self.admin_token) + if status >= 400 or env.get("status") == "error": + raise AuthCellError(env.get("message") or f"HTTP {status}", status, env) + return env + + @staticmethod + def _quote(v: str) -> str: + return urllib.parse.quote(v, safe="") + + # ------------------------------------------------------------------ verify + + def verify_key(self, api_key: str) -> VerifyResult: + """Verify a key. Never raises for an invalid key; check ``result.valid``.""" + status, env = self._send("POST", "/keys/verify", {"api_key": api_key}, token=self.verify_token) + data = env.get("data") + if status in (401, 429) and isinstance(data, dict) and "valid" in data: + return _from_dict(VerifyResult, data) + if status == 400: + return VerifyResult(valid=False, reason="invalid") + if status != 200 or not isinstance(data, dict): + raise AuthCellError(env.get("message") or f"HTTP {status}", status, env) + return _from_dict(VerifyResult, data) + + # ------------------------------------------------------------------ keys def create_key( self, - key_prefix: str, - expires_at: Optional[str] = None, + key_prefix: str = "", + name: str = "", + metadata: Optional[Dict[str, Any]] = None, + expires_at: Union[str, datetime, None] = None, + rate_limit: Optional[int] = None, + ) -> CreatedAPIKey: + body: Dict[str, Any] = {"key_prefix": key_prefix, "name": name} + if metadata is not None: + body["metadata"] = metadata + if expires_at is not None: + body["expires_at"] = _iso(expires_at) + if rate_limit is not None: + body["rate_limit"] = rate_limit + return _from_dict(CreatedAPIKey, self._admin("POST", "/keys", body)["data"]) + + def list_keys( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + active: Optional[bool] = None, + prefix: Optional[str] = None, + ) -> Page: + env = self._admin("GET", "/keys", query={"limit": limit, "offset": offset, "active": active, "prefix": prefix}) + return Page(env.get("data") or [], env.get("meta") or {}) + + def get_key(self, id: str) -> APIKey: + return _from_dict(APIKey, self._admin("GET", f"/keys/{self._quote(id)}")["data"]) + + def update_key( + self, + id: str, + name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + expires_at: Any = _UNSET, rate_limit: Optional[int] = None, - ) -> APIKeyCreateResponse: - body = { - "key_prefix": key_prefix, - "expires_at": expires_at, - "rate_limit": rate_limit, - } - return self._request("/keys", "POST", body, response_type=APIKeyCreateResponse) - - def verify_key(self, api_key: str) -> APIKeyVerifyResponse: - return self._request( - "/keys/verify", - "POST", - {"api_key": api_key}, - response_type=APIKeyVerifyResponse, - ) - - def get_key(self, key_id: str) -> Dict[str, Any]: - return self._request(f"/keys/{key_id}", "GET") - - def revoke_key(self, key_id: str) -> Dict[str, Any]: - return self._request(f"/keys/{key_id}", "DELETE") - - def list_usage(self, key_id: str) -> Dict[str, Any]: - return self._request(f"/usage/{key_id}", "GET") - - def list_audit_log(self) -> Dict[str, Any]: - return self._request("/audit", "GET") + ) -> APIKey: + """Update a key. Pass ``expires_at=None`` to remove its expiry.""" + body: Dict[str, Any] = {} + if name is not None: + body["name"] = name + if metadata is not None: + body["metadata"] = metadata + if expires_at is not _UNSET: + body["expires_at"] = _iso(expires_at) + if rate_limit is not None: + body["rate_limit"] = rate_limit + return _from_dict(APIKey, self._admin("PATCH", f"/keys/{self._quote(id)}", body)["data"]) + + def revoke_key(self, id: str) -> APIKey: + return _from_dict(APIKey, self._admin("DELETE", f"/keys/{self._quote(id)}")["data"]) + + def rotate_key(self, id: str) -> CreatedAPIKey: + """Issue a replacement key with the same settings and revoke the old one.""" + return _from_dict(CreatedAPIKey, self._admin("POST", f"/keys/{self._quote(id)}/rotate")["data"]) + + # ------------------------------------------------------------------ logs + + def list_usage(self, id: str, limit: Optional[int] = None, before: Optional[int] = None) -> Page: + env = self._admin("GET", f"/usage/{self._quote(id)}", query={"limit": limit, "before": before}) + return Page(env.get("data") or [], env.get("meta") or {}) + + def list_audit_log( + self, limit: Optional[int] = None, before: Optional[int] = None, event_type: Optional[str] = None + ) -> Page: + env = self._admin("GET", "/audit", query={"limit": limit, "before": before, "event_type": event_type}) + return Page(env.get("data") or [], env.get("meta") or {}) + + def health(self) -> Dict[str, Any]: + status, env = self._send("GET", "/health") + if status != 200: + raise AuthCellError(env.get("message") or f"HTTP {status}", status, env) + return env.get("data") or {} From 7c9352492b96d73fb6b6063c62942e163496a760 Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 5/8] build(docker): distroless image and a Compose setup that works - Build a static binary with the version baked in and run it on distroless as non-root, with a HEALTHCHECK using -healthcheck. - Add .dockerignore; .env was being copied into the build context. - Compose always points the app at the postgres service. With DB_HOST=localhost from .env the app was trying to reach itself. - Fail early when AUTHCELL_ADMIN_TOKEN or DB_PASSWORD is unset. - Pin postgres:17-alpine and publish it on 127.0.0.1 only. - Document every setting in .env.example. --- .dockerignore | 11 ++++++ .env.example | 32 ++++++++++++++-- Dockerfile | 24 ++++++------ docker-compose.yml | 92 ++++++++++++++++++++++------------------------ 4 files changed, 97 insertions(+), 62 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d205ec3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.env +.env.* +!.env.example +tmp/ +main +dist/ +client/ +*.md +!README.md diff --git a/.env.example b/.env.example index 969e864..64233c1 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,34 @@ +# --- Server --------------------------------------------------------------- PORT=8080 APP_ENV=local +LOG_LEVEL=info + +# Required. Guards every management endpoint. Generate with: openssl rand -hex 32 +AUTHCELL_ADMIN_TOKEN=change-me-to-a-random-64-char-hex-string-0000000000000000 + +# Optional. When set, POST /v1/keys/verify also needs "Authorization: Bearer ". +AUTHCELL_VERIFY_TOKEN= + +# Optional. Comma-separated origins for browser access. Empty disables CORS. +CORS_ORIGINS= +# Optional. Comma-separated proxy IPs/CIDRs whose X-Forwarded-For is trusted. +TRUSTED_PROXIES= + +# --- Keys ----------------------------------------------------------------- +# Window used for each key's rate_limit (e.g. 1000 per 1m). +RATE_LIMIT_WINDOW=1m +# How long verification logs are kept. 0 keeps them forever. +USAGE_RETENTION_DAYS=90 + +# --- Database ------------------------------------------------------------- +# Either set DATABASE_URL, or the DB_* values below. +# DATABASE_URL=postgres://authcell:secret@localhost:5432/authcell?sslmode=disable DB_HOST=localhost DB_PORT=5432 -DB_DATABASE=postgres -DB_USERNAME=postgres -DB_PASSWORD=postgres +DB_DATABASE=authcell +DB_USERNAME=authcell +DB_PASSWORD=authcell DB_SCHEMA=public +DB_SSLMODE=disable +DB_MAX_CONNS=25 +DB_MIN_CONNS=2 diff --git a/Dockerfile b/Dockerfile index dc18829..d714d09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,20 @@ -FROM golang:1.24.4-alpine AS build +# syntax=docker/dockerfile:1 -WORKDIR /app +FROM golang:1.24-alpine AS build +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/authcell ./cmd/api -RUN go build -o main cmd/api/main.go - -FROM alpine:3.20.1 AS prod -WORKDIR /app -COPY --from=build /app/main /app/main -EXPOSE ${PORT} -CMD ["./main"] - - +# Distroless: no shell, runs as an unprivileged user, ships CA certs for TLS to Postgres. +FROM gcr.io/distroless/static-debian12:nonroot AS prod +COPY --from=build /out/authcell /authcell +ENV PORT=8080 +EXPOSE 8080 +USER nonroot:nonroot +HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 CMD ["/authcell", "-healthcheck"] +ENTRYPOINT ["/authcell"] diff --git a/docker-compose.yml b/docker-compose.yml index 1a6f80b..4990765 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,51 +1,47 @@ services: - app: - build: - context: . - dockerfile: Dockerfile - target: prod - restart: unless-stopped - ports: - - ${PORT}:${PORT} - environment: - APP_ENV: ${APP_ENV} - PORT: ${PORT} - DB_HOST: ${DB_HOST} - DB_PORT: ${DB_PORT} - DB_DATABASE: ${DB_DATABASE} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} - DB_SCHEMA: ${DB_SCHEMA} - depends_on: - psql_bp: - condition: service_healthy - networks: - - blueprint - psql_bp: - image: postgres:latest - restart: unless-stopped - environment: - POSTGRES_DB: ${DB_DATABASE} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - ports: - - "${DB_PORT}:5432" - volumes: - - psql_volume_bp:/var/lib/postgresql/data - healthcheck: - test: - [ - "CMD-SHELL", - "sh -c 'pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}'", - ] - interval: 5s - timeout: 5s - retries: 3 - start_period: 15s - networks: - - blueprint + app: + build: + context: . + target: prod + restart: unless-stopped + ports: + - "${PORT:-8080}:${PORT:-8080}" + environment: + APP_ENV: ${APP_ENV:-production} + PORT: ${PORT:-8080} + AUTHCELL_ADMIN_TOKEN: ${AUTHCELL_ADMIN_TOKEN:?set AUTHCELL_ADMIN_TOKEN in .env (openssl rand -hex 32)} + AUTHCELL_VERIFY_TOKEN: ${AUTHCELL_VERIFY_TOKEN:-} + # Inside compose the database is always the "postgres" service, whatever .env says. + DB_HOST: postgres + DB_PORT: 5432 + DB_DATABASE: ${DB_DATABASE:-authcell} + DB_USERNAME: ${DB_USERNAME:-authcell} + DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} + DB_SCHEMA: ${DB_SCHEMA:-public} + RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-1m} + USAGE_RETENTION_DAYS: ${USAGE_RETENTION_DAYS:-90} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_DATABASE:-authcell} + POSTGRES_USER: ${DB_USERNAME:-authcell} + POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} + # Only reachable from this machine. Remove the mapping entirely in production. + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s volumes: - psql_volume_bp: -networks: - blueprint: + pgdata: From 9d19e4c3ac27e4283cbb8c8f5043d8e000ae1856 Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 6/8] ci: run integration tests, fix releases, tidy Makefile - CI reads the Go version from go.mod (it used 1.22 while go.mod required 1.25.3), runs gofmt/vet, runs unit and integration tests against a Postgres service, and builds the Docker image. - Release workflow installed a GoReleaser version named after the git tag; pin it to "~> v2" and fetch full history for changelogs. - GoReleaser injected the version into a placeholder package path; set main.version instead, and ship LICENSE and .env.example in archives. - Makefile: add lint and itest, bake the version into builds, simplify the docker and watch targets. --- .github/workflows/go-test.yml | 48 ++++++++++++++++----- .github/workflows/release.yml | 13 +++--- .gitignore | 3 +- .goreleaser.yml | 50 +++++++++++----------- Makefile | 78 ++++++++++++----------------------- 5 files changed, 95 insertions(+), 97 deletions(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 2d82daa..4ddd99b 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -1,18 +1,44 @@ -name: Go-test -on: [push, pull_request] +name: test + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read jobs: - build: + test: runs-on: ubuntu-latest - + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: authcell + POSTGRES_PASSWORD: authcell + POSTGRES_DB: authcell + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U authcell" + --health-interval 5s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: - go-version: '1.22.x' - + go-version-file: go.mod + - name: Lint + run: make lint - name: Build - run: go build -v ./... - - name: Test with the Go CLI - run: go test ./... + run: go build ./... + - name: Test (unit + integration) + env: + AUTHCELL_TEST_DATABASE_URL: postgres://authcell:authcell@localhost:5432/authcell?sslmode=disable + run: go test -race -count=1 ./... + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build --target prod -t authcell:ci . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8051ea..53816b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: goreleaser +name: release on: push: @@ -13,17 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 with: - go-version: '1.22.x' - + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: ${{ env.GITHUB_REF_NAME }} + version: "~> v2" args: release --clean - workdir: ./ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5cae497..9226ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,8 @@ tmp/ # Project build main -*templ.go +dist/ +build-errors.log # OS X generated file .DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml index e001bf5..1be3352 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,42 +1,38 @@ version: 2 +project_name: authcell + before: hooks: - - go mod tidy - -env: - - PACKAGE_PATH=github.com///cmd + - go mod download builds: -- binary: "{{ .ProjectName }}" - main: ./cmd/api - goos: - - darwin - - linux - - windows - goarch: - - amd64 - - arm64 - env: - - CGO_ENABLED=0 - ldflags: - - -s -w -X {{.Env.PACKAGE_PATH}}={{.Version}} -release: - prerelease: auto + - binary: authcell + main: ./cmd/api + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} universal_binaries: -- replace: true + - replace: true archives: - - name_template: > - {{- .ProjectName }}_{{- .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }}{{- if .Arm }}v{{ .Arm }}{{ end -}} + - name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ title .Os }}_{{ if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }} format_overrides: - goos: windows - format: zip - builds_info: - group: root - owner: root + formats: [zip] files: - README.md + - LICENSE + - .env.example checksum: - name_template: 'checksums.txt' + name_template: checksums.txt + +release: + prerelease: auto diff --git a/Makefile b/Makefile index a4202ff..3b7841a 100644 --- a/Makefile +++ b/Makefile @@ -1,64 +1,40 @@ -# Simple Makefile for a Go project +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +TEST_DB ?= postgres://authcell:authcell@localhost:5432/authcell?sslmode=disable -# Build the application -all: build test - -build: - @echo "Building..." +.PHONY: all build run test itest lint clean watch docker-run docker-down +all: lint test build - @go build -o main cmd/api/main.go +build: + @echo "Building $(VERSION)..." + @CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o main ./cmd/api -# Run the application run: - @go run cmd/api/main.go -# Create DB container -docker-run: - @if docker compose up --build 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose up --build; \ - fi + @go run ./cmd/api -# Shutdown DB container -docker-down: - @if docker compose down 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose down; \ - fi - -# Test the application +# Unit tests only. Integration tests skip without a database. test: - @echo "Testing..." - @go test ./... -v -# Integrations Tests for the application + @go test -race ./... + +# Unit + integration tests against a real Postgres (start one with `make docker-run`). itest: - @echo "Running integration tests..." - @go test ./internal/database -v + @AUTHCELL_TEST_DATABASE_URL="$(TEST_DB)" go test -race -count=1 ./... + +lint: + @test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1) + @go vet ./... -# Clean the binary clean: - @echo "Cleaning..." @rm -f main + @rm -rf tmp dist -# Live Reload -watch: - @if command -v $(GOPATH)/bin/air > /dev/null; then \ - $(GOPATH)/bin/air; \ - echo "Watching..."; \ - else \ - read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ - if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ - go install github.com/air-verse/air@latest; \ - air; \ - echo "Watching...";\ - else \ - echo "You chose not to install air. Exiting..."; \ - exit 1; \ - fi; \ - fi +docker-run: + @docker compose up --build -.PHONY: all build run test clean watch docker-run docker-down itest +docker-down: + @docker compose down + +watch: + @command -v air >/dev/null 2>&1 || go install github.com/air-verse/air@latest + @air From 680908b1f36974e495f8e10648df9a20ffcb6761 Mon Sep 17 00:00:00 2001 From: aw Date: Tue, 27 Oct 2026 20:17:17 +0000 Subject: [PATCH 7/8] docs: rewrite README and add CHANGELOG for v0.1.0 Document the key format, authentication, every endpoint and response, all configuration, both clients, development workflow and the upgrade path from v0.0.x. Fix the broken Python example and code fences. --- CHANGELOG.md | 121 ++++++++++++++++++++ README.md | 318 +++++++++++++++++++++++++++------------------------ 2 files changed, 287 insertions(+), 152 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85669ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +All notable changes to AuthCell are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-10-28 + +A security and reliability release. The admin API is now authenticated, key +hashing is faster and no longer drops long keys, and rate limits are enforced. +**This release has breaking changes.** See "Upgrading" below. + +### Security + +- All management endpoints now require `Authorization: Bearer `. + Before this, anyone who could reach the port could create, list and revoke keys. +- `GET /v1/keys/:id` no longer returns `key_hash`. No endpoint does. +- Error responses no longer include raw database errors. Details are logged server side. +- Verify returns a generic `invalid` reason until the caller proves they hold the real + key, so it can't be used to find out which key ids exist. +- CORS is off by default. Set `CORS_ORIGINS` to allow browser access. +- The Docker image no longer copies `.env` into the build context, runs as non-root on + distroless, and Postgres is only published on `127.0.0.1` in Compose. +- The server refuses to start with a missing, short (< 32 chars) or placeholder admin token. + +### Added + +- `GET /v1/keys` with `limit`, `offset`, `active` and `prefix` filters. +- `PATCH /v1/keys/:id` to change `name`, `metadata`, `expires_at` (send `null` to clear) and `rate_limit`. +- `POST /v1/keys/:id/rotate` issues a replacement key with the same settings and revokes the old one. +- `name` and JSON `metadata` on keys. +- Per-key rate limiting over `RATE_LIMIT_WINDOW` (default `1m`), safe across multiple + instances. Responses include `X-RateLimit-*` headers, and `429` responses include `Retry-After`. +- Verify accepts the key in an `X-API-Key` header as well as the JSON body, and returns + the key's id, prefix, name, metadata, expiry and remaining rate limit. +- Optional `AUTHCELL_VERIFY_TOKEN` to protect the verify endpoint. +- `last_used_at`, `revoked_at` and a real `usage_count`. +- Cursor pagination (`limit`, `before`, `meta.next_before`) for usage and audit logs, and + an `event_type` filter for audit. +- Hourly pruning of usage rows older than `USAGE_RETENTION_DAYS` (default 90). +- Versioned schema migrations guarded by a Postgres advisory lock. +- `DATABASE_URL`, `DB_SSLMODE`, `DB_MAX_CONNS`, `DB_MIN_CONNS`, `REQUEST_TIMEOUT`, + `TRUSTED_PROXIES` and `LOG_LEVEL` settings. +- Structured JSON request logs with request ids. +- `authcell -version` and `authcell -healthcheck` (used by the Docker `HEALTHCHECK`). +- Node client: `listKeys`, `updateKey`, `rotateKey`, `health`, `AuthCellError`, timeouts, + plus `package.json` and `tsconfig.json` so it builds as a package. +- Python client: `list_keys`, `update_key`, `rotate_key`, `health`, `AuthCellError`. +- Unit tests, plus integration tests that run against a real Postgres when + `AUTHCELL_TEST_DATABASE_URL` is set. CI runs both. +- `make lint` and `make itest`. + +### Changed + +- New key format: `_<16 hex key_id>_<48 hex secret>`. The prefix is optional and + may contain single underscores. A trailing underscore on input is ignored. +- Keys are stored as SHA-256 digests instead of bcrypt, which removes about 80 ms of CPU + from every verify. Old bcrypt keys still verify and are rehashed on first use. +- Verify failures return `data: {"valid": false, "reason": ...}` with `401` (`invalid`, + `revoked`, `expired`) or `429` (`rate_limited`). +- Revoke returns the revoked key, and is idempotent. +- `rate_limit` defaults to `0` (unlimited) and must be non-negative. +- `audit_log.actor_id` is now `text`. It used to hold a random UUID per event. +- Clients read `AUTHCELL_ADMIN_TOKEN` and `AUTHCELL_VERIFY_TOKEN` from the environment. + `verifyKey` / `verify_key` return a result instead of throwing for a bad key. +- `PORT` defaults to `8080` instead of silently binding a random port when unset. +- Go 1.24. Dockerfile, CI and `go.mod` now agree on the version. +- Compose always points the app at the `postgres` service, whatever `DB_HOST` says in `.env`. +- Postgres image pinned to `17-alpine` instead of `latest`. + +### Fixed + +- Keys with a prefix longer than 27 characters were saved with an empty hash (bcrypt's + 72-byte limit, error ignored) and could never be verified. +- Prefixes without a trailing underscore, prefixes containing underscores, and empty + prefixes produced keys that could never be verified. +- `/v1/health` called `log.Fatalf` when the database was down, killing the server. It now returns `503`. +- Key listing queried a table that doesn't exist (`api_keys`) and wasn't routed. +- Revoking an unknown id returned `200`. It now returns `404`, and a malformed id returns `400`. +- Usage rows for unknown keys failed to insert (empty string into a `uuid` column). +- Rows with `NULL` columns were silently dropped from usage and audit listings. +- Empty listings returned `null` instead of `[]`. +- Both clients threw on every revoke because the response had no `data` field. +- 8-character key ids (32 bits) caused unique-constraint failures as key counts grew. Ids + are now 64 bits, with a retry on collision. +- Database passwords with special characters broke the connection string. +- The database pool was never closed on shutdown. +- The route table was printed to stdout on every start. +- Release workflow passed the git tag as the GoReleaser version to install. +- GoReleaser injected the version into a placeholder package path that didn't exist. +- CI used Go 1.22 while `go.mod` required 1.25.3. + +### Removed + +- `testcontainers-go`. Integration tests use any Postgres you point them at. +- The unused `GetAPIKeyByPrefix` query. + +### Upgrading from 0.0.x + +1. Generate an admin token: `openssl rand -hex 32`, and set it as `AUTHCELL_ADMIN_TOKEN`. +2. Send `Authorization: Bearer ` on every call except `/v1/health` and `/v1/keys/verify`. +3. Start 0.1.0 against your existing database. Migrations run automatically. +4. Check `rate_limit` on existing keys. It is now enforced per `RATE_LIMIT_WINDOW`. Set it + to `0` with `PATCH /v1/keys/:id` for unlimited. +5. Recreate or rotate any key whose prefix is longer than 27 characters. Those never worked. +6. If your code checked for `401` from verify, it still works. If it parsed `message`, + switch to `data.reason`. + +## [0.0.2] - 2025-10-23 + +- Added the API key listing handler. +- Updated the Node and Python clients. + +## [0.0.1] - 2025-10-19 + +Initial release: create, get, verify and revoke keys, usage and audit logs, +Node and Python clients. + +[0.1.0]: https://github.com/TheLabvent/authcell/compare/v0.0.2...v0.1.0 +[0.0.2]: https://github.com/TheLabvent/authcell/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/TheLabvent/authcell/releases/tag/v0.0.1 diff --git a/README.md b/README.md index bf84171..19eb073 100644 --- a/README.md +++ b/README.md @@ -2,231 +2,245 @@ > Self-hosted API key management as a standalone microservice. -**No vendor lock-in. No external dependencies. Secure and fast.** - -**AuthCell** is a self‑contained, open‑source API key management and verification service. -It provides a fast, lightweight, and self‑hosted alternative to commercial key management solutions, enabling developers to generate, verify, audit, and revoke API keys securely within their own environment. - ---- - -## Overview - -AuthCell offers a drop‑in API security layer that can integrate into any stack or existing microservice. -Designed for simplicity, privacy, and host control, AuthCell runs as a single containerized service — with no external dependencies other than PostgreSQL. +AuthCell issues, verifies, rate-limits, rotates and revokes API keys for your own services. It is a single Go binary backed by PostgreSQL, with no external services and no telemetry. ## Features -- Full API key lifecycle (create, verify, revoke) -- Detailed usage tracking and audit logging -- bcrypt‑based key hashing -- Docker Compose deployment -- Zero cloud lock‑in or external dependencies +- Full key lifecycle: create, list, inspect, update, rotate, revoke +- Fast verification: one indexed lookup plus a constant-time SHA-256 compare +- Per-key rate limits that hold across multiple AuthCell instances +- Expiry, names and JSON metadata on every key +- Usage log for every verification and an audit log for every admin action +- Admin API protected by a bearer token, optional token for the verify endpoint +- Versioned, lock-protected schema migrations that run on startup +- Distroless Docker image, Docker Compose setup, zero-dependency Node and Python clients --- -## Client Libraries - -AuthCell includes lightweight client libraries to simplify integration into your existing applications. -Clients automatically deduce the server URL using the `AUTHCELL_URL` environment variable or fallback to `http://localhost:8080/v1`. +## Quick start ---- +```bash +cp .env.example .env +# Set a real admin token and DB password in .env: +# AUTHCELL_ADMIN_TOKEN=$(openssl rand -hex 32) +docker compose up --build +``` -### Node.js / TypeScript Client +Then: -**File:** `client/nodejs/index.ts` -**Dependencies:** None (uses native `fetch`, Node.js v18+ required) +```bash +export TOKEN= -**Example** +# Create a key (the plaintext key is returned only once) +curl -s -X POST http://localhost:8080/v1/keys \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"key_prefix":"live","name":"billing-service","rate_limit":1000}' -```typescript -import { AuthCellClient } from "./client/nodejs/index"; +# Verify it (this is what your services call on each request) +curl -s -X POST http://localhost:8080/v1/keys/verify \ + -H "Content-Type: application/json" \ + -d '{"api_key":"live_3f9c2a71d04be855_..."}' +``` -const client = new AuthCellClient(); // will use AUTHCELL_URL or localhost +To run without Docker, start Postgres, fill in `.env`, and run `make run`. -async function main() { - const created = await client.createKey({ key_prefix: "demo_" }); - console.log("Created key:", created.api_key); +--- - const verified = await client.verifyKey(created.api_key); - console.log("Verification:", verified.valid); -} +## Key format -main(); +``` +live_3f9c2a71d04be855_9b1f0c2e7d4a... (48 hex chars) +└┬─┘ └──────┬───────┘ └──────┬──────┘ +prefix key_id secret ``` ---- +- **prefix**: optional. Letters, digits and single underscores, up to 32 characters (`live`, `test`, `my_app`). A trailing underscore is ignored, so `demo_` and `demo` are the same. +- **key_id**: 16 hex characters (64 bits). Indexed, so lookups never scan. +- **secret**: 48 hex characters (192 bits) from `crypto/rand`. -### Python Client +Only a SHA-256 digest of the full key is stored. Because the key is long and random, a fast hash is safe here (this is the same approach GitHub and Stripe use for tokens), and it avoids bcrypt's CPU cost on every request. Keys created by older versions with bcrypt still verify, and are moved to the new format the first time they are used. -**File:** `client/python/authcell.py` -**Dependencies:** None (uses `urllib.request`) +--- -**Example** -``` python -from client.python.authcell import AuthCellClient +## Authentication -client = AuthCellClient() # uses AUTHCELL_URL or defaults to http://localhost:8080/v1 +| Endpoint | Auth | +| --- | --- | +| `GET /v1/health` | none | +| `POST /v1/keys/verify` | none, or `AUTHCELL_VERIFY_TOKEN` when set (the admin token also works) | +| everything else | `Authorization: Bearer ` | -key = client.create*key(key_prefix="demo*") -print("API Key:", key.api_key) +AuthCell refuses to start without an admin token of at least 32 characters. Keep it on your backend; never ship it to browsers or mobile apps. -result = client.verify_key(key.api_key) -print("Verified:", result.valid) +--- -```` +## API +All responses share one envelope: -### Planned Client SDKs +```json +{ "status": "success", "code": 200, "data": { ... }, "meta": { ... }, "timestamp": 1730000000 } +{ "status": "error", "code": 400, "message": "rate_limit cannot be negative", "timestamp": 1730000000 } +``` -| Language | Status | Description | -| ---------------------- | ----------------------------- | ------------------------------------------------- | -| **Node.js/TypeScript** | ✅ In repo (`client/nodejs/`) | Native fetch API, zero dependencies | -| **Python** | ✅ In repo (`client/python/`) | urllib.request wrapper with auto server detection | +Full Postman collection: [AuthCell on Postman](https://documenter.getpostman.com/view/28328727/2sB3QQJnT4#be11f7c6-14f0-4a50-9d05-a726046306fa) + +### Keys + +| Method | Path | Description | +| --- | --- | --- | +| `POST` | `/v1/keys` | Create a key | +| `GET` | `/v1/keys` | List keys. Query: `limit` (max 500), `offset`, `active=true\|false`, `prefix` | +| `GET` | `/v1/keys/:id` | Get one key by its UUID | +| `PATCH` | `/v1/keys/:id` | Update `name`, `metadata`, `expires_at` (`null` removes it), `rate_limit` | +| `DELETE` | `/v1/keys/:id` | Revoke a key (idempotent) | +| `POST` | `/v1/keys/:id/rotate` | Issue a new key with the same settings and revoke the old one | +| `POST` | `/v1/keys/verify` | Verify a key | + +**Create** body (all fields optional): + +```json +{ + "key_prefix": "live", + "name": "billing-service", + "metadata": { "team": "payments", "plan": "pro" }, + "expires_at": "2027-01-01T00:00:00Z", + "rate_limit": 1000 +} +``` ---- +`rate_limit` is the number of successful verifications allowed per `RATE_LIMIT_WINDOW` (default one minute). `0` means unlimited, and is the default. -### Example ENV Configuration for Clients +Create and rotate return the key record plus `api_key`, the plaintext key. It is not stored and cannot be shown again. -```bash -export AUTHCELL_URL="https://authcell.mydomain.com/v1" -```` +**Verify** takes `{"api_key": "..."}` or an `X-API-Key` header. -The client will automatically resolve this; no manual configuration required. +| Status | `data` | +| --- | --- | +| `200` | `{"valid": true, "id", "key_id", "key_prefix", "name", "metadata", "expires_at", "rate_limit": {"limit", "remaining", "reset"} \| null}` | +| `401` | `{"valid": false, "reason": "invalid" \| "revoked" \| "expired"}` | +| `429` | `{"valid": false, "reason": "rate_limited"}` plus a `Retry-After` header | +| `400` | missing `api_key` | -## API Endpoints +`reason` is only more specific than `invalid` once the caller has proven they hold the real key, so verify never reveals which keys exist. Rate-limited keys also get `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers. -API Documentation: [View in Postman Documenter](https://documenter.getpostman.com/view/28328727/2sB3QQJnT4#be11f7c6-14f0-4a50-9d05-a726046306fa) +### Usage and audit -### Health +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/v1/usage/:id` | Verification attempts for a key, newest first | +| `GET` | `/v1/audit` | Admin events (`create_key`, `update_key`, `revoke_key`, `rotate_key`). Filter with `event_type` | -``` -GET /v1/health -→ Returns database and service health. -``` +Both take `limit` and `before`. To page, pass `meta.next_before` from the previous response as `before`. -### API Key Management +Usage rows older than `USAGE_RETENTION_DAYS` (default 90) are deleted hourly. -| Method | Path | Description | -| ------ | ----------------- | --------------------------------- | -| POST | `/v1/keys` | Create a new API key | -| GET | `/v1/keys/:id` | Retrieve key metadata by UUID | -| POST | `/v1/keys/verify` | Verify validity of a provided key | -| DELETE | `/v1/keys/:id` | Revoke an API key | - -### Usage and Audit +### Health -| Method | Path | Description | -| ------ | --------------- | --------------------------------------------- | -| GET | `/v1/usage/:id` | Retrieve usage logs for a specific key | -| GET | `/v1/audit` | Retrieve a chronological list of audit events | +`GET /v1/health` returns `200` with pool stats when the database answers and `503` when it doesn't. The binary also has a built-in probe (`authcell -healthcheck`) that the Docker image uses. --- -## Security Details +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `AUTHCELL_ADMIN_TOKEN` | required | Bearer token for management endpoints, 32+ chars | +| `AUTHCELL_VERIFY_TOKEN` | empty | If set, required for `/v1/keys/verify` | +| `PORT` | `8080` | HTTP port | +| `APP_ENV` | `production` | Shown in logs | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `DATABASE_URL` | empty | Full Postgres URL. Overrides the `DB_*` values | +| `DB_HOST` / `DB_PORT` | `localhost` / `5432` | | +| `DB_DATABASE` | `postgres` | | +| `DB_USERNAME` / `DB_PASSWORD` | `postgres` / empty | Special characters are escaped for you | +| `DB_SCHEMA` | `public` | | +| `DB_SSLMODE` | `disable` | Use `require` or `verify-full` for remote databases | +| `DB_MAX_CONNS` / `DB_MIN_CONNS` | `25` / `2` | Connection pool size | +| `RATE_LIMIT_WINDOW` | `1m` | Window for per-key `rate_limit` | +| `USAGE_RETENTION_DAYS` | `90` | `0` keeps usage rows forever | +| `REQUEST_TIMEOUT` | `10s` | Per-request database timeout | +| `CORS_ORIGINS` | empty | Comma-separated origins. Empty disables CORS | +| `TRUSTED_PROXIES` | empty | Proxy IPs/CIDRs whose `X-Forwarded-For` is trusted for client IPs | -### 1. Key Hashing (bcrypt) - -All API keys are irreversibly hashed with **bcrypt** before being stored. -This ensures that raw keys are **never persisted** in the database and cannot be recovered — only verified. +--- -### 2. Unique Key ID for O(1) Validation +## Client libraries -Each key includes a unique 8‑character `key_id` (randomized via crypto/rand). -Verification queries directly by `key_id`, resulting in: +Both clients read `AUTHCELL_URL` (default `http://localhost:8080/v1`), `AUTHCELL_ADMIN_TOKEN` and `AUTHCELL_VERIFY_TOKEN` from the environment, or take them as arguments. `verifyKey` / `verify_key` never throws for a bad key; check `.valid`. Other calls throw `AuthCellError` with `status` and `message`. -- Constant‑time lookups -- No prefix collisions -- No multi‑row scans +### Node.js / TypeScript -**Example key format:** +`client/nodejs`, Node 18+, no runtime dependencies. Build with `npm install && npm run build`. -``` -demo_4d83e91f_98d9c77a-b067-4723-9325-70f59dfd448b -``` +```typescript +import { AuthCellClient } from "./client/nodejs/dist/index.js"; -Where: +const authcell = new AuthCellClient(); // or new AuthCellClient({ baseUrl, adminToken }) -- `demo_` → Environment or custom prefix -- `4d83e91f` → Unique indexed `key_id` -- `98d9c77a…` → UUID for entropy +const key = await authcell.createKey({ key_prefix: "live", name: "billing", rate_limit: 1000 }); +console.log("Give this to the customer once:", key.api_key); -### 3. Database Protection +const result = await authcell.verifyKey(key.api_key); +if (!result.valid) console.log("rejected:", result.reason); -The `api_key` table defines: +await authcell.rotateKey(key.id); +``` -- `key_hash` (secured via bcrypt) -- `key_prefix` and `key_id` (metadata only) -- `UNIQUE INDEX` on `key_id` for deterministic queries -- `is_active`, `expires_at`, and `rate_limit` fields for policy enforcement +### Python -### 4. Audit and Usage Logging +`client/python/authcell.py`, Python 3.8+, standard library only. -Every key event — creation, verification, or revocation — is recorded in: +```python +from client.python.authcell import AuthCellClient -- `audit_log` table (action, IP, event data, timestamp) -- `api_usage` table (key ID, endpoint, HTTP status, remote IP) +authcell = AuthCellClient() # or AuthCellClient(base_url=..., admin_token=...) -This provides complete traceability for compliance and debugging. +key = authcell.create_key(key_prefix="live", name="billing", rate_limit=1000) +print("Give this to the customer once:", key.api_key) ---- +result = authcell.verify_key(key.api_key) +if not result.valid: + print("rejected:", result.reason) -AuthCell is intentionally simpler than enterprise products — no dashboards, billing logic, or tenant management. -Its single goal is **secure key verification**, optimized for embedded or internal API use cases. +authcell.revoke_key(key.id) +``` --- -## Deployment - -### Using Docker Compose +## Development ```bash -docker compose up --build +make run # run locally (reads .env) +make watch # live reload with air +make test # unit tests; integration tests skip without a database +make itest # unit + integration tests against Postgres (TEST_DB=postgres://...) +make lint # gofmt + go vet +make build # ./main with version baked in ``` -On first startup, AuthCell: +Integration tests create and drop their own schema, so they are safe to point at a shared dev database. CI runs them against a Postgres service container. -1. Connects to PostgreSQL -2. Automatically migrates all necessary tables -3. Starts the REST server on port `8080` +Releases are built by GoReleaser when a `v*.*.*` tag is pushed. --- -## Environment Variables +## Upgrading from earlier versions -| Variable | Description | Default | -| ------------- | ----------------- | ----------- | -| `DB_HOST` | Database hostname | `localhost` | -| `DB_PORT` | Database port | `5432` | -| `DB_USERNAME` | Database user | — | -| `DB_PASSWORD` | Database password | — | -| `DB_DATABASE` | Database name | `postgres` | -| `DB_SCHEMA` | Database schema | `public` | -| `PORT` | Web server port | `8080` | - ---- +Start the new version against your existing database. Migrations run automatically and: -## Example Usage +- add `name`, `metadata`, `revoked_at`, `last_used_at` and rate-limit window columns to `api_key` +- make `is_active`, `created_at`, `rate_limit` and `usage_count` non-null (existing nulls are filled) +- change `audit_log.actor_id` from `uuid` to `text` +- add indexes for usage and audit lookups -**Create a new API key** +Behaviour changes to know about: -```bash -curl -X POST http://localhost:8080/v1/keys \ - -H "Content-Type: application/json" \ - -d '{"key_prefix":"demo_","rate_limit":1000}' -``` - -**Verify** - -```bash -curl -X POST http://localhost:8080/v1/keys/verify \ - -H "Content-Type: application/json" \ - -d '{"api_key":"demo_4d83e91f_98d9c77a-b067-4723-9325-70f59dfd448b"}' -``` +- All management endpoints now need the admin token. Update your clients. +- `rate_limit` is now enforced. Existing keys keep their stored value; set it to `0` with `PATCH` for unlimited. +- Old `demo_xxxxxxxx_` keys keep working and are rehashed on first use. Keys created by the old version with a prefix longer than 27 characters were stored with an empty hash due to bcrypt's 72-byte limit and never worked. Rotate or recreate those. +- `key_hash` is no longer returned by any endpoint. --- -### TL;DR - -AuthCell is a **self‑hosted, developer‑first** API key service — -no external dependencies, no telemetry, no hidden databases. -It’s small enough to embed directly into your own product yet secure enough to protect live production APIs. +AuthCell does one thing: secure key verification for your own APIs. There are no dashboards, billing or tenants, by design. From a89ace8c029af718f77dd0c62057a7b993d9f361 Mon Sep 17 00:00:00 2001 From: aw Date: Wed, 28 Oct 2026 00:26:11 +0400 Subject: [PATCH 8/8] build: go mod tidy --- go.sum | 179 +-------------------------------------------------------- 1 file changed, 2 insertions(+), 177 deletions(-) diff --git a/go.sum b/go.sum index 6d4fe75..a9d4a88 100644 --- a/go.sum +++ b/go.sum @@ -1,62 +1,14 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= -github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -67,164 +19,37 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= -github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= -github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= -github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= -github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= -github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0 h1:REJz+XwNpGC/dCgTfYvM4SKqobNqDBfvhq74s2oHTUM= -github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0/go.mod h1:4K2OhtHEeT+JSIFX4V8DkGKsyLa96Y2vLdd3xsxD5HE= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=