Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ APP_URL=http://localhost:8080
# any user by knowing their email. Leave false (or unset) in production.
DEV_MODE=true

# Bootstrap owner: the user who signs in with this email is (and stays) a global
# admin. Deterministic — set this to your email before first sign-in. When empty,
# no admin is auto-assigned.
ADMIN_EMAIL=admin@example.com

# Log handler: "text" (default, human-readable) or "json" (for log shippers).
LOG_FORMAT=text

# ==========================================
# Database (Postgres)
# ==========================================
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ go run ./cmd/server # migrates + seeds + serves :8080
```

Open <http://localhost:8080>. With `DEV_MODE=true`, sign in via the dev-login on
the login page (or `GET /auth/dev-login?email=you@example.com`). The **first user
to sign in becomes admin**; everyone else starts as a member.
the login page (or `GET /auth/dev-login?email=you@example.com`). The user whose
email matches **`ADMIN_EMAIL`** is the admin; everyone else starts as a member.

---

Expand All @@ -184,8 +184,10 @@ to sign in becomes admin**; everyone else starts as a member.
| `APP_URL` | `http://localhost:8080` | external base URL |
| `DATABASE_URL` | local compose DSN | Postgres connection string |
| `SESSION_SECRET` | dev fallback if `DEV_MODE` | required in prod; signs cookies |
| `ADMIN_EMAIL` | empty | the user with this email is (and stays) the global admin; deterministic bootstrap |
| `GOOGLE_CLIENT_ID` / `_SECRET` / `_REDIRECT_URL` | — | OAuth |
| `GEMINI_API_KEY` | empty | empty → moderation no-op + non-AI fallback |
| `LOG_FORMAT` | `text` | `text` or `json` (structured logs for shippers) |
| `DEV_MODE` | `false` | enables `/auth/dev-login`, relaxes `Secure`, dev fallbacks. **Never true in prod.** |

---
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ non-AI combine that stitches the raw answers together honestly.

| Role | Can do |
|------|--------|
| **Admin** | Everything — manage users + teams, create any round, view all rounds, generate and share any consolidation. First user to sign in is auto-promoted. |
| **Admin** | Everything — manage users + teams, create any round, view all rounds, generate and share any consolidation. Bootstrapped via `ADMIN_EMAIL`. |
| **Team Admin** | Manage their team's members; create and manage rounds. |
| **Member** | Submit feedback when assigned as reviewer, read consolidations shared with them (in-app + PDF). |

Expand Down Expand Up @@ -134,7 +134,7 @@ go run ./cmd/server # migrates + seeds + serves :8080

Open <http://localhost:8080>. With `DEV_MODE=true` you can sign in from the login
page's dev-login (or `GET /auth/dev-login?email=you@example.com`) without Google
credentials. **The first user to sign in becomes admin.**
credentials. **Set `ADMIN_EMAIL` to your email** — that account is the admin.

### Full stack in Docker

Expand All @@ -159,6 +159,8 @@ runs). See [`.env.example`](.env.example) for the annotated list.
| `APP_URL` | No | `http://localhost:8080` | External base URL. |
| `DATABASE_URL` | Yes | local compose DSN | Postgres connection string. |
| `SESSION_SECRET` | **Yes** (prod) | dev fallback if `DEV_MODE` | Signs session cookies. Generate with `openssl rand -hex 32`. |
| `ADMIN_EMAIL` | Recommended | — | The user who signs in with this email is the global admin (deterministic bootstrap). |
| `LOG_FORMAT` | No | `text` | `text` or `json` (structured logs for a log shipper). |
| `GOOGLE_CLIENT_ID` | For Google login | — | OAuth client ID. |
| `GOOGLE_CLIENT_SECRET` | For Google login | — | OAuth client secret. |
| `GOOGLE_REDIRECT_URL` | No | `http://localhost:8080/auth/callback` | Must match the Google Cloud Console redirect URI. |
Expand Down
25 changes: 22 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,30 @@ What helps triage move quickly:
never travel in a URL. Sessions are revocable and expire server-side.
- Every state-changing request (POST/PUT/DELETE) is **CSRF-protected** with a
per-session token (`X-CSRF-Token` header for htmx, hidden `csrf_token` field
for forms).
for forms). The one state-changing GET (the consolidation/log SSE streams) is
guarded by a separate session-derived stream token.
- **Admin bootstrap** is deterministic: the user whose email matches
`ADMIN_EMAIL` is (and stays) the global admin. There is no "first user wins"
race. All other role changes go through the admin Users page, which refuses to
demote the last admin.
- `dev-login` (which bypasses OAuth) is only mounted when `DEV_MODE=true` and is
the single most important thing to keep disabled in production.

