Behavision: face recognition for retail, edge to head office
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
This commit is contained in:
246
server/internal/provision/provision.go
Normal file
246
server/internal/provision/provision.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// 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.
|
||||
Reference in New Issue
Block a user