first commit

This commit is contained in:
2026-08-24 13:06:29 +05:30
commit 7d12ebef3d
86 changed files with 39996 additions and 0 deletions

135
go-api/cmd/api/main.go Normal file
View File

@@ -0,0 +1,135 @@
// Command api is the Krow HTTP API.
//
// Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints,
// and the entity and current-user endpoints described in docs/api-contract.md.
//
// Every request outside the public allowlist carries a session cookie that this
// process resolves to a real user. The development organization that used to be
// injected into every request is gone: identity now comes from the sessions
// table, and a database with no users is a database nobody can sign in to,
// which is the correct behaviour rather than a gap.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/httpserver"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := newLogger(cfg.Log.Level)
log.Info("starting krow-api", "env", cfg.AppEnv, "database", cfg.DB.Redacted())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := db.Open(ctx, cfg.DB)
if err != nil {
return err
}
defer database.Close()
log.Info("database connected", "schema", cfg.DB.Schema)
server, err := httpserver.New(cfg, database, log)
if err != nil {
return err
}
// The sweeper's context is cancelled by the same signal that stops the
// server, so the ticker goes away with the process rather than outliving
// the pool it queries.
go sweepSessions(ctx, server.Sessions(), log)
errCh := make(chan error, 1)
go func() { errCh <- server.Start() }()
log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(),
"health", "http://"+server.Addr()+"/health",
"cors_origins", cfg.HTTP.CORSOrigins)
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Info("shutdown signal received, draining")
// context.Background: ctx is already cancelled, and Shutdown needs a
// live deadline of its own to drain in-flight requests.
return server.Shutdown(context.Background())
}
}
// sweepInterval is how often dead sessions are collected.
//
// Sweeping is housekeeping, not correctness: Manager.Authenticate already
// refuses an expired session and deletes the row as it finds it, so a session
// is never usable between its expiry and the next sweep. This only collects the
// rows nobody comes back for. Fifteen minutes keeps the table from growing
// without putting a DELETE on any hot path.
const sweepInterval = 15 * time.Minute
// sweepSessions deletes expired sessions until the context is cancelled.
//
// It runs once immediately so a process that has been down for a while does not
// carry a backlog for a further fifteen minutes, then on the ticker. A failed
// sweep is logged and retried at the next tick: the table being briefly larger
// than it should be is not worth stopping the API for.
func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
sweep := func() {
// A deadline of its own, so a slow or wedged DELETE cannot leave this
// goroutine blocked past shutdown.
sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := sessions.Sweep(sweepCtx)
switch {
case err != nil && ctx.Err() != nil:
// Shutting down; the cancellation is expected, not a failure.
case err != nil:
log.Warn("session sweep failed", "error", err)
case n > 0:
log.Info("swept expired sessions", "deleted", n)
default:
log.Debug("session sweep found nothing to delete")
}
}
sweep()
for {
select {
case <-ctx.Done():
log.Debug("session sweeper stopped")
return
case <-ticker.C:
sweep()
}
}
}
func newLogger(level string) *slog.Logger {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
lvl = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}

75
go-api/cmd/seed/main.go Normal file
View File

@@ -0,0 +1,75 @@
// Command seed loads the frontend's demo dataset into PostgreSQL.
//
// Safe to run repeatedly: every record's key is derived deterministically from
// its source id and written with ON CONFLICT DO UPDATE inside one transaction,
// so re-running restores the seeded values without duplicating a row or
// deleting anything. See internal/seeder.
//
// make seed
package main
import (
"context"
"fmt"
"os"
"sort"
"time"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/seeder"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "seed failed:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
fixture, err := seeder.Load(cfg.Seed.FixturePath)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
database, err := db.Open(ctx, cfg.DB)
if err != nil {
return err
}
defer database.Close()
started := time.Now()
result, err := seeder.New(database.Pool, fixture, time.Now()).Run(ctx)
if err != nil {
return err
}
names := make([]string, 0, len(result.Counts))
total := 0
for name, n := range result.Counts {
names = append(names, name)
total += n
}
sort.Strings(names)
fmt.Printf("seeded into %s (organization %s)\n", cfg.DB.Name, result.OrgID)
for _, name := range names {
fmt.Printf(" %-16s %4d\n", name, result.Counts[name])
}
fmt.Printf(" %-16s %4d records in %s\n", "TOTAL", total, time.Since(started).Round(time.Millisecond))
if result.Pruned > 0 {
// Shift records are a rolling window; ones that fell out of it are
// removed. Said out loud, because a seed that deletes should say so.
fmt.Printf(" %-16s %4d stale shift record(s) outside the current window\n", "PRUNED", result.Pruned)
}
return nil
}

View File

