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

29
API.md
View File

@@ -49,6 +49,7 @@ user; the tenant is always taken from the session and never from the request.
| `DELETE /api/visitors/{id}` — erasure | manager | | `DELETE /api/visitors/{id}` — erasure | manager |
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed | | `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager | | `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
| `POST /api/sites` — open a shop | owner |
| `POST /api/sites/{site}/enrolment-code` | manager | | `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) | | `GET /api/team` | authed (tenant users only) |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager | | `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
@@ -164,9 +165,9 @@ PATCH /api/team/{id} { "role": "manager" } → promote
PATCH /api/team/{id} { "active": false } → they have left; signs them out now PATCH /api/team/{id} { "active": false } → they have left; signs them out now
``` ```
The owner also sets the shop up from the same login — `POST The owner also opens shops and sets them up from the same login — `POST
/api/sites/{site}/enrolment-code` for the shop PC, `POST /api/sites` to open one, `POST /api/sites/{site}/enrolment-code` for its
/api/sites/{site}/cameras` for cameras — see §8. shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8.
### Tier 3 — the salesperson gets their mobile login ### Tier 3 — the salesperson gets their mobile login
@@ -718,6 +719,28 @@ unplugged PC.
footfall lost because that queue overflowed. Non-zero `dropped` is a report footfall lost because that queue overflowed. Non-zero `dropped` is a report
that is wrong in a way the report itself cannot show. that is wrong in a way the report itself cannot show.
### `POST /api/sites` — open a shop (owner)
```
{ "name": "TeNext Bengaluru", "slug": "bengaluru", "timezone": "Asia/Kolkata" }
→ 201 { "site_id": "…", "slug": "bengaluru", "name": "TeNext Bengaluru",
"timezone": "Asia/Kolkata", "broker_username": "tenext-retail.bengaluru" }
```
`slug` and `timezone` are optional: the slug is made from the name (lower-case,
digits and dashes, 3–32 characters) and the timezone defaults to Asia/Kolkata.
The slug is the shop PC's identity and an MQTT topic segment; it **cannot be
changed afterwards**. The server registers the shop's broker login with
Mosquitto in the same request, so the next step is simply
`POST /api/sites/{slug}/enrolment-code` for the PC.
| status | code | meaning |
|---|---|---|
| 403 | `forbidden` | not the owner |
| 409 | `conflict` | a shop with that slug exists |
| 502 | `broker_unavailable` | the broker did not accept the login; **nothing was created** — try again |
| 503 | `broker_unavailable` / `no_encryption_key` | this server cannot create shops; contact support |
### `GET /api/sites/{site}/check` ### `GET /api/sites/{site}/check`
Five ordered steps that answer *is this shop working*, assembled from what head Five ordered steps that answer *is this shop working*, assembled from what head

View File

@@ -1696,20 +1696,41 @@ The enrol response gained `client_slug` and `topic_prefix`, both **derived from
the broker username** rather than looked up separately, so the agent's topic the broker username** rather than looked up separately, so the agent's topic
prefix and the broker's ACL are equal by construction. prefix and the broker's ACL are equal by construction.
### Still a command: creating the shop itself ### Opening a shop is an API call, and the broker learns of it in the same request
`provision site` prints a broker password that a human then has to add to `POST /api/sites` (owner), and `provision site` behind the same code. This was
Mosquitto. So a tenant cannot open their second shop without us, and that is the the last piece of onboarding that needed a shell: `provision site` printed a
one remaining hole in self-service onboarding. Closing it needs a decision, not broker password and a person typed it into Mosquitto's passwd file on the host
code: — which turned out to be mounted read-only in the container, so the first
attempt failed silently and the password had to be re-rolled. No tenant could
open a second branch without us.
- **the server manages Mosquitto's `passwd`/`acl` and reloads it** — possible Neither option recorded here before was taken. The server does not write the
because they are co-located, and it couples the API to the broker's broker's files, and there is still one broker user per site. Mosquitto 2.0's
filesystem; or **dynamic-security plugin** takes the same operations as commands on
- **one broker user per CLIENT rather than per site** — then adding a shop needs `$CONTROL/dynamic-security/v1`, from a client holding the `admin` role;
no broker change at all. Cross-tenant isolation is unchanged; what is given up `server/internal/broker` drives it over the server's own broker login.
is that one of a customer's own PCs could publish as another of their sites.
Every deployed site would need re-provisioning. - **A role per site, with literal topics.** The 2.0 plugin does **not**
substitute `%u` in ACL topics (measured: the publish was denied), so
`site.<client>.<site>` is created with the client and deleted with it.
- **Idempotent.** Re-running `EnsureSite` on an existing login sets the password
to the one the database holds and confirms the role. `addClientRole` on a
client that already has the role answers "Internal error", so the role is
checked with `getClient` rather than inferred from prose.
- **The row and the login are created together, or not at all.** If the broker
refuses, the just-created row is removed and the caller gets 502. A shop that
exists in the database and not on the broker is one whose PC enrols fine and
never delivers a visit — the silent-failure class this whole endpoint ends.
- **Its own connection**, not the ingest client's: that one has
`SetOrderMatters` and blocking handlers, and a provisioning call must neither
wait behind a slow visit nor delay one.
- **Cutover keeps every password.** `behavision-server broker-init` converts the
passwd file into the plugin's store: `$7$` lines are PBKDF2-SHA512 with a
salt and iteration count, which is exactly what the plugin stores, so no shop
PC re-claims and no credential changes hands. Rehearsed locally against a
file `mosquitto_passwd` wrote; `run-local.sh` now brings the broker up the
same way as production.
## Running it against the real office camera: four dead wires ## Running it against the real office camera: four dead wires

View File

