chore: scaffold database tooling

This commit is contained in:
Ruidy 2025-09-20 19:18:30 +02:00
parent 19d94a6349
commit 49d3722b44
No known key found for this signature in database
GPG key ID: 705C24D202990805
10 changed files with 200 additions and 16 deletions

View file

@ -40,3 +40,45 @@
- Add structured logging: text encoder for development, JSON for production deployments. - Add structured logging: text encoder for development, JSON for production deployments.
- Consolidate templates with a base layout to remove duplication across pages. - Consolidate templates with a base layout to remove duplication across pages.
- Introduce configuration loading that sources environment variables, validates them, and exposes typed settings at startup. - Introduce configuration loading that sources environment variables, validates them, and exposes typed settings at startup.
## Database Roadmap
- **users**
- `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`
- `email citext NOT NULL UNIQUE`
- `display_name text`
- `created_at timestamptz NOT NULL DEFAULT now()`
- `updated_at timestamptz NOT NULL DEFAULT now()`
- **user_passwords**
- `user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE`
- `password_hash bytea NOT NULL`
- `password_salt bytea NOT NULL`
- `algorithm text NOT NULL`
- `created_at timestamptz NOT NULL DEFAULT now()`
- `updated_at timestamptz NOT NULL DEFAULT now()`
- **user_oauth_accounts**
- `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`
- `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `provider text NOT NULL`
- `subject text NOT NULL`
- `email text`
- `email_verified boolean NOT NULL DEFAULT false`
- `profile jsonb`
- `created_at timestamptz NOT NULL DEFAULT now()`
- `updated_at timestamptz NOT NULL DEFAULT now()`
- Indexes: `UNIQUE(provider, subject)` and `INDEX(user_id)`
- **login_events**
- `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`
- `user_id uuid REFERENCES users(id)`
- `provider text`
- `success boolean NOT NULL`
- `ip inet`
- `user_agent text`
- `created_at timestamptz NOT NULL DEFAULT now()`
- Indexes: `INDEX(user_id)`, `INDEX(created_at)`
Notes:
- All timestamps default to UTC via `now()`.
- Authentication data stays normalized; optional password/OAuth records live in dedicated tables for clarity and extension.
- `login_events` remains a standard logged table to preserve audit history; revisit storage strategy if write volume demands partitioning later.

View file

@ -1,8 +1,15 @@
BIN_DIR := bin BIN_DIR := bin
BIN_NAME := auth-server BIN_NAME := auth-server
FMT_PATHS := $(shell go list -f '{{.Dir}}' ./...) FMT_PATHS := $(shell go list -f '{{.Dir}}' ./...)
MIGRATIONS_DIR := internal/driver/db/migrations
SQLC_CONFIG := internal/driver/db/sqlc.yaml
DB_URL ?= $(AUTH_DATABASE_URL)
DB_URL := $(strip $(DB_URL))
ifeq ($(DB_URL),)
DB_URL := postgres://localhost/auth_dev?sslmode=disable
endif
.PHONY: run dev build test fmt lint tidy clean .PHONY: run dev build test fmt lint tidy clean migrate-status migrate-up migrate-down migrate-reset migrate-new sqlc-generate
run: run:
go run ./cmd/server go run ./cmd/server
@ -28,3 +35,25 @@ tidy:
clean: clean:
rm -rf $(BIN_DIR) rm -rf $(BIN_DIR)
migrate-status:
goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" status
migrate-up:
goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" up
migrate-down:
goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" down
migrate-reset:
goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" reset
migrate-new:
@if [ -z "$(name)" ]; then \
echo "usage: make migrate-new name=add_feature"; \
exit 1; \
fi
goose -dir $(MIGRATIONS_DIR) create $(name) sql
sqlc-generate:
sqlc generate -f $(SQLC_CONFIG)

View file

@ -20,11 +20,17 @@ templates/assets for single-binary deployment.
2. Use the targets in the [Makefile](./Makefile): 2. Use the targets in the [Makefile](./Makefile):
| Target | Description | | Target | Description |
| ------------ | --------------------------------------------------------------------------------------- | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `make run` | Start the HTTP server with the current environment. | | `make run` | Start the HTTP server with the current environment. |
| `make dev` | Launch [Air](https://github.com/cosmtrek/air) for live reload (requires `air` on PATH). | | `make dev` | Launch [Air](https://github.com/cosmtrek/air) for live reload (requires `air` on PATH). |
| `make build` | Compile to `./bin/auth-server`. | | `make build` | Compile to `./bin/auth-server`. |
| `make test` | Run `go test ./... -cover -count=1`. | | `make test` | Run `go test ./... -cover -count=1`. |
| `make migrate-status` | Show Goose migration status for the configured database. |
| `make migrate-up` | Apply pending migrations to the database at `AUTH_DATABASE_URL` (defaults to `postgres://localhost/auth_dev?sslmode=disable`). |
| `make migrate-down` | Roll back the most recent migration in the target database. |
| `make migrate-reset` | Reset the schema by rolling back all migrations, then re-applying them. |
| `make migrate-new name=` | Create a timestamped SQL migration (e.g. `make migrate-new name=add_users`). |
| `make sqlc-generate` | Regenerate data-access code from SQL queries via `sqlc`. |
3. Visit the login page (default <http://localhost:8000>) and authenticate with 3. Visit the login page (default <http://localhost:8000>) and authenticate with
the demo credentials displayed on screen. the demo credentials displayed on screen.
@ -34,8 +40,9 @@ templates/assets for single-binary deployment.
Settings are sourced from environment variables (see [.env](./.env)). Settings are sourced from environment variables (see [.env](./.env)).
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
| --------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------- | | --------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------ |
| `AUTH_SESSION_SECRET` | Yes | — | Base64-encoded secret used to sign session cookies. | | `AUTH_SESSION_SECRET` | Yes | — | Base64-encoded secret used to sign session cookies. |
| `AUTH_DATABASE_URL` | Yes | — | PostgreSQL connection string (e.g. `postgres://localhost/auth_dev?sslmode=disable`). |
| `AUTH_LISTEN_ADDR` | No | `:8000` | Address the HTTP server binds to. | | `AUTH_LISTEN_ADDR` | No | `:8000` | Address the HTTP server binds to. |
| `AUTH_ENV` | No | `development` | Environment label, controls logger source annotation. | | `AUTH_ENV` | No | `development` | Environment label, controls logger source annotation. |
| `AUTH_LOG_MODE` | No | `text` | Structured log encoder (`text` or `json`). | | `AUTH_LOG_MODE` | No | `text` | Structured log encoder (`text` or `json`). |
@ -43,6 +50,14 @@ Settings are sourced from environment variables (see [.env](./.env)).
| `AUTH_GOOGLE_CLIENT_SECRET` | Conditional | — | Google OAuth 2.0 client secret matching the ID above. | | `AUTH_GOOGLE_CLIENT_SECRET` | Conditional | — | Google OAuth 2.0 client secret matching the ID above. |
| `AUTH_GOOGLE_REDIRECT_URL` | Conditional | — | Registered redirect URL (e.g. `http://localhost:8000/login/google/callback`). | | `AUTH_GOOGLE_REDIRECT_URL` | Conditional | — | Registered redirect URL (e.g. `http://localhost:8000/login/google/callback`). |
## Database Tooling
Migrations live in [`internal/driver/db/migrations`](./internal/driver/db/migrations) and are managed with
[Goose](https://github.com/pressly/goose). Point `AUTH_DATABASE_URL` at your PostgreSQL instance—`postgres://localhost/auth_dev?sslmode=disable`
is a good local default—then use the Makefile helpers (`make migrate-up`, `make migrate-status`, etc.) to evolve the schema. The same DSN drives
[`sqlc`](https://sqlc.dev/) generation with `make sqlc-generate`, which reads [`internal/driver/db/sqlc.yaml`](./internal/driver/db/sqlc.yaml) and
emits typed data-access code alongside the queries.
## Project Layout ## Project Layout
- `cmd/server` — application entrypoint. - `cmd/server` — application entrypoint.

View file

@ -15,6 +15,7 @@ const (
envLogMode = "AUTH_LOG_MODE" envLogMode = "AUTH_LOG_MODE"
envEnvironment = "AUTH_ENV" envEnvironment = "AUTH_ENV"
envSessionSecret = "AUTH_SESSION_SECRET" envSessionSecret = "AUTH_SESSION_SECRET"
envDatabaseURL = "AUTH_DATABASE_URL"
envGoogleClientID = "AUTH_GOOGLE_CLIENT_ID" envGoogleClientID = "AUTH_GOOGLE_CLIENT_ID"
envGoogleClientSecret = "AUTH_GOOGLE_CLIENT_SECRET" envGoogleClientSecret = "AUTH_GOOGLE_CLIENT_SECRET"
envGoogleRedirectURL = "AUTH_GOOGLE_REDIRECT_URL" envGoogleRedirectURL = "AUTH_GOOGLE_REDIRECT_URL"
@ -29,6 +30,7 @@ type Config struct {
LogMode logging.Mode LogMode logging.Mode
Environment string Environment string
SessionSecret []byte SessionSecret []byte
DatabaseURL string
GoogleOAuth GoogleOAuthConfig GoogleOAuth GoogleOAuthConfig
} }
@ -63,6 +65,11 @@ func New() (*Config, error) {
return nil, fmt.Errorf("invalid %s: %w", envSessionSecret, err) return nil, fmt.Errorf("invalid %s: %w", envSessionSecret, err)
} }
databaseURL := strings.TrimSpace(os.Getenv(envDatabaseURL))
if databaseURL == "" {
return nil, fmt.Errorf("missing required configuration: set %s", envDatabaseURL)
}
googleOAuth := GoogleOAuthConfig{ googleOAuth := GoogleOAuthConfig{
ClientID: strings.TrimSpace(os.Getenv(envGoogleClientID)), ClientID: strings.TrimSpace(os.Getenv(envGoogleClientID)),
ClientSecret: strings.TrimSpace(os.Getenv(envGoogleClientSecret)), ClientSecret: strings.TrimSpace(os.Getenv(envGoogleClientSecret)),
@ -78,6 +85,7 @@ func New() (*Config, error) {
LogMode: logMode, LogMode: logMode,
Environment: environment, Environment: environment,
SessionSecret: secret, SessionSecret: secret,
DatabaseURL: databaseURL,
GoogleOAuth: googleOAuth, GoogleOAuth: googleOAuth,
} }

View file

@ -9,6 +9,7 @@ import (
func TestNewDefaults(t *testing.T) { func TestNewDefaults(t *testing.T) {
t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32))) t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32)))
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
cfg, err := New() cfg, err := New()
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@ -30,6 +31,7 @@ func TestNewDefaults(t *testing.T) {
func TestNewOverrides(t *testing.T) { func TestNewOverrides(t *testing.T) {
secret := base64.StdEncoding.EncodeToString(bytesOfLength(40)) secret := base64.StdEncoding.EncodeToString(bytesOfLength(40))
t.Setenv("AUTH_SESSION_SECRET", secret) t.Setenv("AUTH_SESSION_SECRET", secret)
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
t.Setenv("AUTH_LISTEN_ADDR", "127.0.0.1:9000") t.Setenv("AUTH_LISTEN_ADDR", "127.0.0.1:9000")
t.Setenv("AUTH_LOG_MODE", "json") t.Setenv("AUTH_LOG_MODE", "json")
t.Setenv("AUTH_ENV", "production") t.Setenv("AUTH_ENV", "production")
@ -54,6 +56,7 @@ func TestNewOverrides(t *testing.T) {
func TestNewMissingSecret(t *testing.T) { func TestNewMissingSecret(t *testing.T) {
t.Setenv("AUTH_SESSION_SECRET", "") t.Setenv("AUTH_SESSION_SECRET", "")
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
if _, err := New(); err == nil { if _, err := New(); err == nil {
t.Fatalf("expected error for missing secret") t.Fatalf("expected error for missing secret")
} }
@ -61,6 +64,7 @@ func TestNewMissingSecret(t *testing.T) {
func TestNewInvalidSecret(t *testing.T) { func TestNewInvalidSecret(t *testing.T) {
t.Setenv("AUTH_SESSION_SECRET", "not-base64") t.Setenv("AUTH_SESSION_SECRET", "not-base64")
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
if _, err := New(); err == nil { if _, err := New(); err == nil {
t.Fatalf("expected error for invalid secret") t.Fatalf("expected error for invalid secret")
} }
@ -68,6 +72,7 @@ func TestNewInvalidSecret(t *testing.T) {
func TestNewShortSecretAccepted(t *testing.T) { func TestNewShortSecretAccepted(t *testing.T) {
t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(16))) t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(16)))
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
if _, err := New(); err != nil { if _, err := New(); err != nil {
t.Fatalf("expected short secret to pass config load, got %v", err) t.Fatalf("expected short secret to pass config load, got %v", err)
} }
@ -77,6 +82,7 @@ func TestNewGoogleOAuthPartialConfiguration(t *testing.T) {
t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32))) t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32)))
t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client") t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client")
t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "") t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "")
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
if _, err := New(); err == nil { if _, err := New(); err == nil {
t.Fatalf("expected error for partial google oauth config") t.Fatalf("expected error for partial google oauth config")
} }
@ -87,6 +93,7 @@ func TestNewGoogleOAuthConfigured(t *testing.T) {
t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client") t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client")
t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "secret") t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "secret")
t.Setenv("AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8000/login/google/callback") t.Setenv("AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8000/login/google/callback")
t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable")
cfg, err := New() cfg, err := New()
if err != nil { if err != nil {

View file

@ -0,0 +1,6 @@
[goose]
dir = "migrations"
[goose.envs.local]
driver = "postgres"
open = "postgres://localhost/auth_dev?sslmode=disable"

View file

@ -0,0 +1,59 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_passwords (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash BYTEA NOT NULL,
password_salt BYTEA NOT NULL,
algorithm TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_oauth_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT,
email_verified BOOLEAN NOT NULL DEFAULT false,
profile JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX user_oauth_accounts_provider_subject_idx
ON user_oauth_accounts (provider, subject);
CREATE INDEX user_oauth_accounts_user_id_idx
ON user_oauth_accounts (user_id);
CREATE TABLE login_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
provider TEXT,
success BOOLEAN NOT NULL,
ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX login_events_user_id_idx ON login_events (user_id);
CREATE INDEX login_events_created_at_idx ON login_events (created_at);
-- +goose Down
DROP TABLE IF EXISTS login_events;
DROP TABLE IF EXISTS user_oauth_accounts;
DROP TABLE IF EXISTS user_passwords;
DROP TABLE IF EXISTS users;
DROP EXTENSION IF EXISTS citext;
DROP EXTENSION IF EXISTS pgcrypto;

View file

View file

@ -0,0 +1,16 @@
version: "2"
sql:
- engine: "postgresql"
schema: migrations
queries:
- queries
gen:
go:
package: db
out: internal/driver/db/sqlc
emit_json_tags: true
sql_package: pgx/v5
emit_interface: false
overrides:
- db_type: "uuid"
go_type: github.com/google/uuid.UUID

View file

@ -21,6 +21,7 @@ func newTestServer(t *testing.T) *Server {
LogMode: logging.ModeText, LogMode: logging.ModeText,
Environment: "test", Environment: "test",
SessionSecret: bytes.Repeat([]byte("s"), 32), SessionSecret: bytes.Repeat([]byte("s"), 32),
DatabaseURL: "postgres://localhost/auth_test?sslmode=disable",
} }
logger := logging.New(io.Discard, logging.ModeText, nil) logger := logging.New(io.Discard, logging.ModeText, nil)
@ -45,6 +46,7 @@ func newGoogleTestServer(t *testing.T) *Server {
ClientSecret: "secret", ClientSecret: "secret",
RedirectURL: "http://localhost/login/google/callback", RedirectURL: "http://localhost/login/google/callback",
}, },
DatabaseURL: "postgres://localhost/auth_test?sslmode=disable",
} }
logger := logging.New(io.Discard, logging.ModeText, nil) logger := logging.New(io.Discard, logging.ModeText, nil)