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:
115
server/internal/store/api_sites.go
Normal file
115
server/internal/store/api_sites.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user