See [ADR-0004](docs/adr/0004-session-cookie-auth.md) for the rationale.

## Transport & browser hardening

- **Security headers** on every response: a strict `Content-Security-Policy`
(`script-src 'self'` — all JS is self-hosted, no inline scripts),
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive
`Permissions-Policy`.
- **Rate limiting** (per-IP, in-memory): tight on auth endpoints, a cap on
feedback submissions, and a backstop on all authenticated routes. A reverse
proxy should still throttle in front for production.
- Static analysis: `govulncheck` and `gosec` run clean (reviewed false positives
are annotated with `#nosec` + justification).

---

## Anonymity & privacy by design
Expand Down Expand Up @@ -150,8 +168,9 @@ Things the application can't enforce on your behalf:
- [ ] Terminate TLS at a reverse proxy (Caddy / nginx + Let's Encrypt). OAuth
requires HTTPS in production; set `APP_URL` / `GOOGLE_REDIRECT_URL` to the
public HTTPS URLs.
- [ ] Throttle abusive traffic at the reverse proxy (`rate_limit` in Caddy,
`limit_req` in nginx).
- [ ] Set `ADMIN_EMAIL` to the owner's address before first sign-in.
- [ ] The app rate-limits per IP already; for extra depth, also throttle at the
reverse proxy (`rate_limit` in Caddy, `limit_req` in nginx).
- [ ] Schedule an off-host `pg_dump` backup.
- [ ] **Never** set `DEV_MODE=true` in production — it unlocks `dev-login` and
relaxes the Secure cookie flag.
35 changes: 30 additions & 5 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
Expand All @@ -17,6 +19,7 @@ import (
"github.com/mondial7/smart-360/internal/config"
"github.com/mondial7/smart-360/internal/db"
"github.com/mondial7/smart-360/internal/handlers"
"github.com/mondial7/smart-360/internal/logstream"
"github.com/mondial7/smart-360/internal/repo"
"github.com/mondial7/smart-360/internal/view"
"github.com/mondial7/smart-360/web"
Expand Down Expand Up @@ -48,6 +51,19 @@ func run() error {
return err
}

// Logging: slog (text or JSON) tee'd to stderr and the in-memory log hub
// that backs the admin Logs page. The standard logger is redirected to the
// same writer so chi's request logs and any log.Print calls are captured too.
logs := logstream.New(500)
logOut := io.MultiWriter(os.Stderr, logs)
slog.SetDefault(slog.New(logHandler(cfg.LogFormat, logOut)))
log.SetFlags(0)
log.SetOutput(logOut)

if cfg.AdminEmail == "" {
slog.Warn("ADMIN_EMAIL is not set — no admin will be auto-assigned; set it to bootstrap the owner")
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

Expand All @@ -60,7 +76,7 @@ func run() error {
if err := db.Migrate(ctx, pool); err != nil {
return err
}
log.Println("migrations applied")
slog.Info("migrations applied")

if err := db.Seed(ctx, pool, cfg.DevMode); err != nil {
return err
Expand All @@ -72,7 +88,7 @@ func run() error {
return err
}
authSvc := auth.New(cfg, repos)
h := handlers.New(repos, authSvc, renderer, cfg)
h := handlers.New(repos, authSvc, renderer, cfg, logs)

srv := &http.Server{
Addr: ":" + cfg.Port,
Expand All @@ -81,17 +97,26 @@ func run() error {
}

go func() {
log.Printf("listening on :%s (dev_mode=%v)", cfg.Port, cfg.DevMode)
slog.Info("listening", "addr", ":"+cfg.Port, "dev_mode", cfg.DevMode)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
slog.Error("server error", "err", err)
stop()
}
}()

<-ctx.Done()
log.Println("shutting down")
slog.Info("shutting down")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}

// logHandler builds an slog handler for the configured format.
func logHandler(format string, w io.Writer) slog.Handler {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
if format == "json" {
return slog.NewJSONHandler(w, opts)
}
return slog.NewTextHandler(w, opts)
}
28 changes: 28 additions & 0 deletions cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func newRouter(cfg *config.Config, authSvc *auth.Service, h *handlers.Handlers)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)

// Per-IP rate limits (in-memory). These are a backstop against brute force
// and abuse; a production deployment behind a proxy should still throttle
Expand Down Expand Up @@ -62,3 +63,30 @@ func newRouter(cfg *config.Config, authSvc *auth.Service, h *handlers.Handlers)

return r
}

// contentSecurityPolicy is intentionally strict: all scripts are self-hosted
// (htmx, sse, app.js), so no 'unsafe-inline' for scripts. Inline style="…"
// attributes in templates require 'unsafe-inline' for styles only. SSE is
// same-origin (connect-src 'self').
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self'; " +
"font-src 'self'; " +
"form-action 'self'; " +
"base-uri 'self'; " +
"frame-ancestors 'none'"

// securityHeaders sets defensive response headers on every response.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
next.ServeHTTP(w, r)
})
}
2 changes: 2 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-smart360}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-smart360}?sslmode=disable
SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET}
APP_URL: ${APP_URL}
ADMIN_EMAIL: ${ADMIN_EMAIL:?set ADMIN_EMAIL to bootstrap the admin}
LOG_FORMAT: ${LOG_FORMAT:-json}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-smart360}:${POSTGRES_PASSWORD:-smart360}@postgres:5432/${POSTGRES_DB:-smart360}?sslmode=disable
SESSION_SECRET: ${SESSION_SECRET}
APP_URL: ${APP_URL:-http://localhost:8080}
ADMIN_EMAIL: ${ADMIN_EMAIL}
LOG_FORMAT: ${LOG_FORMAT:-text}
GEMINI_API_KEY: ${GEMINI_API_KEY}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
Expand Down
11 changes: 10 additions & 1 deletion docs/deployment-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,15 @@ DATABASE_URL=postgres://smart360:<password>@localhost:5432/smart360?sslmode=disa

SESSION_SECRET=<run: openssl rand -hex 32>

# The account that signs in with this email becomes the admin. Set it before
# first sign-in.
ADMIN_EMAIL=you@example.com

APP_URL=https://feedback.example.com

# Structured logs for a shipper (optional): text | json
LOG_FORMAT=json

GOOGLE_CLIENT_ID=<from Google Cloud Console>
GOOGLE_CLIENT_SECRET=<from Google Cloud Console>
GOOGLE_REDIRECT_URL=https://feedback.example.com/auth/callback
Expand Down Expand Up @@ -197,7 +204,9 @@ page.
sudo systemctl restart smart360
```

Sign in. The first user to authenticate is promoted to **Administrator**.
Sign in with the address you set as `ADMIN_EMAIL` — that account is the
**Administrator**. Everyone else is a member until you promote them from the
Users page.

---

Expand Down
28 changes: 28 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ func issueCookie(t *testing.T, s *Service, userID string) *http.Cookie {
return nil
}

func TestAdminEmailBootstrap(t *testing.T) {
ctx := context.Background()
cfg := &config.Config{SessionSecret: "s", DevMode: true, AdminEmail: "Owner@Example.com"}
repos := repo.NewFakes()
s := New(cfg, repos)

// New owner (case-insensitive match) is provisioned as admin.
owner, err := s.upsertUser(ctx, "owner@example.com", "Owner", "")
if err != nil || owner.Role != models.RoleAdmin {
t.Fatalf("expected owner to be admin, got role=%q err=%v", owner.Role, err)
}
// Anyone else is a member.
other, _ := s.upsertUser(ctx, "someone@example.com", "Someone", "")
if other.Role != models.RoleMember {
t.Fatalf("expected member, got %q", other.Role)
}

// Self-healing: an owner account that predates ADMIN_EMAIL (or was demoted)
// is promoted back to admin on next login.
demoted := &models.User{Email: "owner2@example.com", Role: models.RoleMember}
_ = repos.Users.Create(ctx, demoted)
cfg.AdminEmail = "owner2@example.com"
got, err := s.upsertUser(ctx, "owner2@example.com", "Owner Two", "")
if err != nil || got.Role != models.RoleAdmin {
t.Fatalf("expected self-healing promotion to admin, got role=%q err=%v", got.Role, err)
}
}

func TestSessionRoundTrip(t *testing.T) {
s, repos := newTestService(t)
u := &models.User{Email: "a@example.com", Name: "Ann", Role: models.RoleAdmin}
Expand Down
Loading
Loading