@@ -0,0 +1,259 @@
// Command setpassword sets a user's password.
//
// It exists because migration 000001 left users.password_hash nullable and
// NULL, and the seeded demo user still has no password. Nothing in the seed
// fixture, the migrations or this repository contains, generates or defaults a
// password: a password enters the system here, typed by a person, and nowhere
// else.
//
// # prompt for the password, twice, with the input hidden
// cd go-api && go run ./cmd/setpassword -email demo@krow.app
// cd go-api && go run ./cmd/setpassword -id 9a1f...-uuid
//
// # non-interactive, for a provisioning script — the password arrives on
// # stdin, never in argv, so it does not reach `ps` or the shell history
// printf '%s' "$NEW_PASSWORD" | go run ./cmd/setpassword -email demo@krow.app -stdin
//
// There is deliberately no -password flag. A password in argv is visible to
// every process on the machine through `ps`, and lands in the shell history
// besides. stdin is the only non-interactive route.
//
// The password, the confirmation and the resulting hash are never printed,
// never logged and never written anywhere but the users.password_hash column,
// through a bind parameter.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/term"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
)
func main() {
if err := run(); err != nil {
// The error strings in this file name rules and identifiers only. No
// path here can carry the password into this line.
fmt.Fprintf(os.Stderr, "setpassword: %v\n", err)
os.Exit(1)
}
}
type target struct {
id string
email string
role string
hadHash bool
}
func run() error {
var (
email = flag.String("email", "", "the user's email address")
id = flag.String("id", "", "the user's UUID")
fromStdin = flag.Bool("stdin", false, "read the password from stdin instead of prompting")
)
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
"Usage: setpassword (-email <address> | -id <uuid>) [-stdin]\n\n"+
"Sets one user's password, hashed with argon2id. The password is never\n"+
"echoed, printed or logged, and there is no -password flag by design.\n\n")
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() > 0 {
// A bare argument is most likely someone typing the password after the
// command. Refuse loudly rather than ignoring it — and say nothing
// about what the argument was.
return errors.New("unexpected positional argument; pass -email or -id, and supply the password when prompted")
}
if (*email == "") == (*id == "") {
return errors.New("pass exactly one of -email or -id")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
database, err := db.Open(ctx, cfg.DB)
if err != nil {
return err
}
defer database.Close()
// Resolve and show the target BEFORE asking for a password, so nobody
// types a secret at a prompt that turns out to be pointed at the wrong
// user, or at no user at all.
t, err := resolve(ctx, database, *email, *id)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "database: %s\nuser: %s <%s>\nrole: %s\npassword: %s\n\n",
cfg.DB.Name, t.id, t.email, t.role, existingState(t.hadHash))
password, err := readPassword(*fromStdin)
if err != nil {
return err
}
// The plaintext lives in this slice and nowhere else. Wipe it as soon as
// the hash exists. Go's garbage collector may still have copied it, so
// this is a reduction in exposure rather than a guarantee — worth doing,
// not worth trusting.
defer wipe(password)
if err := auth.ValidatePassword(string(password)); err != nil {
return describePolicy(err)
}
hash, err := auth.HashPassword(string(password))
if err != nil {
return err
}
// Parameterized, and keyed by the UUID resolved above rather than by the
// string the operator typed. Neither the hash nor the password is ever
// interpolated into SQL.
const q = `UPDATE users SET password_hash = $2::text, updated_date = now() WHERE id = $1::uuid`
tag, err := database.Pool.Exec(ctx, q, t.id, hash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("expected to update exactly one user, updated %d", tag.RowsAffected())
}
// Confirms the identity and nothing about the secret: no hash, no length,
// no prefix.
fmt.Fprintf(os.Stderr, "password set for %s (%s)\n", t.email, t.id)
return nil
}
func existingState(had bool) string {
if had {
return "already set (it will be replaced)"
}
return "not set yet"
}
// resolve finds exactly one user by email or by id.
//
// Email lookup relies on the citext column, so it is case-insensitive, and on
// the global unique index added by migration 000004, so it cannot match two
// users in two organizations.
func resolve(ctx context.Context, database *db.DB, email, id string) (target, error) {
var (
t target
err error
)
if email != "" {
const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL
FROM users WHERE email = $1::citext`
err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(email)).
Scan(&t.id, &t.email, &t.role, &t.hadHash)
} else {
const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL
FROM users WHERE id = $1::uuid`
err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(id)).
Scan(&t.id, &t.email, &t.role, &t.hadHash)
}
if errors.Is(err, pgx.ErrNoRows) {
return t, errors.New("no such user")
}
if err != nil {
return t, fmt.Errorf("look up user: %w", err)
}
return t, nil
}
// readPassword collects the password without echoing it.
//
// Interactively it asks twice and compares, because a mistyped password that
// nobody can see is otherwise only discovered at the next login. With -stdin
// it reads the stream verbatim, minus one trailing newline, so
// `printf '%s' "$P" | setpassword -stdin` and a here-string both work.
func readPassword(fromStdin bool) ([]byte, error) {
if fromStdin {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read password from stdin: %w", err)
}
return trimOneNewline(raw), nil
}
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
// Falling back to an echoing read here would print the password to the
// screen and into any transcript. Refuse and name the flag instead.
return nil, errors.New("stdin is not a terminal; re-run with -stdin to read the password from the pipe")
}
fmt.Fprint(os.Stderr, "New password: ")
first, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, fmt.Errorf("read password: %w", err)
}
fmt.Fprint(os.Stderr, "Confirm password: ")
second, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
wipe(first)
return nil, fmt.Errorf("read confirmation: %w", err)
}
defer wipe(second)
if string(first) != string(second) {
wipe(first)
return nil, errors.New("the two entries do not match")
}
return first, nil
}
// describePolicy turns a policy error into advice, still without quoting the
// password or revealing its length.
func describePolicy(err error) error {
switch {
case errors.Is(err, auth.ErrEmptyPassword):
return errors.New("the password is empty")
case errors.Is(err, auth.ErrPasswordTooShort):
return fmt.Errorf("the password is too short; it must be at least %d bytes", auth.MinPasswordLength)
case errors.Is(err, auth.ErrPasswordTooLong):
return fmt.Errorf("the password is too long; the maximum is %d bytes", auth.MaxPasswordLength)
}
return err
}
// trimOneNewline removes a single trailing "\n" or "\r\n", and only one: a
// password may legitimately end in whitespace, so this strips the line
// terminator a shell adds and nothing more.
func trimOneNewline(b []byte) []byte {
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
if n := len(b); n > 0 && b[n-1] == '\r' {
b = b[:n-1]
}
}
return b
}
func wipe(b []byte) {
for i := range b {
b[i] = 0
}
}