Opening a shop is an API call; the broker learns of it in the same request

The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-19 11:55:26 +05:30
parent 5f83a1077d
commit 4c750cb2ac
21 changed files with 1310 additions and 54 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/loyaly/behavision-server/internal/broker"
"github.com/loyaly/behavision-server/internal/provision"
"github.com/loyaly/behavision-server/internal/secret"
)
@@ -43,6 +44,14 @@ func runProvision(args []string) error {
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
p := &provision.Provisioner{Pool: pool, Secrets: box}
// The broker, so a new site's login is registered here and now instead of
// printed for somebody to type into a password file. Same variables the
// server itself connects with.
if u := os.Getenv("MQTT_URL"); u != "" && os.Getenv("MQTT_USERNAME") != "" {
dyn := broker.New(u, os.Getenv("MQTT_USERNAME"), os.Getenv("MQTT_PASSWORD"), nil)
defer dyn.Close()
p.Broker = dyn
}
switch args[0] {
case "client":
@@ -82,12 +91,16 @@ func runProvision(args []string) error {
return err
}
fmt.Printf("site created: %s\n", res.SiteID)
if res.BrokerRegistered {
fmt.Printf("broker login %s registered - this site can publish now.\n", res.Username)
return nil
}
fmt.Printf("\nAdd this broker user to Mosquitto, then this site can publish:\n\n")
fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
res.Username, res.Password)
// The broker keeps a hash; we keep it sealed. Neither side can show it
// again, which is why it is printed here in full.
fmt.Printf("The password is stored encrypted and handed out only at "+
fmt.Printf("The password is stored encrypted and handed out only at " +
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
return nil