@@ -72,15 +72,34 @@ step "3b. Schema"
"./$STATE/bv-server" migrate "./$STATE/bv-server" migrate
step "4. Mosquitto" step "4. Mosquitto"
if [ ! -f "$STATE/mosquitto/mosquitto.conf" ]; then # Dynamic security, not a passwd file - the same shape as production. The
# server registers each site's broker login itself over the control topic, so
# there is no per-site password to type here and nothing to restart. The store
# is seeded once with the server's own login as the plugin admin; after that
# the plugin owns the file.
mkdir -p "$STATE/mosquitto/data"
# Rewritten when it is the pre-plugin shape, so a checkout that ran the old
# script comes up in the new one rather than half of each.
if ! grep -q mosquitto_dynamic_security "$STATE/mosquitto/mosquitto.conf" 2>/dev/null; then
rm -f "$STATE/mosquitto/passwd" "$STATE/mosquitto/acl"
docker rm -f bv-mqtt >/dev/null 2>&1 || true
cat > "$STATE/mosquitto/mosquitto.conf" <<EOF cat > "$STATE/mosquitto/mosquitto.conf" <<EOF
per_listener_settings false
listener 1883 listener 1883
allow_anonymous false allow_anonymous false
password_file /mosquitto/config/passwd plugin /usr/lib/mosquitto_dynamic_security.so
acl_file /mosquitto/config/acl plugin_opt_config_file /mosquitto/data/dynamic-security.json
EOF EOF
printf 'user behavision-server\ntopic read bv/#\n' > "$STATE/mosquitto/acl" fi
: > "$STATE/mosquitto/passwd" if [ ! -f "$STATE/mosquitto/data/dynamic-security.json" ]; then
: > "$STATE/mosquitto/passwd.seed"
docker run --rm -v "$PWD/$STATE/mosquitto:/m" eclipse-mosquitto:2 \
mosquitto_passwd -b /m/passwd.seed behavision-server "$MQTT_PASSWORD" 2>/dev/null
"./$STATE/bv-server" broker-init -passwd "$STATE/mosquitto/passwd.seed" \
-out "$STATE/mosquitto/data/dynamic-security.json" -backend-user behavision-server >/dev/null
rm -f "$STATE/mosquitto/passwd.seed"
# The plugin rewrites this file, so the broker's user (1883) must own it.
chmod 666 "$STATE/mosquitto/data/dynamic-security.json"
fi fi
# A container is reused only if its config mount still points HERE. The bind # A container is reused only if its config mount still points HERE. The bind
# source is baked in when the container is created, so one made while the # source is baked in when the container is created, so one made while the
@@ -98,6 +117,7 @@ if docker inspect bv-mqtt >/dev/null 2>&1; then
fi fi
docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \ docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \
-p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \ -p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \
-v "$MQTT_CONF/data:/mosquitto/data" \
eclipse-mosquitto:2 >/dev/null eclipse-mosquitto:2 >/dev/null
docker start bv-mqtt >/dev/null 2>&1 || true docker start bv-mqtt >/dev/null 2>&1 || true
@@ -114,13 +134,7 @@ if ! docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1; then
docker logs --tail 5 bv-mqtt >&2 docker logs --tail 5 bv-mqtt >&2
exit 1 exit 1
fi fi
# stderr is NOT discarded here. A failure means the server cannot authenticate echo " broker on ${MQTT_PORT} (dynamic security)"
# to its own broker, and the whole point of this script is that you find that
# out now rather than from an empty arrivals feed.
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd \
behavision-server "$MQTT_PASSWORD" >/dev/null
docker restart bv-mqtt >/dev/null
echo " broker on ${MQTT_PORT}"
step "5. First accounts" step "5. First accounts"
# Idempotent throughout: every provision subcommand upserts, so re-running this # Idempotent throughout: every provision subcommand upserts, so re-running this
@@ -140,14 +154,9 @@ step "5. First accounts"
# never readable again - so it is pushed into Mosquitto here in the same breath. # never readable again - so it is pushed into Mosquitto here in the same breath.
# A shop PC enrolled on an earlier run therefore has to be claimed again, which # A shop PC enrolled on an earlier run therefore has to be claimed again, which
# is the right trade locally and is why this is not how production works. # is the right trade locally and is why this is not how production works.
SITE_OUT=$("./$STATE/bv-server" provision site -client tenext-retail -slug chennai \ MQTT_URL="tcp://127.0.0.1:${MQTT_PORT}" MQTT_USERNAME=behavision-server MQTT_PASSWORD="$MQTT_PASSWORD" \
-name "TeNext Chennai" -tz Asia/Kolkata) "./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
BUSER=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd \([^ ]*\) .*/\1/p") -name "TeNext Chennai" -tz Asia/Kolkata | sed 's/^/ /'
BPASS=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd [^ ]* '\(.*\)'.*/\1/p")
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null
grep -q "^user $BUSER$" "$STATE/mosquitto/acl" || \
printf '\nuser %s\ntopic write bv/%s/#\n' "$BUSER" "$BUSER" >> "$STATE/mosquitto/acl"
docker restart bv-mqtt >/dev/null
printf ' platform admin admin@loyaly.ai / loyaly-platform-2026 (Companies only)\n' printf ' platform admin admin@loyaly.ai / loyaly-platform-2026 (Companies only)\n'
printf ' TeNext owner suriya@tenext.in / tenext-2026 (Shops, Live, Cameras, Customers, Reports)\n' printf ' TeNext owner suriya@tenext.in / tenext-2026 (Shops, Live, Cameras, Customers, Reports)\n'

View File

@@ -0,0 +1,69 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"github.com/loyaly/behavision-server/internal/broker"
)
// broker-init converts Mosquitto's passwd file into the dynamic-security
// plugin's store, once, at cutover. After it the broker is driven over MQTT
// and the passwd and acl files are no longer read.
func runBrokerInit(args []string) error {
fs := flag.NewFlagSet("broker-init", flag.ExitOnError)
passwd := fs.String("passwd", "", "path to the mosquitto passwd file to convert")
out := fs.String("out", "", "where to write dynamic-security.json (must be writable by the broker)")
backend := fs.String("backend-user", "behavision-backend", "the server's own broker username; becomes the plugin admin")
health := fs.String("health-user", "health", "the healthcheck username")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `usage: behavision-server broker-init -passwd FILE -out FILE
Converts a mosquitto_passwd file into the dynamic-security plugin's store,
keeping every password hash exactly as it is, so no shop PC has to be
re-claimed. Then in mosquitto.conf replace password_file/acl_file with:
per_listener_settings false
plugin /usr/lib/mosquitto_dynamic_security.so
plugin_opt_config_file /mosquitto/data/dynamic-security.json
and restart the broker. From then on 'provision site' and POST /api/sites
register a shop's login themselves.
`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
if *passwd == "" || *out == "" {
fs.Usage()
return errors.New("-passwd and -out are required")
}
f, err := os.Open(*passwd)
if err != nil {
return err
}
defer f.Close()
st, err := broker.FromPasswd(f, *backend, *health)
if err != nil {
return err
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s already exists - refusing to overwrite a live store", *out)
}
w, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer w.Close()
if err := st.Encode(w); err != nil {
return err
}
fmt.Printf("wrote %s: %d clients, %d roles\n", *out, len(st.Clients), len(st.Roles))
for _, c := range st.Clients {
fmt.Printf(" %-28s %s\n", c.Username, c.Roles[0].Rolename)
}
return nil
}

