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

255 lines
8.0 KiB
Go

package main
import (
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/meshcore-analyzer/users"
)
const (
hkSecret = "hook-secret-0123456789abcdef"
hkPK = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
hkPK2 = "2222222222222222222222222222222222222222222222222222222222222222"
)
func hkTopic(pk string) string { return "meshcore/BRU/" + pk + "/packets" }
type hookFixture struct {
*authFixture
idx *observerLoginIndex
}
// newHookFixture is an auth fixture with observer logins on and the hook
// routes registered.
func newHookFixture(t *testing.T, adminEmails ...string) *hookFixture {
t.Helper()
a, fake := newTestAuthService(t, adminEmails...)
a.set.observerLogins = observerLoginSettings{enabled: true, hookSecret: hkSecret}
idx, err := newObserverLoginIndex(a.st, nil)
if err != nil {
t.Fatal(err)
}
a.obsIdx = idx
srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats(), auth: a}
r := mux.NewRouter()
srv.registerAuthRoutes(r)
return &hookFixture{authFixture: &authFixture{srv: srv, router: r, fake: fake, st: a.st}, idx: idx}
}
// addLogin registers a user and gives them an MQTT login.
func (f *hookFixture) addLogin(t *testing.T, email, username, password string) int64 {
t.Helper()
c := f.registerAndActivate(t, email, "U "+username, "correct-horse-battery")
l := users.MQTTLogin{UserID: c.me.ID, Username: username, PasswordHash: olHash(password), PasswordEnc: "enc", CreatedAt: time.Now()}
if err := f.st.CreateMQTTLogin(l); err != nil {
t.Fatal(err)
}
f.idx.setLogin(l)
return c.me.ID
}
// post sends one hook request and returns the status and, for a 200, the result.
func (f *hookFixture) post(t *testing.T, path string, body any, secret string) (int, hookResult) {
t.Helper()
var mods []reqMod
if secret != "" {
mods = append(mods, header(hookSecretHeader, secret))
}
w := f.do("POST", path, body, mods...)
if w.Code != 200 {
return w.Code, ""
}
return w.Code, decode[emqxHookResp](t, w).Result
}
func (f *hookFixture) authn(t *testing.T, user, pw string) hookResult {
t.Helper()
_, r := f.post(t, "/api/mqtt-hook/emqx/authn", emqxAuthnReq{Username: user, Password: pw, ClientID: "c1"}, hkSecret)
return r
}
func (f *hookFixture) authz(t *testing.T, user, topic, action string) hookResult {
t.Helper()
_, r := f.post(t, "/api/mqtt-hook/emqx/authz", emqxAuthzReq{Username: user, Topic: topic, Action: action}, hkSecret)
return r
}
func TestHook_RequiresSecret(t *testing.T) {
f := newHookFixture(t)
f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
body := emqxAuthnReq{Username: "obs_aaaaaaaaaaaa", Password: "pw-a"}
for _, path := range []string{"/api/mqtt-hook/emqx/authn", "/api/mqtt-hook/emqx/authz"} {
for _, secret := range []string{"", "wrong", hkSecret + "x"} {
if code, _ := f.post(t, path, body, secret); code != 401 {
t.Fatalf("%s secret %q = %d, want 401", path, secret, code)
}
}
}
if code, r := f.post(t, "/api/mqtt-hook/emqx/authn", body, hkSecret); code != 200 || r != hookAllow {
t.Fatalf("right secret = %d %v", code, r)
}
}
func TestHook_BadJSONIs400(t *testing.T) {
f := newHookFixture(t)
w := f.do("POST", "/api/mqtt-hook/emqx/authn", "not an object", header(hookSecretHeader, hkSecret))
if w.Code != 400 {
t.Fatalf("code = %d", w.Code)
}
}
func TestHook_EMQXAuthn(t *testing.T) {
f := newHookFixture(t)
f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
cases := []struct {
user, pw string
want hookResult
}{
{"obs_aaaaaaaaaaaa", "pw-a", hookAllow},
{"obs_aaaaaaaaaaaa", "nope", hookDeny},
{"shared_user", "whatever", hookIgnore},
}
for _, c := range cases {
if got := f.authn(t, c.user, c.pw); got != c.want {
t.Fatalf("%s/%s = %q, want %q", c.user, c.pw, got, c.want)
}
}
}
func TestHook_AuthnTouchThrottled(t *testing.T) {
f := newHookFixture(t)
uid := f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
if f.authn(t, "obs_aaaaaaaaaaaa", "pw-a") != hookAllow {
t.Fatal("authn not allowed")
}
f.srv.auth.waitAudits()
l, _ := f.st.MQTTLoginForUser(uid)
if l.LastAuthAt == nil {
t.Fatal("first auth did not touch")
}
first := *l.LastAuthAt
time.Sleep(1100 * time.Millisecond) // users.db stores whole seconds
f.authn(t, "obs_aaaaaaaaaaaa", "pw-a")
f.srv.auth.waitAudits()
l, _ = f.st.MQTTLoginForUser(uid)
if !l.LastAuthAt.Equal(first) {
t.Fatalf("touched again inside the window: %v -> %v", first, l.LastAuthAt)
}
tt := &authTouches{}
now := time.Now()
if !tt.due(1, now) {
t.Fatal("first touch must be due")
}
if tt.due(1, now.Add(9*time.Minute)) {
t.Fatal("touch due after 9 minutes")
}
if !tt.due(1, now.Add(11*time.Minute)) {
t.Fatal("touch not due after 11 minutes")
}
}
func TestHook_EMQXAuthz_BindsAndAllows(t *testing.T) {
f := newHookFixture(t)
uid := f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
if r := f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK), "publish"); r != hookAllow {
t.Fatalf("authz = %q", r)
}
bs, err := f.st.ObserverBindingsForUser(uid)
if err != nil || len(bs) != 1 || bs[0].Pubkey != hkPK || bs[0].BoundBy != "auto" {
t.Fatalf("bindings = %+v, %v", bs, err)
}
cases := []struct {
user, topic, action string
want hookResult
}{
{"obs_aaaaaaaaaaaa", hkTopic(hkPK), "subscribe", hookDeny},
{"obs_aaaaaaaaaaaa", "meshcore/BRU/short/packets", "publish", hookDeny},
{"shared_user", hkTopic(hkPK), "publish", hookIgnore},
}
for _, c := range cases {
if got := f.authz(t, c.user, c.topic, c.action); got != c.want {
t.Fatalf("%+v = %q", c, got)
}
}
f.addLogin(t, "b@example.org", "obs_bbbbbbbbbbbb", "pw-b")
if r := f.authz(t, "obs_bbbbbbbbbbbb", hkTopic(hkPK), "publish"); r != hookDeny {
t.Fatalf("foreign publish = %q", r)
}
}
func TestHook_RevokedLoginDeniedImmediately(t *testing.T) {
f := newHookFixture(t)
uid := f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
if r := f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK), "publish"); r != hookAllow {
t.Fatalf("before revoke = %q", r)
}
if err := f.st.RevokeMQTTLogin(uid); err != nil {
t.Fatal(err)
}
f.idx.revoke(uid)
if r := f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK), "publish"); r != hookDeny {
t.Fatalf("after revoke authz = %q", r)
}
if r := f.authn(t, "obs_aaaaaaaaaaaa", "pw-a"); r != hookDeny {
t.Fatalf("after revoke authn = %q", r)
}
}
func TestHook_KnownObserverBindMailsAdmins(t *testing.T) {
f := newHookFixture(t, "admin@example.org")
f.registerAndActivate(t, "admin@example.org", "Admin", "correct-horse-battery")
db := setupTestDB(t)
f.srv.db = db
if _, err := db.conn.Exec(`INSERT INTO observers (id, name) VALUES (?, 'Known')`, strings.ToLower(hkPK)); err != nil {
t.Fatal(err)
}
f.addLogin(t, "a@example.org", "obs_aaaaaaaaaaaa", "pw-a")
before := len(f.fake.Sent())
f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK2), "publish")
f.srv.auth.waitAudits()
if got := len(f.fake.Sent()); got != before {
t.Fatalf("unknown observer mailed: %d new", got-before)
}
f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK), "publish")
f.srv.auth.waitAudits()
sent := f.fake.Sent()[before:]
if len(sent) != 1 || sent[0].To != "admin@example.org" || sent[0].Tag != "observer-bound" {
t.Fatalf("mails = %+v", sent)
}
f.authz(t, "obs_aaaaaaaaaaaa", hkTopic(hkPK), "publish")
f.srv.auth.waitAudits()
if got := len(f.fake.Sent()) - before; got != 1 {
t.Fatalf("repeat publish mailed again: %d", got)
}
}
func TestHookRoutesAbsentWhenDisabled(t *testing.T) {
f := newAuthFixture(t)
for _, p := range []string{"/api/mqtt-hook/emqx/authn", "/api/mqtt-hook/emqx/authz"} {
w := f.serve("POST", p, emqxAuthnReq{}, header(hookSecretHeader, hkSecret))
if w.Code != 404 {
t.Fatalf("%s = %d, want 404", p, w.Code)
}
}
}
func TestNewAuthServiceBuildsIndexOnlyWhenEnabled(t *testing.T) {
a, _ := newTestAuthService(t)
if a.obsIdx != nil {
t.Fatal("index built while disabled")
}
set := *a.set
set.observerLogins = observerLoginSettings{enabled: true, hookSecret: hkSecret}
b := newAuthService(&set, a.st, a.mail)
if b.obsIdx == nil {
t.Fatal("index not built while enabled")
}
}