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
74 lines
1.9 KiB
Go
74 lines
1.9 KiB
Go
package broker
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const passwd = `behavision-backend:$7$101$c2FsdA==$ZGlnZXN0
|
|
health:$7$101$aGVhbHRo$aGFzaA==
|
|
acme.store1:$7$101$c2l0ZQ==$c2l0ZWhhc2g=
|
|
`
|
|
|
|
func TestPasswdBecomesTheStoreWithHashesIntact(t *testing.T) {
|
|
st, err := FromPasswd(strings.NewReader(passwd), "behavision-backend", "health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(st.Clients) != 3 {
|
|
t.Fatalf("clients: %d", len(st.Clients))
|
|
}
|
|
site := st.Clients[2]
|
|
if site.Password != "c2l0ZWhhc2g=" || site.Salt != "c2l0ZQ==" || site.Iterations != 101 {
|
|
t.Fatalf("hash not carried over intact: %+v", site)
|
|
}
|
|
if site.Roles[0].Rolename != "site.acme.store1" {
|
|
t.Fatalf("site role: %+v", site.Roles)
|
|
}
|
|
var siteRole *Role
|
|
for i := range st.Roles {
|
|
if st.Roles[i].Rolename == "site.acme.store1" {
|
|
siteRole = &st.Roles[i]
|
|
}
|
|
}
|
|
if siteRole == nil {
|
|
t.Fatal("no role for the site")
|
|
}
|
|
topics := map[string]bool{}
|
|
for _, a := range siteRole.ACLs {
|
|
topics[a.ACLType+" "+a.Topic] = true
|
|
}
|
|
for _, want := range []string{
|
|
"publishClientSend bv/acme.store1/visit",
|
|
"publishClientSend bv/acme.store1/heartbeat",
|
|
"publishClientSend bv/acme.store1/status",
|
|
"subscribePattern bv/acme.store1/cmd/#",
|
|
} {
|
|
if !topics[want] {
|
|
t.Errorf("missing %q in %v", want, topics)
|
|
}
|
|
}
|
|
if st.Clients[0].Roles[0].Rolename != "admin" {
|
|
t.Fatalf("backend is not an admin: %+v", st.Clients[0].Roles)
|
|
}
|
|
if st.DefaultACLAccess["publishClientSend"] || st.DefaultACLAccess["subscribe"] {
|
|
t.Fatal("default access must be deny")
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := st.Encode(&buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !json.Valid(buf.Bytes()) {
|
|
t.Fatal("encoded store is not valid JSON")
|
|
}
|
|
}
|
|
|
|
func TestANonPBKDF2LineIsRefusedByName(t *testing.T) {
|
|
_, err := FromPasswd(strings.NewReader("old:$6$abc$def\n"), "b", "h")
|
|
if err == nil || !strings.Contains(err.Error(), "old") {
|
|
t.Fatalf("expected a named refusal, got %v", err)
|
|
}
|
|
}
|