Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
247 lines
8.1 KiB
Go
247 lines
8.1 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return out, tx.Commit(ctx)
|
|
}
|
|
|
|
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.
|