View File

@@ -25,11 +25,12 @@ import (
"github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/api"
"github.com/loyaly/behavision-server/internal/assistant" "github.com/loyaly/behavision-server/internal/assistant"
"github.com/loyaly/behavision-server/internal/blob" "github.com/loyaly/behavision-server/internal/blob"
"github.com/loyaly/behavision-server/internal/broker"
"github.com/loyaly/behavision-server/internal/ingest" "github.com/loyaly/behavision-server/internal/ingest"
"github.com/loyaly/behavision-server/internal/migrate" "github.com/loyaly/behavision-server/internal/migrate"
"github.com/loyaly/behavision-server/internal/web"
"github.com/loyaly/behavision-server/internal/secret" "github.com/loyaly/behavision-server/internal/secret"
"github.com/loyaly/behavision-server/internal/store" "github.com/loyaly/behavision-server/internal/store"
"github.com/loyaly/behavision-server/internal/web"
"github.com/loyaly/behavision-server/migrations" "github.com/loyaly/behavision-server/migrations"
) )
@@ -46,6 +47,13 @@ func main() {
} }
return return
} }
if len(os.Args) > 1 && os.Args[1] == "broker-init" {
if err := runBrokerInit(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if len(os.Args) > 1 && os.Args[1] == "migrate" { if len(os.Args) > 1 && os.Args[1] == "migrate" {
if err := runMigrate(os.Args[2:]); err != nil { if err := runMigrate(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
@@ -194,8 +202,11 @@ func run() error {
apiSrv := &api.Server{ apiSrv := &api.Server{
Store: st, Store: st,
Log: logger, Log: logger,
Blob: objectStore(ctx, logger), // Opening a shop registers its broker login at the same moment, over the
Hub: hub, // same broker credential the ingest side already holds.
Broker: broker.New(brokerURL, brokerUser, brokerPass, logger),
Blob: objectStore(ctx, logger),
Hub: hub,
Bootstrap: api.BootstrapConfig{ Bootstrap: api.BootstrapConfig{
// What an enrolling PC is told to connect to. From the server's own // What an enrolling PC is told to connect to. From the server's own
// environment, never from the request: an agent asking where to // environment, never from the request: an agent asking where to

View File

@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "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/provision"
"github.com/loyaly/behavision-server/internal/secret" "github.com/loyaly/behavision-server/internal/secret"
) )
@@ -43,6 +44,14 @@ func runProvision(args []string) error {
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY") box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
p := &provision.Provisioner{Pool: pool, Secrets: box} 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] { switch args[0] {
case "client": case "client":
@@ -82,12 +91,16 @@ func runProvision(args []string) error {
return err return err
} }
fmt.Printf("site created: %s\n", res.SiteID) 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("\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", fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
res.Username, res.Password) res.Username, res.Password)
// The broker keeps a hash; we keep it sealed. Neither side can show it // The broker keeps a hash; we keep it sealed. Neither side can show it
// again, which is why it is printed here in full. // 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") "enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
return nil return nil

View File

@@ -36,6 +36,15 @@ import (
type Store interface { type Store interface {
// --- identity --- // --- identity ---
UserByEmail(ctx context.Context, email string) (UserRecord, error) UserByEmail(ctx context.Context, email string) (UserRecord, error)
// --- shops ---
// CreateSite writes the shop and its sealed broker password in one
// transaction and returns the plaintext once, for the broker registration
// that must follow. DeleteNewSite is the compensation when that
// registration fails: a shop whose PC can enrol but never publish is the
// silent failure this whole endpoint exists to end.
CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error)
DeleteNewSite(ctx context.Context, clientID, siteID string) error
TouchUserLogin(ctx context.Context, userID string) error TouchUserLogin(ctx context.Context, userID string) error
CreateSession(ctx context.Context, s NewSession) error CreateSession(ctx context.Context, s NewSession) error
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error) SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
@@ -168,6 +177,10 @@ type Server struct {
// business questions the screens ask. Nil means this deployment has no // business questions the screens ask. Nil means this deployment has no
// API key, which is supported: the UI hides the panel. // API key, which is supported: the UI hides the panel.
Assistant Assistant Assistant Assistant
// Broker registers a shop's login with Mosquitto at the moment the shop is
// created. Nil means this deployment cannot create shops through the API
// and says so, rather than creating one that can never publish.
Broker SiteBroker
// Live relays camera frames from a shop PC to whoever is watching, on // Live relays camera frames from a shop PC to whoever is watching, on
// demand. Created on first use. // demand. Created on first use.
Live *LiveHub Live *LiveHub
@@ -264,6 +277,7 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall)) mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion)) mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites)) mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
// Cameras, onboarded from head office. The shop PC still does the // Cameras, onboarded from head office. The shop PC still does the
// connecting - it is the only thing on the camera's network - so these // connecting - it is the only thing on the camera's network - so these

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"github.com/jackc/pgx/v5/pgconn"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -261,6 +262,32 @@ func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, e
return f.sales, nil return f.sales, nil
} }
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sites {
if s.Slug == slug {
return NewSite{}, &pgconn.PgError{Code: "23505"}
}
}
id := "site-" + slug
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
}
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.sites[:0]
for _, s := range f.sites {
if s.SiteID != siteID {
kept = append(kept, s)
}
}
f.sites = kept
return nil
}
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) { func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
return f.sites, nil return f.sites, nil
} }

View File

