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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user