// 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 // . 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.