@@ -0,0 +1,107 @@
package api
import (
"context"
"errors"
"net/http"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// SiteBroker is the broker-side half of creating a shop. It is the
// internal/broker package's interface, redeclared here so this package does
// not import a paho dependency for the sake of one method.
type SiteBroker interface {
EnsureSite(ctx context.Context, username, password string) error
DeleteSite(ctx context.Context, username string) error
}
// Same rule the database enforces (sites_slug_format), checked here so the
// caller gets a sentence instead of a constraint name.
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`)
// POST /api/sites - an owner opens a shop.
//
// Until this existed a shop was `provision site` on the server's command line
// followed by a hand edit of the broker's password file. That made every new
// branch a support ticket, and it was the last piece of onboarding that could
// not be done from the product. The row and the broker login are created
// together here; if the broker will not take the login, the row is removed
// again and the caller is told, because a shop that exists in the database and
// not on the broker is one whose PC enrols fine and never delivers a visit.
func (s *Server) handleCreateSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
// Owner, not manager: a shop is a billing and tenancy object, not a
// setting. Managers can set up the PC and cameras once it exists.
if p.Role != "owner" || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can open a new shop.")
return
}
if s.Broker == nil {
writeErr(w, http.StatusServiceUnavailable, "broker_unavailable",
"This server is not connected to a broker that can register shops. Contact support.")
return
}
var in NewSiteInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.Name = clip(trim(in.Name), 120)
if in.Name == "" {
badRequest(w, "Give the shop a name.")
return
}
in.Slug = slugify(in.Slug)
if in.Slug == "" {
in.Slug = slugify(in.Name)
}
if !slugRe.MatchString(in.Slug) {
badRequest(w, "The short name must be 3-32 characters: lower-case letters, digits and dashes.")
return
}
tz := strings.TrimSpace(in.Timezone)
if tz == "" {
tz = "Asia/Kolkata"
}
if _, err := time.LoadLocation(tz); err != nil {
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
return
}
site, err := s.Store.CreateSite(r.Context(), p.ClientID, in.Slug, in.Name, tz)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeErr(w, http.StatusConflict, "conflict", "A shop with that short name already exists.")
return
}
if errors.Is(err, ErrNoSecrets) {
writeErr(w, http.StatusServiceUnavailable, "no_encryption_key",
"This server has no encryption key, so a shop's broker password cannot be stored. Contact support.")
return
}
s.serverError(w, "create site", err)
return
}
if err := s.Broker.EnsureSite(r.Context(), site.Username, site.Password); err != nil {
s.logf("create site %s: broker registration failed, removing the row: %v", site.Slug, err)
if derr := s.Store.DeleteNewSite(r.Context(), p.ClientID, site.SiteID); derr != nil {
s.logf("create site %s: could not remove the row after broker failure: %v", site.Slug, derr)
}
writeErr(w, http.StatusBadGateway, "broker_unavailable",
"The broker did not accept the new shop, so it was not created. Try again in a moment; if it keeps failing, contact support.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.created", Entity: "site", EntityID: site.SiteID,
Detail: map[string]any{"slug": site.Slug, "name": site.Name, "timezone": site.Timezone},
})
writeJSON(w, http.StatusCreated, site)
}

View File

@@ -0,0 +1,110 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
)
// fakeBroker records what the server asked the broker to do.
type fakeBroker struct {
ensured map[string]string
deleted []string
fail error
}
func (b *fakeBroker) EnsureSite(_ context.Context, user, pass string) error {
if b.fail != nil {
return b.fail
}
if b.ensured == nil {
b.ensured = map[string]string{}
}
b.ensured[user] = pass
return nil
}
func (b *fakeBroker) DeleteSite(_ context.Context, user string) error {
b.deleted = append(b.deleted, user)
return nil
}
func ownerSession(t *testing.T, s *Server, fs *fakeStore) Session {
t.Helper()
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true,
})
return login(t, s, "owner@acme.com", "correct horse battery")
}
func TestAnOwnerOpensAShopAndTheBrokerLearnsOfIt(t *testing.T) {
s, fs := newServer(t)
b := &fakeBroker{}
s.Broker = b
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Acme Bengaluru!"})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["slug"] != "acme-bengaluru" {
t.Errorf("slug not derived from the name: %v", out["slug"])
}
if _, leaked := out["password"]; leaked {
t.Fatal("the broker password was serialised")
}
if b.ensured["acme.acme-bengaluru"] == "" {
t.Fatalf("broker was not told about the shop: %+v", b.ensured)
}
if len(fs.sites) != 1 {
t.Fatalf("expected one site, have %d", len(fs.sites))
}
}
func TestABrokerFailureLeavesNoHalfMadeShop(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{fail: errors.New("no answer on the control topic")}
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Ghost"})
if rec.Code != http.StatusBadGateway {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.sites) != 0 {
t.Fatalf("a shop the broker never accepted was kept: %+v", fs.sites)
}
}
func TestAManagerCannotOpenAShop(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{}
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Nope"})
if rec.Code != http.StatusForbidden {
t.Fatalf("manager opened a shop: %d", rec.Code)
}
}
func TestADuplicateShortNameIsAConflict(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{}
seedSite(fs)
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Again", "slug": "chennai"})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNoBrokerConfiguredSaysSo(t *testing.T) {
s, fs := newServer(t)
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Shop"})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}

View File

@@ -160,6 +160,30 @@ type PurchaseInput struct {
// SiteHealth is what the dashboard needs to distinguish "no customers" from // SiteHealth is what the dashboard needs to distinguish "no customers" from
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes // "this shop's PC has been unplugged for a week" - two identical rows of zeroes
// with completely different responses. // with completely different responses.
// NewSiteInput is what an owner types to open a shop. The slug is derived
// from the name when absent, because it becomes the shop PC's identity and
// an MQTT topic segment, and a person asked to invent one invents a bad one.
type NewSiteInput struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// NewSite is the created shop plus the broker login the server registered for
// it. The password is sealed in the database and handed to a shop PC at
// enrolment; it is NOT in the API response, because nobody needs to see it -
// the enrolment code is the credential a person handles.
type NewSite struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
// Username is the broker login; Password is returned to the caller in
// Go only, for the broker registration, and never serialised.
Username string `json:"broker_username"`
Password string `json:"-"`
}
type SiteHealth struct { type SiteHealth struct {
SiteID string `json:"site_id"` SiteID string `json:"site_id"`
Slug string `json:"slug"` Slug string `json:"slug"`

View File

@@ -0,0 +1,325 @@
// Package broker creates and removes a site's broker login at runtime.
//
// Until this existed a shop was created in two places by two mechanisms:
// `provision site` wrote the row and printed a password, and a person then
// typed that password into Mosquitto's passwd file on the host and reloaded
// the broker. Nothing else in the product needed a shell, so this one step
// was what stopped a tenant opening a second branch on their own - and it was
// fragile even for us: the file was mounted read-only in the container, the
// first attempt failed, and the password had to be re-rolled.
//
// Mosquitto 2.0's dynamic-security plugin takes the same operations as
// commands on a control topic, from a client that holds the `admin` role.
// The server already holds a broker login; this gives it that role and uses
// it. No file, no reload, no docker socket, and the per-site credential
// model is unchanged - one username per shop, topics only under its own
// prefix.
//
// Isolation is a ROLE PER SITE with literal topics, not one role with `%u`:
// the 2.0 plugin does not substitute `%u` in ACL topics (measured - the
// publish was denied). The role is created and deleted with the client, so
// there is still exactly one thing to get right and it is done in one place.
package broker
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
const (
controlTopic = "$CONTROL/dynamic-security/v1"
responseTopic = "$CONTROL/dynamic-security/v1/response"
// A control round trip on a healthy broker is milliseconds; this is a
// bound on a broker that is up but not answering control commands, which
// is what a broker WITHOUT the plugin looks like.
commandTimeout = 8 * time.Second
connectTimeout = 10 * time.Second
)
// ErrUnavailable is returned when the broker cannot be reached or does not
// answer control commands. Callers turn it into a 502/503, never a 500: the
// operator's next step is to look at the broker, not at the server.
var ErrUnavailable = errors.New("broker: dynamic security not available")
// SiteBroker is what the API and the provisioner depend on. A fake satisfies
// it in tests; Dynsec satisfies it in production.
type SiteBroker interface {
// EnsureSite creates the broker login for one site, or resets its password
// if it already exists. Idempotent: safe to run again after any failure.
EnsureSite(ctx context.Context, username, password string) error
// DeleteSite removes the login and its role. Missing is not an error.
DeleteSite(ctx context.Context, username string) error
}
// Dynsec drives the plugin over its own connection - not the ingest client's.
// That one has SetOrderMatters and blocking handlers, and a provisioning call
// must neither wait behind a slow visit nor delay one.
type Dynsec struct {
url, user, pass string
log *log.Logger
mu sync.Mutex
client paho.Client
pending map[string]chan []response // keyed by batch id
}
func New(url, user, pass string, logger *log.Logger) *Dynsec {
if logger == nil {
logger = log.Default()
}
return &Dynsec{url: url, user: user, pass: pass, log: logger, pending: map[string]chan []response{}}
}
type response struct {
Command string `json:"command"`
Error string `json:"error,omitempty"`
CorrelationData string `json:"correlationData,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// SiteTopics are exactly what a shop PC may do: publish its own visits,
// heartbeats and status, and read its own commands. This mirrors the acl
// file the broker used to be configured with, rule for rule.
func siteACLs(username string) []map[string]any {
prefix := "bv/" + username
acl := func(kind, topic string) map[string]any {
return map[string]any{"acltype": kind, "topic": topic, "allow": true, "priority": 0}
}
return []map[string]any{
acl("publishClientSend", prefix+"/visit"),
acl("publishClientSend", prefix+"/heartbeat"),
acl("publishClientSend", prefix+"/status"),
acl("publishClientReceive", prefix+"/cmd/#"),
acl("subscribePattern", prefix+"/cmd/#"),
}
}
// RoleName is the per-site role. Exported so the migration can name it the
// same way.
func RoleName(username string) string { return "site." + username }
func (d *Dynsec) EnsureSite(ctx context.Context, username, password string) error {
if strings.TrimSpace(username) == "" || password == "" {
return errors.New("broker: username and password are required")
}
role := RoleName(username)
cmds := []map[string]any{{"command": "createRole", "rolename": role}}
for _, a := range siteACLs(username) {
c := map[string]any{"command": "addRoleACL", "rolename": role}
for k, v := range a {
c[k] = v
}
cmds = append(cmds, c)
}
cmds = append(cmds, map[string]any{
"command": "createClient", "username": username, "password": password,
"roles": []map[string]any{{"rolename": role}},
})
res, err := d.run(ctx, cmds)
if err != nil {
return err
}
clientExisted := false
for _, r := range res {
switch {
case r.Error == "":
case strings.Contains(r.Error, "already exists") && r.Command != "createClient":
// A re-run after a partial failure. Fine.
case r.Command == "createClient" && strings.Contains(r.Error, "already exists"):
clientExisted = true
default:
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
}
}
if !clientExisted {
return nil
}
// The login exists from an earlier run: make its password THIS one - the
// database holds this one and the enrolment hands it out - and make sure
// it carries the role. addClientRole on a client that already has it
// answers "Internal error", so check first rather than guess from prose.
res, err = d.run(ctx, []map[string]any{
{"command": "setClientPassword", "username": username, "password": password},
{"command": "getClient", "username": username},
})
if err != nil {
return err
}
hasRole := false
for _, r := range res {
if r.Error != "" {
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
}
if r.Command == "getClient" {
var got struct {
Client struct {
Roles []struct{ Rolename string } `json:"roles"`
} `json:"client"`
}
_ = json.Unmarshal(r.Data, &got)
for _, rr := range got.Client.Roles {
if rr.Rolename == role {
hasRole = true
}
}
}
}
if hasRole {
return nil
}
res, err = d.run(ctx, []map[string]any{{"command": "addClientRole", "username": username, "rolename": role}})
if err != nil {
return err
}
if res[0].Error != "" {
return fmt.Errorf("broker: addClientRole: %s", res[0].Error)
}
return nil
}
func (d *Dynsec) DeleteSite(ctx context.Context, username string) error {
res, err := d.run(ctx, []map[string]any{
{"command": "deleteClient", "username": username},
{"command": "deleteRole", "rolename": RoleName(username)},
})
if err != nil {
return err
}
for _, r := range res {
if r.Error != "" && !strings.Contains(r.Error, "not found") {
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
}
}
return nil
}
// Close drops the control connection. Safe when never connected.
func (d *Dynsec) Close() {
d.mu.Lock()
c := d.client
d.client = nil
d.mu.Unlock()
if c != nil && c.IsConnected() {
c.Disconnect(250)
}
}
// run sends one batch and waits for its one response message. Every command
// carries the batch id as correlationData, and the plugin echoes it, so a
// response for somebody else's batch - two servers, or a retry - is never
// mistaken for ours.
func (d *Dynsec) run(ctx context.Context, cmds []map[string]any) ([]response, error) {
if err := d.connect(ctx); err != nil {
return nil, err
}
id := newID()
for _, c := range cmds {
c["correlationData"] = id
}
body, err := json.Marshal(map[string]any{"commands": cmds})
if err != nil {
return nil, err
}
ch := make(chan []response, 1)
d.mu.Lock()
d.pending[id] = ch
client := d.client
d.mu.Unlock()
defer func() {
d.mu.Lock()
delete(d.pending, id)
d.mu.Unlock()
}()
tok := client.Publish(controlTopic, 1, false, body)
if !tok.WaitTimeout(commandTimeout) {
return nil, fmt.Errorf("%w: publish timed out", ErrUnavailable)
}
if tok.Error() != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
}
select {
case res := <-ch:
return res, nil
case <-time.After(commandTimeout):
return nil, fmt.Errorf("%w: no answer on %s - is the dynamic-security plugin enabled and does %q hold the admin role?", ErrUnavailable, responseTopic, d.user)
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (d *Dynsec) onResponse(_ paho.Client, m paho.Message) {
var env struct {
Responses []response `json:"responses"`
}
if err := json.Unmarshal(m.Payload(), &env); err != nil || len(env.Responses) == 0 {
return
}
id := env.Responses[0].CorrelationData
d.mu.Lock()
ch, ok := d.pending[id]
d.mu.Unlock()
if ok {
select {
case ch <- env.Responses:
default:
}
}
}
func (d *Dynsec) connect(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.client != nil && d.client.IsConnectionOpen() {
return nil
}
opts := paho.NewClientOptions().
AddBroker(d.url).
SetClientID(fmt.Sprintf("behavision-dynsec-%s", newID()[:8])).
SetUsername(d.user).
SetPassword(d.pass).
SetAutoReconnect(true).
SetCleanSession(true).
SetKeepAlive(30 * time.Second).
SetConnectTimeout(connectTimeout)
opts.OnConnect = func(c paho.Client) {
// Re-subscribed on every (re)connect: clean session keeps nothing.
c.Subscribe(responseTopic, 1, d.onResponse)
}
c := paho.NewClient(opts)
tok := c.Connect()
if !tok.WaitTimeout(connectTimeout) {
return fmt.Errorf("%w: connect to %s timed out", ErrUnavailable, d.url)
}
if tok.Error() != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
}
// The subscription must be in place before the first command is sent, or
// its answer is published to nobody.
st := c.Subscribe(responseTopic, 1, d.onResponse)
if !st.WaitTimeout(connectTimeout) || st.Error() != nil {
c.Disconnect(100)
return fmt.Errorf("%w: cannot subscribe to %s (does %q hold the admin role?)", ErrUnavailable, responseTopic, d.user)
}
d.client = c
return nil
}
func newID() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}

