A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.
Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.
Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.
Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.
Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.
Found by running it, not by tests:
* UPDATE ... RETURNING gives the value AFTER the update, so the prune
read back empty keys, deleted nothing, and the table grew with
footfall exactly as if it were not there. The fake agreed with either
version; only the live Postgres test caught it.
* Trusting only the auth flag broke every shop card, because Sites.jsx
rebuilt a partial snapshot object and dropped it. A relative URL is
now sufficient on its own.
* ago() renders a future time as "just now", so a code valid for a week
read "expires just now".
Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
188 lines
6.8 KiB
Go
188 lines
6.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Face images held by this server, for a deployment with no object storage.
|
|
//
|
|
// The bucket stays primary wherever one is configured: a presigned PUT never
|
|
// passes the bytes through the API at all, which is what makes it the right
|
|
// route at estate scale. This is the fallback that stops "no S3 account" from
|
|
// meaning "no photograph of any customer, ever" - see migration 011 for why it
|
|
// is bounded and therefore safe to keep here.
|
|
|
|
// DBKeyPrefix marks an image key that names a row in this database rather than
|
|
// an object in a bucket.
|
|
//
|
|
// One column, `visits.image_key`, names either. A prefix rather than a second
|
|
// nullable column because every read already has the key in hand and can tell
|
|
// which store to ask without a further lookup - and because a key that does not
|
|
// say where it lives is a key some future caller will hand to the wrong one.
|
|
const DBKeyPrefix = "db:"
|
|
|
|
// ErrNoFace means there is no stored image under that key. Ordinary absence,
|
|
// not a fault: most deployments store no faces at all.
|
|
var ErrNoFace = errors.New("no such face image")
|
|
|
|
// PutVisitFace stores one face crop and returns the key that names it.
|
|
//
|
|
// The client and site come from the AGENT'S credential, never from the request,
|
|
// so a shop PC cannot file an image under another tenant. There is no visitor
|
|
// id yet - the server has not matched the template at this point - so the row
|
|
// is claimed later, by RecordVisit, and swept if that never happens.
|
|
func (s *Store) PutVisitFace(ctx context.Context, clientID, siteID string,
|
|
jpeg []byte) (string, error) {
|
|
|
|
var id string
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO visit_faces (client_id, site_id, image, bytes)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4)
|
|
RETURNING id::text`, clientID, siteID, jpeg, len(jpeg)).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("store face: %w", err)
|
|
}
|
|
return DBKeyPrefix + id, nil
|
|
}
|
|
|
|
// VisitFace reads one back, scoped to the tenant that is asking.
|
|
//
|
|
// The client id is in the WHERE clause and not merely checked afterwards: an
|
|
// image key travels in an API response, and a caller who kept one from a
|
|
// previous tenancy - or guessed one - must get nothing rather than a photograph
|
|
// of somebody else's customer.
|
|
func (s *Store) VisitFace(ctx context.Context, clientID, key string) ([]byte, error) {
|
|
id, ok := strings.CutPrefix(key, DBKeyPrefix)
|
|
if !ok || !looksLikeUUID(id) {
|
|
return nil, ErrNoFace
|
|
}
|
|
var img []byte
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT image FROM visit_faces
|
|
WHERE id = $1::uuid AND client_id = $2::uuid`, id, clientID).Scan(&img)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNoFace
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read face: %w", err)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
// DeleteVisitFaces erases stored faces outright.
|
|
//
|
|
// Used by the erasure path, which must destroy the image rather than unlink it.
|
|
// The rule the bucket path already follows applies unchanged: a face image that
|
|
// survives an erasure request is the one outcome that endpoint must never
|
|
// produce, so a failure here has to reach the caller.
|
|
func (s *Store) DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error {
|
|
ids := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
if id, ok := strings.CutPrefix(k, DBKeyPrefix); ok && looksLikeUUID(id) {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
DELETE FROM visit_faces
|
|
WHERE client_id = $1::uuid AND id = ANY($2::uuid[])`, clientID, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("delete faces: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pruneVisitorFaces keeps ONE stored face per visitor: the newest.
|
|
//
|
|
// This is what bounds the table to the customer base rather than to footfall,
|
|
// and it is the whole reason face images may live in Postgres at all. It runs
|
|
// inside RecordVisit's transaction, right after the visit is linked to a
|
|
// person, so the superseded row and the key that named it disappear together.
|
|
//
|
|
// ONE statement, and that is not tidiness. The first version read the old keys
|
|
// with `UPDATE visits SET image_key = ” ... RETURNING image_key` - which
|
|
// returns the value AFTER the update, so every key came back as the empty
|
|
// string it had just been set to, the delete list was always empty, and the
|
|
// table grew with footfall exactly as if the prune did not exist. The visits
|
|
// looked right; only the row count gave it away. A CTE cannot have that bug:
|
|
// `doomed` reads the pre-image, and both the update and the delete are driven
|
|
// from it.
|
|
//
|
|
// The old key is blanked rather than marked deleted. `image_deleted_at` means
|
|
// an erasure was performed and is what an auditor reads; borrowing it to mean
|
|
// "we kept a better photo" would put ordinary housekeeping into the record of
|
|
// legal requests.
|
|
func pruneVisitorFaces(ctx context.Context, tx pgx.Tx, clientID, visitorID, keepVisitID string) error {
|
|
_, err := tx.Exec(ctx, `
|
|
WITH doomed AS (
|
|
SELECT v.id, v.image_key
|
|
FROM visits v
|
|
WHERE v.client_id = $1::uuid
|
|
AND v.visitor_id = $2::uuid
|
|
AND v.id <> $3::uuid
|
|
AND v.image_key LIKE 'db:%'
|
|
), cleared AS (
|
|
UPDATE visits SET image_key = ''
|
|
WHERE id IN (SELECT id FROM doomed)
|
|
)
|
|
DELETE FROM visit_faces f
|
|
WHERE f.client_id = $1::uuid
|
|
-- Joined on the text form deliberately: the alternative is casting a
|
|
-- substring of a stored key to uuid, which throws on a malformed row
|
|
-- and would take an ordinary visit down with it.
|
|
AND 'db:' || f.id::text IN (SELECT image_key FROM doomed)`,
|
|
clientID, visitorID, keepVisitID)
|
|
if err != nil {
|
|
return fmt.Errorf("prune faces: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SweepOrphanFaces removes images no visit ever claimed.
|
|
//
|
|
// An agent uploads a face before the server has decided who it is, so a row is
|
|
// briefly unreferenced by design. It stays that way for good if the visit that
|
|
// would have claimed it never arrives - a dropped queue, a corrupt entry - and
|
|
// that is one stored photograph of a real person that nothing points at and
|
|
// nothing would ever delete. Erasure could not reach it either: it is found
|
|
// through the visitor, and this row has none.
|
|
func (s *Store) SweepOrphanFaces(ctx context.Context, olderThan string) (int, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM visit_faces f
|
|
WHERE f.captured_at < now() - $1::interval
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM visits v
|
|
WHERE v.image_key = 'db:' || f.id::text)`, olderThan)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sweep faces: %w", err)
|
|
}
|
|
return int(tag.RowsAffected()), nil
|
|
}
|
|
|
|
func looksLikeUUID(s string) bool {
|
|
if len(s) != 36 {
|
|
return false
|
|
}
|
|
for i, c := range s {
|
|
switch i {
|
|
case 8, 13, 18, 23:
|
|
if c != '-' {
|
|
return false
|
|
}
|
|
default:
|
|
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
if !isHex {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|