Files
meshcore-analyzer/cmd/server/user_mgmt_config.go
T
efitenandClaude Opus 5.5 2456762179 feat: account data export and users.db backup (#2141)
A small follow-up to parts A to E of #2128: users can download all data
the instance holds about them, and the server backs up `users.db`.

Stacked on #2140. Review the commits after that branch.

## The situation

- An instance with accounts holds personal data, but a user has no way
to get a copy (GDPR articles 15 and 20). Self-delete already exists.
- `users.db` is the only file the server writes, and nothing backs it
up: `GET /api/backup` snapshots the analyzer database only.

## What this PR adds

**Data export.** `GET /api/account/export` and a "Download my data"
button on the account page give one JSON file with:
- the profile, including activation and a pending email change;
- sessions (times and user agent);
- synced settings, own proposals, notification prefs and watches;
- audit rows where the user is actor or target, with other accounts as
an id only;
- the mail log.

Password hash, session and link tokens and the unsubscribe token are
left out: they are credentials. Each export is audited as `user.export`.

**users.db backup**
- Daily `VACUUM INTO` snapshots through `internal/users`, on by default
when user management is on. They go to `backups/` next to `users.db`,
the 7 newest are kept, the directory is 0700 and the files 0600.
Rotation only touches `users-<timestamp>.db` files and never the
snapshot it just wrote. Config: `userManagement.backup {enabled, dir,
keep}`.
- `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to
keep off the server; audited as `user.backup`.
- `docs/user-guide/accounts.md` gains a restore procedure. It covers
what a restore brings back (accounts deleted after the snapshot, old
passwords, revoked sessions) and how to clean that up afterwards.

Spec:
[`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md).

## Verification

- `internal/users` with `-race` and the `cmd/server` suite pass locally,
30 new Go tests. The file-mode tests skip on Windows.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 27 of 27 steps locally.
- On our production instance since 8 October 2026; the first snapshot
there was 143 kB.

## Not in this PR

- Importing an export into another instance.
- Off-site upload of the snapshots. The admin download covers that by
hand.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:16:49 +02:00

269 lines
9.4 KiB
Go