View File

@@ -0,0 +1,96 @@
package broker
import (
"context"
"os"
"testing"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
// Runs against a real Mosquitto with the dynamic-security plugin, because a
// fake broker would only prove the JSON matches what I believe the plugin
// wants. Gated on the environment like the store's live tests:
//
// DYNSEC_TEST_URL=tcp://127.0.0.1:51884 DYNSEC_TEST_USER=behavision-backend \
// DYNSEC_TEST_PASS=pw-backend go test ./internal/broker -run Live -v
func TestLiveEnsureAndDeleteSite(t *testing.T) {
url, user, pass := os.Getenv("DYNSEC_TEST_URL"), os.Getenv("DYNSEC_TEST_USER"), os.Getenv("DYNSEC_TEST_PASS")
if url == "" {
t.Skip("DYNSEC_TEST_URL not set")
}
d := New(url, user, pass, nil)
defer d.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
site := "livetest.shop1"
if err := d.EnsureSite(ctx, site, "first-pw"); err != nil {
t.Fatalf("EnsureSite: %v", err)
}
// Idempotent, and the password becomes the one given THIS time.
if err := d.EnsureSite(ctx, site, "second-pw"); err != nil {
t.Fatalf("EnsureSite again: %v", err)
}
if err := canConnect(url, site, "first-pw"); err == nil {
t.Fatalf("old password still accepted after re-ensure")
}
if err := canConnect(url, site, "second-pw"); err != nil {
t.Fatalf("new password refused: %v", err)
}
// Own topic allowed, another site's refused. A denied publish at QoS 1
// still gets a PUBACK, so this is observed through the backend's inbox.
got := make(chan string, 4)
backend := connect(t, url, user, pass)
defer backend.Disconnect(100)
backend.Subscribe("bv/#", 1, func(_ paho.Client, m paho.Message) { got <- m.Topic() }).Wait()
shop := connect(t, url, site, "second-pw")
defer shop.Disconnect(100)
shop.Publish("bv/other.shop/visit", 1, false, "leak").Wait()
shop.Publish("bv/"+site+"/visit", 1, false, "ok").Wait()
select {
case topic := <-got:
if topic != "bv/"+site+"/visit" {
t.Fatalf("first delivered topic was %s", topic)
}
case <-time.After(5 * time.Second):
t.Fatal("own-topic publish never arrived")
}
select {
case topic := <-got:
t.Fatalf("unexpected second delivery: %s", topic)
case <-time.After(1500 * time.Millisecond):
}
if err := d.DeleteSite(ctx, site); err != nil {
t.Fatalf("DeleteSite: %v", err)
}
if err := d.DeleteSite(ctx, site); err != nil {
t.Fatalf("DeleteSite twice: %v", err)
}
if err := canConnect(url, site, "second-pw"); err == nil {
t.Fatal("deleted site can still connect")
}
}
func connect(t *testing.T, url, user, pass string) paho.Client {
t.Helper()
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
tok := c.Connect()
tok.Wait()
if tok.Error() != nil {
t.Fatalf("connect as %s: %v", user, tok.Error())
}
return c
}
func canConnect(url, user, pass string) error {
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
tok := c.Connect()
tok.Wait()
if tok.Error() == nil {
c.Disconnect(50)
}
return tok.Error()
}

View File

@@ -0,0 +1,132 @@
package broker
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// Store is the plugin's on-disk shape - the part of it this code writes.
type Store struct {
Clients []Client `json:"clients"`
Roles []Role `json:"roles"`
DefaultACLAccess map[string]bool `json:"defaultACLAccess"`
}
type Client struct {
Username string `json:"username"`
TextName string `json:"textName,omitempty"`
Password string `json:"password"`
Salt string `json:"salt"`
Iterations int `json:"iterations"`
Roles []RoleRef `json:"roles"`
}
type RoleRef struct {
Rolename string `json:"rolename"`
}
type Role struct {
Rolename string `json:"rolename"`
ACLs []ACL `json:"acls"`
}
type ACL struct {
ACLType string `json:"acltype"`
Topic string `json:"topic"`
Allow bool `json:"allow"`
Priority int `json:"priority"`
}
// FromPasswd converts a mosquitto_passwd file into the plugin's store,
// keeping every password exactly as it is.
//
// This is the cutover for a broker that already has sites: the hashes in the
// passwd file are PBKDF2-SHA512 ($7$<iterations>$<salt>$<digest>, base64),
// which is the same thing the plugin stores as password/salt/iterations - so
// no shop PC has to be re-claimed and no credential changes hands. The roles
// reproduce the acl file rule for rule: the backend reads everything and
// drives the plugin, the health probe reads uptime, and every other user is a
// site that may write under its own prefix and read its own commands.
func FromPasswd(r io.Reader, backendUser, healthUser string) (*Store, error) {
st := &Store{
DefaultACLAccess: map[string]bool{
"publishClientSend": false, "publishClientReceive": false,
"subscribe": false, "unsubscribe": true,
},
}
st.Roles = append(st.Roles,
Role{Rolename: "admin", ACLs: []ACL{
{"publishClientSend", "$CONTROL/dynamic-security/#", true, 0},
{"publishClientReceive", "$CONTROL/dynamic-security/#", true, 0},
{"subscribePattern", "$CONTROL/dynamic-security/#", true, 0},
}},
Role{Rolename: "backend", ACLs: []ACL{
{"publishClientSend", "bv/#", true, 0},
{"publishClientReceive", "bv/#", true, 0},
{"subscribePattern", "bv/#", true, 0},
{"publishClientReceive", "$SYS/#", true, 0},
{"subscribePattern", "$SYS/#", true, 0},
}},
Role{Rolename: "health", ACLs: []ACL{
{"publishClientReceive", "$SYS/broker/uptime", true, 0},
{"subscribePattern", "$SYS/broker/uptime", true, 0},
}},
)
sc := bufio.NewScanner(r)
line := 0
for sc.Scan() {
line++
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
user, hash, ok := strings.Cut(text, ":")
if !ok {
return nil, fmt.Errorf("passwd line %d: no ':'", line)
}
parts := strings.Split(hash, "$")
// "", "7", iterations, salt, digest
if len(parts) != 5 || parts[1] != "7" {
return nil, fmt.Errorf("passwd line %d (%s): not a $7$ PBKDF2 hash; re-set that password with mosquitto_passwd first", line, user)
}
iters, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("passwd line %d (%s): iterations %q", line, user, parts[2])
}
c := Client{Username: user, Password: parts[4], Salt: parts[3], Iterations: iters}
switch user {
case backendUser:
c.TextName = "Behavision server"
c.Roles = []RoleRef{{"admin"}, {"backend"}}
case healthUser:
c.TextName = "health probe"
c.Roles = []RoleRef{{"health"}}
default:
role := RoleName(user)
var acls []ACL
for _, a := range siteACLs(user) {
acls = append(acls, ACL{a["acltype"].(string), a["topic"].(string), true, 0})
}
st.Roles = append(st.Roles, Role{Rolename: role, ACLs: acls})
c.TextName = "site " + user
c.Roles = []RoleRef{{role}}
}
st.Clients = append(st.Clients, c)
}
if err := sc.Err(); err != nil {
return nil, err
}
return st, nil
}
// Encode writes the store as the plugin reads it.
func (s *Store) Encode(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", "\t")
return enc.Encode(s)
}

