Files
meshcore-analyzer/cmd/server/user_mgmt_config.go
T
efitenandClaude Opus 5.5 d29957fbdc feat(observer-logins): lock the shared login out of migrated observers (#2348)
Follow-up to #2306 / #2319. With personal observer logins, a migrated
observer still is not safe from the shared login: the hook answers
`ignore` for the shared login, so EMQX's built-in database lets it
publish under any observer's topic, including one whose owner has moved
to a personal login. This PR lets an operator close that per migrated
observer, before the shared login is removed altogether.

## Changes

**users.db v8.** `observer_bindings.confirmed_at`: the moment the
owner's personal login first published under that pubkey.
- An auto-bind (the owner's first publish) is confirmed at once.
- An admin assignment stays unconfirmed until the owner's login
publishes under that pubkey; that publish sets it.
- Reassigning to another user or unbinding clears it; reassigning to the
same owner keeps it.
- Migration: existing `auto` rows get `confirmed_at = bound_at`, `admin`
rows stay NULL.

**Config.** `userManagement.observerLogins.sharedUsernames` (default
empty): the broker's shared login names. A name starting with `obs_` is
refused at startup.

**Authz hook.** For a login in `sharedUsernames`, a publish under
`meshcore/<x>/<pubkey>/...` is denied when that pubkey's binding is
confirmed, whatever the number of topic segments (the ingestor
attributes `meshcore/<x>/<pubkey>` and longer topics to that pubkey).
Logged as `publish denied: user=<name> observer=<8 hex>: confirmed
owner`. Every other request from a shared login keeps `ignore`, so the
built-in database decides as before. Personal logins are unchanged.

An orphaned binding (owner account deleted) does not count as confirmed.

**Admin.** The observers table of the Observer logins tab gets a
Confirmed column; the bindings JSON gets `confirmedAt`.

**Docs.** `docs/observer-mqtt-logins.md`: "Locking the shared login out
of migrated observers" (what it denies, how to see `confirmedAt`, the
risk below). `config.example.json` has the key.

## Risk for operators

An observer that is confirmed but still runs a second uploader on the
shared login loses that second uploader once its name is in
`sharedUsernames`. The deny log line names the observer.

## Tests

- `internal/users`: v7 to v8 migration on a real v7 database, auto
confirm, admin assignment then owner confirm, non-owner and repeated
confirm, reassign clears, orphan reassign.
- `cmd/server`: the decision table (confirmed, unconfirmed, unbound,
non-publish), topics with 3, 4 and 5+ segments, trailing slash, empty
and `client` iata, lowercase key; owner publish confirms, confirm
failure still allows and retries; reassign, unbind and user removal
clear the flag; the index load against a real store; config validation;
the hook and admin endpoints end to end.
- `tests/e2e/test-user-management-e2e.js`: the Confirmed column for an
auto-bound observer.
- Local: `internal/users`, `cmd/server` (two Windows-only tests skipped,
as on master) and `cmd/ingestor` green. The E2E assertion was not run
locally; CI runs it.

## Not in this PR

- Using `confirmed_at` for verified `/neighbors` reports: a separate PR,
built on #2300.
- Removing the shared login: still an operator step after migration.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:10:41 +01:00

494 lines
18 KiB
Go

package main
import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"path/filepath"
"regexp"
"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"`
ObserverLogins *ObserverLoginsConfig `json:"observerLogins,omitempty"`
}
// UserMailConfig is userManagement.mail.
type UserMailConfig struct {
Provider string `json:"provider,omitempty"`
BrevoAPIKey string `json:"brevoApiKey,omitempty"`
PostalBaseURL string `json:"postalBaseUrl,omitempty"`
PostalAPIKey string `json:"postalApiKey,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"`
// ExternalAlerts feeds the node.external event (#2177). Absent: the
// event is not offered.
ExternalAlerts *ExternalAlertsConfig `json:"externalAlerts,omitempty"`
// AdaptiveOffline learns node.offline's threshold per node from its own
// certain traffic (#2260, adaptive_offline.go). Absent or not enabled:
// the fixed healthThresholds silent windows only.
AdaptiveOffline *AdaptiveOfflineConfig `json:"adaptiveOffline,omitempty"`
}
// AdaptiveOfflineConfig is userManagement.notifications.adaptiveOffline.
type AdaptiveOfflineConfig struct {
Enabled bool `json:"enabled"`
FloorMinutes int `json:"floorMinutes,omitempty"` // shortest learned threshold, default 30
MinObservers int `json:"minObservers,omitempty"` // fewer distinct observers keep the fixed window, default 3
}
// ExternalAlertsConfig is userManagement.notifications.externalAlerts: a
// JSON feed of per-node alerts computed outside CoreScope.
type ExternalAlertsConfig struct {
URL string `json:"url"`
MaxAgeHours int `json:"maxAgeHours,omitempty"`
Label string `json:"label,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
external externalAlertSettings
adaptive adaptiveSettings
}
// adaptiveSettings is the resolved adaptiveOffline block; the zero value is
// off.
type adaptiveSettings struct {
enabled bool
floor time.Duration
minObservers int
}
const (
defaultAdaptiveFloorMinutes = 30
defaultAdaptiveMinObservers = 3
)
// resolveAdaptiveOffline fills the defaults; nil or not enabled is off.
func resolveAdaptiveOffline(c *AdaptiveOfflineConfig) adaptiveSettings {
if c == nil || !c.Enabled {
return adaptiveSettings{}
}
return adaptiveSettings{enabled: true,
floor: time.Duration(positiveOr(c.FloorMinutes, defaultAdaptiveFloorMinutes)) * time.Minute,
minObservers: positiveOr(c.MinObservers, defaultAdaptiveMinObservers)}
}
// externalAlertSettings is the resolved externalAlerts block; url "" means
// not configured.
type externalAlertSettings struct {
url string
maxAge time.Duration // a feed generated longer ago is stale
label string // names the source in mail lines and on the account page
}
const (
defaultExternalAlertsMaxAgeHours = 48
defaultExternalAlertsLabel = "External alert"
)
// resolveExternalAlerts validates the externalAlerts block and fills the
// defaults; nil gives the zero value (not configured).
func resolveExternalAlerts(c *ExternalAlertsConfig) (externalAlertSettings, error) {
if c == nil {
return externalAlertSettings{}, nil
}
u, err := url.Parse(strings.TrimSpace(c.URL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return externalAlertSettings{}, errors.New("userManagement.notifications.externalAlerts.url must be an absolute http(s) URL")
}
label := mailSafeText(c.Label)
if label == "" {
label = defaultExternalAlertsLabel
}
return externalAlertSettings{url: u.String(), label: label,
maxAge: time.Duration(positiveOr(c.MaxAgeHours, defaultExternalAlertsMaxAgeHours)) * time.Hour}, nil
}
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),
adaptive: resolveAdaptiveOffline(c.AdaptiveOffline),
}
}
// 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
}
// ObserverLoginsConfig is userManagement.observerLogins: personal MQTT logins
// for observers. Off by default.
type ObserverLoginsConfig struct {
Enabled bool `json:"enabled"`
HookSecret string `json:"hookSecret,omitempty"`
EncryptionKey string `json:"encryptionKey,omitempty"`
Broker ObserverBrokerConfig `json:"broker"`
// SharedUsernames are the broker's own shared login names; the authz
// hook denies them a publish under a confirmed observer pubkey.
SharedUsernames []string `json:"sharedUsernames,omitempty"`
}
// ObserverBrokerConfig is the broker an observer connects to, as shown to
// the user.
type ObserverBrokerConfig struct {
Host string `json:"host"`
Port int `json:"port,omitempty"`
Transport string `json:"transport,omitempty"`
TLS bool `json:"tls,omitempty"`
}
// observerLoginSettings is the resolved form; the zero value means off.
type observerLoginSettings struct {
enabled bool
hookSecret string
key []byte // 32 bytes, for users.NewSecretBox
host string
port int
transport string
tls bool
// sharedUsernames is nil when none are configured.
sharedUsernames map[string]bool
}
const (
defaultObserverBrokerPort = 1883
defaultObserverBrokerTransport = "tcp"
)
// resolveObserverLogins validates the block only when it is enabled. The
// hook secret and key may come from the environment, which wins.
func resolveObserverLogins(c *ObserverLoginsConfig, getenv func(string) string) (observerLoginSettings, error) {
if c == nil || !c.Enabled {
return observerLoginSettings{}, nil
}
o := observerLoginSettings{enabled: true, tls: c.Broker.TLS}
o.hookSecret = envOrValue(getenv, "CORESCOPE_OBSERVER_HOOK_SECRET", c.HookSecret)
if o.hookSecret == "" {
return observerLoginSettings{}, errors.New("userManagement.observerLogins.hookSecret is required (or CORESCOPE_OBSERVER_HOOK_SECRET)")
}
if len(o.hookSecret) < 16 {
return observerLoginSettings{}, errors.New("userManagement.observerLogins.hookSecret must be at least 16 characters")
}
rawKey := envOrValue(getenv, "CORESCOPE_OBSERVER_KEY", c.EncryptionKey)
if rawKey == "" {
return observerLoginSettings{}, errors.New("userManagement.observerLogins.encryptionKey is required (or CORESCOPE_OBSERVER_KEY)")
}
key, err := base64.StdEncoding.DecodeString(rawKey)
if err != nil || len(key) != 32 {
return observerLoginSettings{}, errors.New("userManagement.observerLogins.encryptionKey must be standard base64 of exactly 32 bytes")
}
o.key = key
o.host = strings.TrimSpace(c.Broker.Host)
if o.host == "" {
return observerLoginSettings{}, errors.New("userManagement.observerLogins.broker.host is required")
}
o.port = positiveOr(c.Broker.Port, defaultObserverBrokerPort)
o.transport = strings.TrimSpace(c.Broker.Transport)
if o.transport == "" {
o.transport = defaultObserverBrokerTransport
}
if o.transport != "tcp" && o.transport != "websockets" {
return observerLoginSettings{}, fmt.Errorf("userManagement.observerLogins.broker.transport %q is not supported (use \"tcp\" or \"websockets\")", o.transport)
}
for _, name := range c.SharedUsernames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if strings.HasPrefix(name, mqttUsernamePrefix) {
return observerLoginSettings{}, fmt.Errorf("userManagement.observerLogins.sharedUsernames: %q is a personal login name", name)
}
if o.sharedUsernames == nil {
o.sharedUsernames = map[string]bool{}
}
o.sharedUsernames[name] = true
}
return o, nil
}
// 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", "postal" or (e2etest builds only) "fake"
brevoAPIKey string
postalBaseURL string // no trailing slash
postalAPIKey string
fromEmail string
fromName string
webhookSecret string
proposals proposalSettings
notify notifySettings
backup backupSettings
observerLogins observerLoginSettings
}
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.postalAPIKey = envOrValue(getenv, "CORESCOPE_POSTAL_API_KEY", u.Mail.PostalAPIKey)
webhookEnv := "CORESCOPE_BREVO_WEBHOOK_SECRET"
if set.provider == "postal" {
webhookEnv = "CORESCOPE_POSTAL_WEBHOOK_SECRET"
}
set.webhookSecret = envOrValue(getenv, webhookEnv, 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 "postal":
if set.postalAPIKey == "" {
return nil, errors.New("userManagement.mail: a Postal API key is required (mail.postalApiKey or CORESCOPE_POSTAL_API_KEY)")
}
if set.postalBaseURL, err = resolvePostalBaseURL(u.Mail.PostalBaseURL); err != nil {
return nil, err
}
// Postal sends the secret as HTTP Basic auth taken from the webhook
// URL, and its URL check allows only these characters there.
if set.webhookSecret != "" && !postalSecretRe.MatchString(set.webhookSecret) {
return nil, errors.New("userManagement.mail.webhookSecret may contain only letters, digits, '.', '_' and '-' with the postal provider")
}
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\" or \"postal\")", 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)
if set.notify.enabled {
if set.notify.external, err = resolveExternalAlerts(u.Notifications.ExternalAlerts); err != nil {
return nil, err
}
}
set.backup = resolveBackup(u.Backup, set.dbPath)
if set.observerLogins, err = resolveObserverLogins(u.ObserverLogins, getenv); err != nil {
return nil, err
}
return set, nil
}
var postalSecretRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
// resolvePostalBaseURL validates mail.postalBaseUrl. The API key travels in a
// header, so plain http is allowed only to a loopback host.
func resolvePostalBaseURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", errors.New("userManagement.mail.postalBaseUrl must be an absolute http(s) URL, e.g. https://postal.example.org")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("userManagement.mail.postalBaseUrl must not contain credentials, a query or a fragment")
}
if u.Scheme == "http" {
host := u.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return "", errors.New("userManagement.mail.postalBaseUrl must use https (http is allowed only for localhost)")
}
}
u.Path = strings.TrimRight(u.Path, "/")
return u.String(), nil
}
func envOrValue(getenv func(string) string, key, fallback string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return strings.TrimSpace(fallback)
}