Files
Behavision/server/internal/provision/provision.go
Suriyakumarvijayanayagam 4c750cb2ac 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
2026-09-19 11:55:26 +05:30

268 lines
8.9 KiB
Go

// Package provision creates the rows a new customer needs before anything else
// works: a client, a site with its broker identity, staff logins, and the
// one-shot codes their PCs enrol with.
//
// It is a command, not an API. Creating a tenant is rare, needs database
// access anyway to add the matching Mosquitto user, and an HTTP endpoint that
// mints tenants is a much larger thing to have to secure than a subcommand
// that only runs on the box.
package provision
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/auth"
"github.com/loyaly/behavision-server/internal/secret"
)
type Provisioner struct {
Pool *pgxpool.Pool
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) {
slug = strings.ToLower(strings.TrimSpace(slug))
var id string
err := p.Pool.QueryRow(ctx, `
INSERT INTO clients (slug, name) VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id::text`, slug, name).Scan(&id)
return id, err
}
// SiteResult carries the broker credential exactly once. It is stored
// encrypted and never returned by any HTTP route, so this is the only moment
// the plaintext exists outside the enrolment response.
type SiteResult struct {
SiteID string
AgentID string
Username 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.
//
// The password is generated here rather than typed: it is never memorised by
// anyone, it goes into Mosquitto's passwd file and into this database sealed,
// and a human-chosen one would only be weaker.
func (p *Provisioner) CreateSite(ctx context.Context, clientSlug, siteSlug, name, tz string) (
SiteResult, error) {
var out SiteResult
if p.Secrets == nil {
return out, errors.New("BEHAVISION_SECRET_KEY must be set to create a site")
}
clientSlug = strings.ToLower(strings.TrimSpace(clientSlug))
siteSlug = strings.ToLower(strings.TrimSpace(siteSlug))
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return out, fmt.Errorf("unknown timezone %q", tz)
}
var clientID string
if err := p.Pool.QueryRow(ctx,
`SELECT id::text FROM clients WHERE slug = $1`, clientSlug).
Scan(&clientID); err != nil {
return out, fmt.Errorf("no client %q: %w", clientSlug, err)
}
tx, err := p.Pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx) //nolint:errcheck
if err := tx.QueryRow(ctx, `
INSERT INTO sites (client_id, slug, name, timezone)
VALUES ($1, $2, $3, $4)
ON CONFLICT (client_id, slug) DO UPDATE
SET name = EXCLUDED.name, timezone = EXCLUDED.timezone
RETURNING id::text`, clientID, siteSlug, name, tz).
Scan(&out.SiteID); err != nil {
return out, fmt.Errorf("create site: %w", err)
}
// The username IS the MQTT topic prefix, enforced by the broker's
// `pattern write bv/%u/...`. It must match contract.ParseTopic's
// <client>.<site> shape exactly or the server cannot resolve the tenant.
out.Username = clientSlug + "." + siteSlug
if err := tx.QueryRow(ctx, `
INSERT INTO agents (client_id, site_id, mqtt_username)
VALUES ($1, $2::uuid, $3)
ON CONFLICT (site_id) DO UPDATE SET mqtt_username = EXCLUDED.mqtt_username
RETURNING id::text`, clientID, out.SiteID, out.Username).
Scan(&out.AgentID); err != nil {
return out, fmt.Errorf("create agent: %w", err)
}
out.Password, err = randomSecret(24)
if err != nil {
return out, err
}
sealed, err := p.Secrets.SealString(out.Password, out.AgentID)
if err != nil {
return out, err
}
if _, err := tx.Exec(ctx,
`UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`,
out.AgentID, sealed); err != nil {
return out, err
}
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,
fullName, password string) (string, string, error) {
email = auth.NormalizeEmail(email)
if email == "" {
return "", "", errors.New("email is required")
}
switch role {
case "owner", "manager", "staff", "admin":
default:
return "", "", fmt.Errorf("role must be owner, manager, staff or admin")
}
if role == "admin" && clientSlug != "" {
// A platform admin is defined by having no client. Letting one be
// created inside a tenant would produce an account whose scope depends
// on which query happens to check first.
return "", "", errors.New("an admin has no client - omit -client")
}
if role != "admin" && clientSlug == "" {
return "", "", errors.New("-client is required for this role")
}
if password == "" {
var err error
if password, err = randomSecret(16); err != nil {
return "", "", err
}
}
hash, err := auth.HashPassword(password)
if err != nil {
return "", "", err
}
var clientID any
if clientSlug != "" {
var id string
if err := p.Pool.QueryRow(ctx,
`SELECT id::text FROM clients WHERE slug = $1`,
strings.ToLower(clientSlug)).Scan(&id); err != nil {
return "", "", fmt.Errorf("no client %q: %w", clientSlug, err)
}
clientID = id
}
// Upsert, because resetting a forgotten password is the other reason this
// command exists. The WHERE is what keeps that from being a way to hijack
// an account: an address is unique across the whole platform now, so
// without it `provision user -client acme -email someone@..` would quietly
// take an existing platform admin - or another company's owner - and
// rewrite their password and role, leaving the row attached to its
// original client.
var id string
err = p.Pool.QueryRow(ctx, `
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
VALUES ($1::uuid, $2, $3, $4, $5)
ON CONFLICT (lower(email))
DO UPDATE SET password_hash = EXCLUDED.password_hash,
full_name = EXCLUDED.full_name,
role = EXCLUDED.role,
active = true
WHERE app_users.client_id IS NOT DISTINCT FROM EXCLUDED.client_id
RETURNING id::text`, clientID, email, hash, fullName, role).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("%s already has an account in another company "+
"(or is a platform admin); one address is one account", email)
}
return id, password, err
}
// IssueEnrolmentToken mints the code an installer types once.
//
// Short-lived on purpose: it is read aloud, pasted into chat and photographed,
// and it is the only thing standing between a stranger and a site's broker
// credentials. A week is long enough to get an engineer to a shop.
func (p *Provisioner) IssueEnrolmentToken(ctx context.Context, clientSlug,
siteSlug, label string, ttl time.Duration) (string, time.Time, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
var clientID, siteID string
if err := p.Pool.QueryRow(ctx, `
SELECT c.id::text, si.id::text
FROM sites si JOIN clients c ON c.id = si.client_id
WHERE c.slug = $1 AND si.slug = $2`,
strings.ToLower(clientSlug), strings.ToLower(siteSlug)).
Scan(&clientID, &siteID); err != nil {
return "", time.Time{}, fmt.Errorf("no site %s/%s: %w",
clientSlug, siteSlug, err)
}
code, err := auth.NewEnrolmentCode()
if err != nil {
return "", time.Time{}, err
}
expires := time.Now().Add(ttl).UTC()
if _, err := p.Pool.Exec(ctx, `
INSERT INTO site_enrolment_tokens (client_id, site_id, token_hash,
label, expires_at)
VALUES ($1::uuid, $2::uuid, $3, $4, $5)`,
clientID, siteID, auth.HashToken(auth.NormalizeCode(code)),
label, expires); err != nil {
return "", time.Time{}, err
}
return code, expires, nil
}
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
}
// enrolmentCode is grouped for reading aloud. The hyphens are cosmetic - the
// handler strips them before hashing - so an operator who types it without
// them still gets in.