Files
meshcore-analyzer/cmd/server/observer_admin_handlers.go
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

205 lines
5.8 KiB
Go

package main
import (
"errors"
"log"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/meshcore-analyzer/users"
)
// adminObserverLoginJSON never carries credential material.
type adminObserverLoginJSON struct {
UserID int64 `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
CreatedAt string `json:"createdAt"`
Revoked bool `json:"revoked"`
LastAuthAt *string `json:"lastAuthAt"`
Observers []string `json:"observers"`
}
type adminObserverBindingJSON struct {
Pubkey string `json:"pubkey"`
UserID *int64 `json:"userId"`
BoundAt string `json:"boundAt"`
BoundBy string `json:"boundBy"`
// ConfirmedAt is null until the owner's login has published under the
// pubkey, and for an orphan (the index never treats one as confirmed).
ConfirmedAt *string `json:"confirmedAt"`
}
type adminObserverLoginsResponse struct {
Logins []adminObserverLoginJSON `json:"logins"`
Bindings []adminObserverBindingJSON `json:"bindings"`
}
type observerBindingRequest struct {
UserID *int64 `json:"userId"`
}
func adminObserverFail(w http.ResponseWriter, op string, err error) {
log.Printf("[observer-logins] admin %s: %v", op, err)
writeError(w, http.StatusInternalServerError, "internal error")
}
func (s *Server) handleAdminObserverLogins(w http.ResponseWriter, _ *http.Request, _ *users.User, _ *users.Session) {
a := s.auth
logins, err := a.st.AllMQTTLogins()
if err != nil {
adminObserverFail(w, "list logins", err)
return
}
bindings, err := a.st.ObserverBindings()
if err != nil {
adminObserverFail(w, "list bindings", err)
return
}
ids := make([]int64, 0, len(logins))
for _, l := range logins {
ids = append(ids, l.UserID)
}
accounts, err := a.st.UsersByID(ids)
if err != nil {
adminObserverFail(w, "load users", err)
return
}
owned := make(map[int64][]string, len(logins))
out := adminObserverLoginsResponse{
Logins: make([]adminObserverLoginJSON, 0, len(logins)),
Bindings: make([]adminObserverBindingJSON, 0, len(bindings)),
}
for _, b := range bindings {
if b.UserID != nil {
owned[*b.UserID] = append(owned[*b.UserID], b.Pubkey)
}
j := adminObserverBindingJSON{Pubkey: b.Pubkey, UserID: b.UserID, BoundAt: rfc3339(b.BoundAt), BoundBy: b.BoundBy}
if b.UserID != nil && b.ConfirmedAt != nil {
ts := rfc3339(*b.ConfirmedAt)
j.ConfirmedAt = &ts
}
out.Bindings = append(out.Bindings, j)
}
for _, l := range logins {
u := accounts[l.UserID]
j := adminObserverLoginJSON{UserID: l.UserID, Email: u.Email, DisplayName: u.DisplayName, Username: l.Username,
CreatedAt: rfc3339(l.CreatedAt), Revoked: l.RevokedAt != nil, Observers: owned[l.UserID]}
if j.Observers == nil {
j.Observers = []string{}
}
if l.LastAuthAt != nil {
ts := rfc3339(*l.LastAuthAt)
j.LastAuthAt = &ts
}
out.Logins = append(out.Logins, j)
}
writeJSON(w, out)
}
func (s *Server) handleAdminObserverLoginRevoke(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) {
a := s.auth
if !a.observerFeature(w) {
return
}
uid, ok := adminUserIDParam(w, r)
if !ok {
return
}
if err := a.st.RevokeMQTTLogin(uid); err != nil {
if errors.Is(err, users.ErrNotFound) {
writeError(w, http.StatusNotFound, "user has no observer login")
return
}
adminObserverFail(w, "revoke", err)
return
}
a.obsIdx.revoke(uid)
a.audit(idPtr(actor.ID), "observer.login.revoke", idPtr(uid), nil)
writeJSON(w, okResponse{OK: true})
}
func (s *Server) handleAdminObserverLoginReissue(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) {
a := s.auth
if !a.observerFeature(w) {
return
}
t := s.adminTarget(w, r)
if t == nil {
return
}
if _, err := a.st.MQTTLoginForUser(t.ID); err != nil {
if errors.Is(err, users.ErrNotFound) {
writeError(w, http.StatusNotFound, "user has no observer login")
return
}
adminObserverFail(w, "get login", err)
return
}
l, password, ok := a.newMQTTLogin(w, t.ID)
if !ok {
return
}
if err := a.st.ReissueMQTTLogin(l); err != nil {
adminObserverFail(w, "reissue", err)
return
}
a.obsIdx.setLogin(l)
a.audit(idPtr(actor.ID), "observer.login.reissue", idPtr(t.ID), map[string]string{"username": l.Username})
if !a.sendObserverCredentials(r.Context(), w, t, l.Username, password,
"the login was reissued and the old one no longer works, but the mail could not be sent; the user can ask for it again from their account page") {
return
}
writeJSON(w, sentResponse{Sent: true})
}
func (s *Server) handleAdminObserverBinding(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) {
a := s.auth
if !a.observerFeature(w) {
return
}
pk, err := users.NormalizeObserverKey(mux.Vars(r)["pubkey"])
if err != nil {
writeError(w, http.StatusBadRequest, "pubkey must be 64 hex characters")
return
}
var req observerBindingRequest
if !decodeJSON(w, r, &req) {
return
}
detail := map[string]string{"pubkey": pk, "userId": "none"}
if req.UserID != nil {
if _, err := a.st.GetByID(*req.UserID); err != nil {
adminStoreFail(w, "load", *req.UserID, err)
return
}
detail["userId"] = strconv.FormatInt(*req.UserID, 10)
}
detail["previousUserId"] = "none"
if prev := a.obsIdx.ownerOf(pk); prev != nil {
detail["previousUserId"] = strconv.FormatInt(*prev, 10)
}
if err := a.st.SetObserverOwner(pk, req.UserID); err != nil {
adminObserverFail(w, "set binding", err)
return
}
if req.UserID == nil {
a.obsIdx.unbind(pk)
} else {
a.obsIdx.setOwner(pk, req.UserID)
}
a.audit(idPtr(actor.ID), "observer.binding.set", req.UserID, detail)
writeJSON(w, okResponse{OK: true})
}
func adminUserIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return 0, false
}
return id, true
}