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

@@ -110,7 +110,7 @@ func (m *Manager) ForwardAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
email := strings.TrimSpace(r.Header.Get(fa.HeaderEmail))
email := strings.ToLower(strings.TrimSpace(r.Header.Get(fa.HeaderEmail)))
if email == "" {
next.ServeHTTP(w, r)
return

View File

@@ -142,7 +142,16 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error)
return u, err
}
// normalizeEmail lowercases and trims an address so identity matching is
// case-insensitive. The users.email unique index and the forward-auth match key
// are otherwise byte-exact, which would let "Ada@x.com" and "ada@x.com" become
// two accounts for one person.
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
email = normalizeEmail(email)
if email == "" {
return nil, nil
}
@@ -176,6 +185,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
// auth_source='forward' and carry an unusable password hash (forward-auth
// users never log in with a local password).
func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) {
email = normalizeEmail(email)
if email == "" {
return nil, errors.New("forward-auth requires an email")
}
@@ -184,11 +194,17 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r
return nil, err
}
if existing != nil {
if existing.Role != role || (username != "" && existing.Username != username) {
newName := existing.Username
if username != "" {
newName = username
newName := existing.Username
if username != "" && username != existing.Username {
// The IdP renamed the user. users.username is UNIQUE, so the
// desired name may already belong to a different row; resolve a
// free variant rather than letting the UPDATE fail with a 500.
newName, err = s.uniqueUsername(ctx, username, existing.ID)
if err != nil {
return nil, err
}
}
if existing.Role != role || newName != existing.Username {
if _, err := s.DB.ExecContext(ctx,
`UPDATE users SET role = ?, username = ? WHERE id = ?`,
string(role), newName, existing.ID); err != nil {
@@ -199,13 +215,27 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r
}
return existing, nil
}
if username == "" {
username = email
desired := username
if desired == "" {
desired = email
}
// users.username is UNIQUE; an Authentik username can collide with an
// existing local/forward row. Pick a free variant so a new identity is
// never locked out by a 500 on a username clash.
uname, err := s.uniqueUsername(ctx, desired, 0)
if err != nil {
return nil, err
}
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`,
username, "!forward-auth", string(role), email)
uname, "!forward-auth", string(role), email)
if err != nil {
// A concurrent first-login for the same email may have created the row
// between GetUserByEmail and this INSERT; the email unique index then
// rejects us. Re-resolve and let the race winner stand.
if u, getErr := s.GetUserByEmail(ctx, email); getErr == nil && u != nil {
return u, nil
}
return nil, err
}
id, err := res.LastInsertId()
@@ -215,6 +245,24 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r
return s.GetUserByID(ctx, id)
}
// uniqueUsername returns desired if it is free (or already held by excludeID),
// otherwise appends a numeric suffix until a free name is found. Bounded so a
// pathological collision set can't loop forever.
func (s *Store) uniqueUsername(ctx context.Context, desired string, excludeID int64) (string, error) {
candidate := desired
for i := 1; i <= 1000; i++ {
ex, err := s.GetUserByUsername(ctx, candidate)
if err != nil {
return "", err
}
if ex == nil || ex.ID == excludeID {
return candidate, nil
}
candidate = fmt.Sprintf("%s-%d", desired, i)
}
return "", fmt.Errorf("no free username for %q", desired)
}
func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
return err

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))

View File

@@ -28,26 +28,31 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
return email.New(apiKey, from)
}
// sendDealEmails mails a deal alert to the item's owner, if that user opted
// into deal-alert email and has a destination address. Items are private, so a
// deal only ever notifies the person who is watching it. Entirely best-effort:
// an unconfigured Resend account or a send failure is logged and swallowed.
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
// dealMailer resolves the item owner and Resend client for deal-alert email.
// It returns (nil, nil) when email should be skipped (Resend unconfigured, no
// owner, owner opted out, or no address) so the caller can resolve once per
// poll instead of once per result. Best-effort: errors are logged and swallowed.
func (s *Scheduler) dealMailer(ctx context.Context, it models.Item) (*email.Client, *models.User) {
client := s.emailClient(ctx)
if !client.Configured() {
return
}
if it.UserID == 0 {
return
if !client.Configured() || it.UserID == 0 {
return nil, nil
}
owner, err := s.store.GetUserByID(ctx, it.UserID)
if err != nil {
slog.Error("load item owner failed", "item_id", it.ID, "err", err)
return
return nil, nil
}
if owner == nil || !owner.EmailDealAlerts || owner.Email == "" {
return
return nil, nil
}
return client, owner
}
// sendDealEmail mails a single deal alert to the resolved owner. The client and
// owner come from dealMailer (resolved once per poll), so this does no DB or
// settings I/O per result. Items are private, so a deal only ever notifies the
// person watching it.
func (s *Scheduler) sendDealEmail(ctx context.Context, client *email.Client, owner *models.User, it models.Item, r apify.UnifiedResult) {
subject := fmt.Sprintf("Veola deal: %s", it.Name)
price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price)
target := ""

View File

@@ -221,6 +221,10 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
)
bestIdx := PickBest(results)
// Resolve the deal-email recipient once: owner and Resend client are
// constant for the whole poll, so per-result sends reuse them instead of
// re-reading settings and the owner row for every alerting result.
dealClient, dealOwner := s.dealMailer(ctx, it)
alertsSent := 0
for _, r := range results {
exists, err := s.store.ResultExists(ctx, it.ID, r.URL)
@@ -247,9 +251,11 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
alerted = true
alertsSent++
}
// Email opted-in users in addition to ntfy. Best-effort: a mail
// failure must not block result storage or the next alert.
s.sendDealEmails(ctx, it, r)
// Email the owner in addition to ntfy. Best-effort: a mail failure
// must not block result storage or the next alert.
if dealOwner != nil {
s.sendDealEmail(ctx, dealClient, dealOwner, it, r)
}
}
price := r.Price
_, err = s.store.InsertResult(ctx, &models.Result{
@@ -323,10 +329,17 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
// usage counter that feeds the budget view. Every Apify-billed run in Veola
// goes through here so the count stays honest.
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
raw, err := s.apifyClient(ctx).Run(ctx, actorID, input)
if err != nil {
// Count only runs that actually reached Apify. Counting before the call
// would inflate the budget estimate with config errors and failed runs
// that Apify never billed.
return nil, err
}
if err := s.store.IncrementApifyUsage(ctx); err != nil {
slog.Error("apify usage increment failed", "err", err)
}
return s.apifyClient(ctx).Run(ctx, actorID, input)
return raw, nil
}
// ApifyUsage reports Apify run counts and the cost model used by the budget