Harden forward-auth, per-user isolation, and budget accuracy

Code-review fixes on the Authentik/budget/Resend work:

- UpsertForwardUser: avoid 500 lockout on username UNIQUE collision and
  on the concurrent first-login race; normalize emails (lower+trim) so
  case variants don't create duplicate accounts.
- Centralize email management in Authentik: when forward-auth mode is on,
  email is read-only in Veola for every user (not just forward rows);
  pure-local deployments keep editable email for Resend.
- Fail closed on per-user isolation: ownedItem rejects uid==0/orphaned
  items; dashboard and global results error on a userless request.
- Budget accuracy: count Apify usage only after a successful run, and
  count the billed Test Apify run.
- Efficiency: resolve the deal-email recipient once per poll; build the
  settings page once in PostEmailPrefs.
This commit is contained in:
prosolis
2026-06-20 14:05:04 -07:00
parent feea126c5e
commit 4fb1a4553e
10 changed files with 174 additions and 78 deletions

View File

@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"net/http"
"veola/internal/db"
@@ -31,6 +32,11 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
uid := a.userID(r)
// Fail closed: every store method below treats ownerID 0 as "all owners",
// so a userless request must error rather than render cross-tenant data.
if uid == 0 {
return templates.DashboardData{}, errors.New("dashboard: no authenticated user in context")
}
stats, err := a.Store.GetDashboardStatsForUser(r.Context(), uid)
if err != nil {
return templates.DashboardData{}, err

View File

@@ -28,7 +28,10 @@ func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) {
if err != nil {
return nil, err
}
if it == nil || it.UserID != a.userID(r) {
// Fail closed: a zero uid (no authenticated user) must never match, and an
// orphaned item (UserID 0) must never be treated as owned-by-default.
uid := a.userID(r)
if it == nil || uid == 0 || it.UserID != uid {
return nil, nil
}
return it, nil

View File

@@ -106,6 +106,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
to := strings.TrimSpace(q.Get("to"))
uid := a.userID(r)
// Fail closed: ListResults/NextEndingResult treat OwnerID 0 as "all owners",
// so a userless request must not fall through to a cross-tenant view.
if uid == 0 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
items, err := a.Store.ListItemsForUser(r.Context(), uid)
if err != nil {
a.serverError(w, r, err)

View File

@@ -2,6 +2,7 @@ package handlers
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -91,6 +92,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
ForwardAuthMode: a.Cfg.Auth.ForwardAuthEnabled(),
}, nil
}
@@ -108,11 +110,13 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
return
}
email := strings.TrimSpace(r.PostFormValue("email"))
// Forward-auth (Authentik) owns the address: it is the IdP match key, kept
// in sync on every request. Never overwrite it from the form, or the next
// sign-in would no longer match and orphan the row. Users change their
// email in Authentik; only the opt-in toggles are editable here.
if cur.AuthSource == "forward" {
// In forward-auth mode, Authentik is the single source of truth for email
// addresses — Veola never writes them. For a forward row this also preserves
// the IdP match key (changing it would orphan the row on next sign-in); for
// a break-glass local row it keeps email management centralized in Authentik.
// Only the opt-in toggles are editable here. When forward-auth is off (pure
// local deployment, no IdP), local users set their own Resend address.
if a.Cfg.Auth.ForwardAuthEnabled() || cur.AuthSource == "forward" {
email = cur.Email
}
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
@@ -132,12 +136,8 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately (CurrentUser in d predates the write).
d, err = a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately on the already-built data (CurrentUser
// in d predates the write); no need to reassemble the whole settings page.
if d.Page.CurrentUser != nil {
d.Page.CurrentUser.Email = email
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
@@ -347,6 +347,11 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
if err != nil {
d.TestApifyOK = "Apify test failed: " + err.Error()
} else {
// The test launches a real, billed actor run; count it toward the
// budget view like every other Apify run.
if incErr := a.Store.IncrementApifyUsage(r.Context()); incErr != nil {
slog.Error("apify usage increment failed", "err", incErr)
}
d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw))
}
render(w, r, templates.Settings(d))