package main
import (
"errors"
"fmt"
"net"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/meshcore-analyzer/users"
)
// UserManagementConfig is the "userManagement" block of config.json.
type UserManagementConfig struct {
Enabled bool `json:"enabled"`
DBPath string `json:"dbPath,omitempty"`
AdminEmails []string `json:"adminEmails,omitempty"`
PublicBaseURL string `json:"publicBaseUrl,omitempty"`
SessionDays int `json:"sessionDays,omitempty"`
TrustedProxies []string `json:"trustedProxies,omitempty"`
Mail UserMailConfig `json:"mail"`
ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"`
Notifications *NotificationsConfig `json:"notifications,omitempty"`
Backup *UsersBackupConfig `json:"backup,omitempty"`
}
// UserMailConfig is userManagement.mail.
type UserMailConfig struct {
Provider string `json:"provider,omitempty"`
BrevoAPIKey string `json:"brevoApiKey,omitempty"`
FromEmail string `json:"fromEmail,omitempty"`
FromName string `json:"fromName,omitempty"`
WebhookSecret string `json:"webhookSecret,omitempty"`
}
// ChannelProposalsConfig is userManagement.channelProposals
// (docs/specs/2026-10-07-channel-proposals-design.md). Off by default.
type ChannelProposalsConfig struct {
Enabled bool `json:"enabled"`
MaxPending int `json:"maxPending,omitempty"`
MaxApproved int `json:"maxApproved,omitempty"`
PerUserPerDay int `json:"perUserPerDay,omitempty"`
}
// proposalSettings is the resolved form; the zero value means off.
type proposalSettings struct {
enabled bool
maxPending int // pending proposals of every kind
maxApproved int // approved hashtag channels; bounds the ingestor's key set
perUserPerDay int
}
const (
defaultMaxPendingProposals = 100
defaultMaxApprovedChannels = 128
defaultProposalsPerUserPerDay = 5
)
func positiveOr(v, def int) int {
if v <= 0 {
return def
}
return v
}
// resolveProposals fills the defaults for absent, zero or negative limits.
func resolveProposals(c *ChannelProposalsConfig) proposalSettings {
if c == nil || !c.Enabled {
return proposalSettings{}
}
return proposalSettings{
enabled: true,
maxPending: positiveOr(c.MaxPending, defaultMaxPendingProposals),
maxApproved: positiveOr(c.MaxApproved, defaultMaxApprovedChannels),
perUserPerDay: positiveOr(c.PerUserPerDay, defaultProposalsPerUserPerDay),
}
}
// NotificationsConfig is userManagement.notifications
// (docs/specs/2026-10-07-node-notifications-design.md). Off by default.
type NotificationsConfig struct {
Enabled bool `json:"enabled"`
IntervalMinutes int `json:"intervalMinutes,omitempty"`
PerUserPerDay int `json:"perUserPerDay,omitempty"`
MaxMailsPerDay int `json:"maxMailsPerDay,omitempty"`
MaxWatchesPerUser int `json:"maxWatchesPerUser,omitempty"`
}
// notifySettings is the resolved form; the zero value means off.
type notifySettings struct {
enabled bool
interval time.Duration // between evaluations, at least a minute
perUserPerDay int // notification mails per user per 24 hours
maxMailsPerDay int // notification mails per instance per 24 hours
maxWatchesPerUser int
}
const (
defaultNotifyIntervalMinutes = 5
defaultNotifyPerUserPerDay = 20
// defaultNotifyMaxMailsPerDay counts notification mail only. A third of
// Brevo's free 300 a day, so activation, reset and address-change mail
// (and other senders on the same account) still fit in the quota.
defaultNotifyMaxMailsPerDay = 100
defaultNotifyMaxWatches = 50
)
// resolveNotifications fills the defaults for absent, zero or negative
// values. The interval is in whole minutes, so its floor is one minute.
func resolveNotifications(c *NotificationsConfig) notifySettings {
if c == nil || !c.Enabled {
return notifySettings{}
}
return notifySettings{
enabled: true,
interval: time.Duration(positiveOr(c.IntervalMinutes, defaultNotifyIntervalMinutes)) * time.Minute,
perUserPerDay: positiveOr(c.PerUserPerDay, defaultNotifyPerUserPerDay),
maxMailsPerDay: positiveOr(c.MaxMailsPerDay, defaultNotifyMaxMailsPerDay),
maxWatchesPerUser: positiveOr(c.MaxWatchesPerUser, defaultNotifyMaxWatches),
}
}
// UsersBackupConfig is userManagement.backup: scheduled users.db
// snapshots (docs/specs/2026-10-08-account-export-and-users-backup-design.md).
// On by default: an absent block, or one without "enabled", means on.
type UsersBackupConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Dir string `json:"dir,omitempty"`
Keep int `json:"keep,omitempty"`
}
// backupSettings is the resolved form; the zero value means off.
type backupSettings struct {
enabled bool
dir string // relative paths resolve against the working directory, like dbPath
keep int // snapshots kept after each run
}
const defaultBackupKeep = 7
// resolveBackup fills the defaults: dir "backups" next to users.db, keep 7
// for an absent, zero or negative value. Only "enabled": false turns it off.
func resolveBackup(c *UsersBackupConfig, dbPath string) backupSettings {
if c != nil && c.Enabled != nil && !*c.Enabled {
return backupSettings{}
}
b := backupSettings{enabled: true, dir: filepath.Join(filepath.Dir(dbPath), "backups"), keep: defaultBackupKeep}
if c == nil {
return b
}
if d := strings.TrimSpace(c.Dir); d != "" {
b.dir = d
}
b.keep = positiveOr(c.Keep, defaultBackupKeep)
return b
}
// UserManagementEnabled reports whether optional accounts are on. Nil config
// or absent section means off (the default).
func (c *Config) UserManagementEnabled() bool {
return c != nil && c.UserManagement != nil && c.UserManagement.Enabled
}
// userMgmtSettings is the validated, resolved form the auth service runs on.
type userMgmtSettings struct {
dbPath string
adminEmails map[string]bool
baseURL *url.URL // no trailing slash, no query or fragment
secureCookie bool
sessionTTL time.Duration
trustedProxies []*net.IPNet
provider string // "brevo" or (e2etest builds only) "fake"
brevoAPIKey string
fromEmail string
fromName string
webhookSecret string
proposals proposalSettings
notify notifySettings
backup backupSettings
}
const defaultSessionDays = 30
// fakeMailerAllowed is flipped only by the e2etest build (auth_e2e.go).
var fakeMailerAllowed bool
// resolveUserManagement validates the block and fills defaults. It refuses
// configurations where nobody could activate an account, so the server
// fails at startup instead of running half-working.
func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, getenv func(string) string) (*userMgmtSettings, error) {
set := &userMgmtSettings{adminEmails: map[string]bool{}}
set.dbPath = strings.TrimSpace(u.DBPath)
if set.dbPath == "" {
set.dbPath = filepath.Join(filepath.Dir(measurementDBPath), "users.db")
}
for _, raw := range u.AdminEmails {
e, err := users.NormalizeEmail(raw)
if err != nil {
return nil, fmt.Errorf("userManagement.adminEmails: %q is not a valid address", raw)
}
set.adminEmails[e] = true
}
base, err := url.Parse(strings.TrimSpace(u.PublicBaseURL))
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
return nil, errors.New("userManagement.publicBaseUrl must be an absolute http(s) URL, e.g. https://corescope.example.org")
}
base.Path = strings.TrimRight(base.Path, "/")
base.RawQuery, base.Fragment = "", ""
set.baseURL = base
set.secureCookie = base.Scheme == "https"
days := u.SessionDays
if days <= 0 {
days = defaultSessionDays
}
if days > 365 {
days = 365
}
set.sessionTTL = time.Duration(days) * 24 * time.Hour
set.trustedProxies = parseCIDRList(u.TrustedProxies, "userManagement.trustedProxies")
set.provider = strings.ToLower(strings.TrimSpace(u.Mail.Provider))
if set.provider == "" {
set.provider = "brevo"
}
set.brevoAPIKey = envOrValue(getenv, "CORESCOPE_BREVO_API_KEY", u.Mail.BrevoAPIKey)
set.webhookSecret = envOrValue(getenv, "CORESCOPE_BREVO_WEBHOOK_SECRET", u.Mail.WebhookSecret)
from, err := users.NormalizeEmail(u.Mail.FromEmail)
if err != nil {
return nil, errors.New("userManagement.mail.fromEmail must be a valid address")
}
set.fromEmail = from
set.fromName = strings.TrimSpace(u.Mail.FromName)
if set.fromName == "" {
set.fromName = "CoreScope"
}
switch set.provider {
case "brevo":
if set.brevoAPIKey == "" {
return nil, errors.New("userManagement.mail: a Brevo API key is required (mail.brevoApiKey or CORESCOPE_BREVO_API_KEY)")
}
case "fake":
if !fakeMailerAllowed {
return nil, errors.New(`userManagement.mail.provider "fake" is only available in e2etest builds`)
}
default:
return nil, fmt.Errorf("userManagement.mail.provider %q is not supported (use \"brevo\")", u.Mail.Provider)
}
if set.webhookSecret != "" && len(set.webhookSecret) < 16 {
return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters")
}
set.proposals = resolveProposals(u.ChannelProposals)
set.notify = resolveNotifications(u.Notifications)
set.backup = resolveBackup(u.Backup, set.dbPath)
return set, nil
}
func envOrValue(getenv func(string) string, key, fallback string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return strings.TrimSpace(fallback)
}