View File

@@ -0,0 +1,73 @@
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)
}
}

View File

@@ -28,6 +28,15 @@ import (
type Provisioner struct { type Provisioner struct {
Pool *pgxpool.Pool Pool *pgxpool.Pool
Secrets *secret.Box Secrets *secret.Box
// Broker registers a site's login with Mosquitto when set. Without it the
// command prints the password for a hand edit of the passwd file, which
// is the pre-dynamic-security fallback and nothing more.
Broker SiteBroker
}
// SiteBroker mirrors internal/broker's interface without importing it.
type SiteBroker interface {
EnsureSite(ctx context.Context, username, password string) error
} }
func (p *Provisioner) CreateClient(ctx context.Context, slug, name string) (string, error) { func (p *Provisioner) CreateClient(ctx context.Context, slug, name string) (string, error) {
@@ -48,6 +57,9 @@ type SiteResult struct {
AgentID string AgentID string
Username string Username string
Password string Password string
// BrokerRegistered is true when the login was pushed to Mosquitto here,
// so nothing remains for a person to type.
BrokerRegistered bool
} }
// CreateSite makes a site, its agent row, and the broker password. // CreateSite makes a site, its agent row, and the broker password.
@@ -120,7 +132,16 @@ func (p *Provisioner) CreateSite(ctx context.Context, clientSlug, siteSlug, name
out.AgentID, sealed); err != nil { out.AgentID, sealed); err != nil {
return out, err return out, err
} }
return out, tx.Commit(ctx) if err := tx.Commit(ctx); err != nil {
return out, err
}
if p.Broker != nil {
if err := p.Broker.EnsureSite(ctx, out.Username, out.Password); err != nil {
return out, fmt.Errorf("site %s created, but the broker did not accept its login: %w", out.Username, err)
}
out.BrokerRegistered = true
}
return out, nil
} }
func (p *Provisioner) CreateUser(ctx context.Context, clientSlug, email, role, func (p *Provisioner) CreateUser(ctx context.Context, clientSlug, email, role,

View File

@@ -0,0 +1,115 @@
package store
import (
"context"
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/api"
)
// CreateSite is the row half of opening a shop. The broker half follows it in
// the handler and the provisioner, and both use this so there is one
// definition of what a site is.
func (s *Store) CreateSite(ctx context.Context, clientID, slug, name, tz string) (api.NewSite, error) {
var out api.NewSite
if s.secrets == nil {
return out, ErrNoSecrets
}
slug = strings.ToLower(strings.TrimSpace(slug))
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return out, fmt.Errorf("unknown timezone %q", tz)
}
var clientSlug string
if err := s.pool.QueryRow(ctx, `SELECT slug FROM clients WHERE id = $1::uuid`, clientID).Scan(&clientSlug); err != nil {
return out, fmt.Errorf("client %s: %w", clientID, err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx) //nolint:errcheck
// A plain INSERT, not an upsert: the API must not let an owner silently
// rename an existing shop by re-posting its slug. A duplicate surfaces as
// 23505 and the handler turns it into 409.
if err := tx.QueryRow(ctx, `
INSERT INTO sites (client_id, slug, name, timezone)
VALUES ($1::uuid, $2, $3, $4)
RETURNING id::text`, clientID, slug, name, tz).Scan(&out.SiteID); err != nil {
return out, err
}
out.Slug, out.Name, out.Timezone = slug, name, tz
// The broker username IS the topic namespace: <client>.<site>. The ACL is
// written against it, so it is derived, never chosen.
out.Username = clientSlug + "." + slug
var agentID string
if err := tx.QueryRow(ctx, `
INSERT INTO agents (client_id, site_id, mqtt_username)
VALUES ($1::uuid, $2::uuid, $3)
RETURNING id::text`, clientID, out.SiteID, out.Username).Scan(&agentID); err != nil {
return out, fmt.Errorf("create agent: %w", err)
}
out.Password, err = randomSecret(24)
if err != nil {
return out, err
}
// Sealed with the agent id as aad, so a row copied between agents does not
// decrypt into a working credential.
sealed, err := s.secrets.SealString(out.Password, agentID)
if err != nil {
return out, err
}
if _, err := tx.Exec(ctx, `UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`, agentID, sealed); err != nil {
return out, err
}
return out, tx.Commit(ctx)
}
// DeleteNewSite removes a shop that was created moments ago and could not be
// registered with the broker. Scoped to the tenant and refused once the shop
// has anything under it: this is compensation for a failed create, not a
// delete-shop feature.
func (s *Store) DeleteNewSite(ctx context.Context, clientID, siteID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var used bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid)
OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid)`, siteID).Scan(&used); err != nil {
return err
}
if used {
return fmt.Errorf("site %s is in use", siteID)
}
if _, err := tx.Exec(ctx, `DELETE FROM agents WHERE site_id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
return err
}
return tx.Commit(ctx)
}
func randomSecret(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// base32 without padding: this gets typed, pasted into config files and
// read down a phone line, and base64's + / = survive none of that.
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<title>Behavision</title> <title>Behavision</title>
<script type="module" crossorigin src="/assets/index-BI5JLIeo.js"></script> <script type="module" crossorigin src="/assets/index-xQ5C-n-U.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css">
</head> </head>
<body> <body>

View File

@@ -205,6 +205,7 @@ export const api = {
me: () => send('GET', '/api/auth/me'), me: () => send('GET', '/api/auth/me'),
sites: () => send('GET', '/api/sites'), sites: () => send('GET', '/api/sites'),
createSite: (input) => send('POST', '/api/sites', input),
// The live arrivals feed. `cursor` is opaque and must be echoed back. // The live arrivals feed. `cursor` is opaque and must be echoed back.
arrivals: (params) => send('GET', '/api/visits' + qs(params)), arrivals: (params) => send('GET', '/api/visits' + qs(params)),

View File

@@ -17,18 +17,22 @@ import SiteCheck from './SiteCheck.jsx'
// shops. So the shop's own camera view is the card, the numbers sit under it, // shops. So the shop's own camera view is the card, the numbers sit under it,
// and one line says what to do — with the technical detail one click away // and one line says what to do — with the technical detail one click away
// rather than on the surface. // rather than on the surface.
export default function Sites() { export default function Sites({ user }) {
const { data, error, loading } = usePolled(() => api.sites(), 20000, []) const { data, error, loading, reload } = usePolled(() => api.sites(), 20000, [])
// Cameras come from a second call and are joined here rather than server-side: // Cameras come from a second call and are joined here rather than server-side:
// the picture is decoration on this screen, so it must never be able to make // the picture is decoration on this screen, so it must never be able to make
// the health list fail. If this errors the cards simply have no photograph. // the health list fail. If this errors the cards simply have no photograph.
const { data: cams } = usePolled(() => api.cameras(), 60000, []) const { data: cams } = usePolled(() => api.cameras(), 60000, [])
const [checking, setChecking] = useState(null) const [checking, setChecking] = useState(null)
const [opening, setOpening] = useState(false)
const sites = data || [] const sites = data || []
// Opening a shop is the owner's: it is a billing and tenancy object, not a
// setting. Managers set up the PC and cameras once it exists.
const canOpen = user?.role === 'owner'
if (loading && !data) return <Loading /> if (loading && !data) return <Loading />
if (error) return <Problem error={error} /> if (error) return <Problem error={error} />
if (!sites.length) return <Empty /> if (!sites.length && !opening) return <Empty canOpen={canOpen} onOpen={() => setOpening(true)} />
// Counted from the same verdicts the cards show. Summarising with a second, // Counted from the same verdicts the cards show. Summarising with a second,
// simpler rule up here is how a header ends up reading "all working" over a // simpler rule up here is how a header ends up reading "all working" over a
@@ -51,6 +55,9 @@ export default function Sites() {
{fresh > 0 && <> · {fresh} not set up yet</>} {fresh > 0 && <> · {fresh} not set up yet</>}
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>} {!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
</p> </p>
{canOpen && (
<button className="primary" onClick={() => setOpening(true)}>Open a new shop</button>
)}
</header> </header>
<div className="grid sites"> <div className="grid sites">
@@ -64,10 +71,66 @@ export default function Sites() {
button on the camera screen that always checked sites[0], so with two button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */} shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />} {checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
{opening && <NewShop onClose={() => setOpening(false)}
onCreated={() => { setOpening(false); reload() }} />}
</> </>
) )
} }
// Until this existed a shop was a command on the server plus a hand edit of
// the broker's password file - every new branch a support ticket, and the one
// piece of onboarding that could not be done from the product. The server now
// registers the shop's broker login as it creates the row, so the next step
// really is just "Set up a shop PC" on the new card.
function NewShop({ onClose, onCreated }) {
const [form, setForm] = useState({ name: '', slug: '', timezone: 'Asia/Kolkata' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
onCreated(await api.createSite({ name: form.name, slug: form.slug || undefined, timezone: form.timezone }))
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<h2>Open a new shop</h2>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<form className="drawer-body" onSubmit={submit}>
<label>Shop name
<input value={form.name} onChange={set('name')} required autoFocus placeholder="TeNext Bengaluru" />
</label>
<label>Short name
<input value={form.slug} onChange={set('slug')} placeholder="made from the name if left empty" />
<span className="hint">
Lower-case letters, digits and dashes. It becomes the shop PC’s
identity and cannot be changed afterwards.
</span>
</label>
<label>Timezone
<input value={form.timezone} onChange={set('timezone')} required />
<span className="hint">Footfall is bucketed by the shop’s own clock, e.g. Asia/Kolkata.</span>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy || !form.name.trim()}>
{busy ? 'Opening…' : 'Open shop'}
</button>
</form>
</aside>
</div>
)
}
// The one line the card leads with, in severity order. Only the first is shown: // The one line the card leads with, in severity order. Only the first is shown:
// a shop that is offline AND has a bad camera needs its PC turned on first, and // a shop that is offline AND has a bad camera needs its PC turned on first, and
// listing both invites someone to start with the wrong one. // listing both invites someone to start with the wrong one.
@@ -272,13 +335,15 @@ export function Problem({ error }) {
) )
} }
function Empty() { function Empty({ canOpen, onOpen }) {
return ( return (
<div className="state"> <div className="state">
<h2>No shops yet</h2> <h2>No shops yet</h2>
<p className="sub"> <p className="sub">
A shop appears here once its PC has been claimed with an enrolment code. {canOpen ? 'Open your first shop, then set up its PC and cameras from its card.'
: 'The owner opens shops; they appear here once created.'}
</p> </p>
{canOpen && <button className="primary" onClick={onOpen}>Open a new shop</button>}
</div> </div>
) )
} }