Accounts people can create, and photos on a server with no bucket
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
This commit is contained in:
187
server/internal/store/api_faces.go
Normal file
187
server/internal/store/api_faces.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
261
server/internal/store/api_faces_live_test.go
Normal file
261
server/internal/store/api_faces_live_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/contract"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
)
|
||||
|
||||
// Face images held by this server, against a real database.
|
||||
//
|
||||
// The prune is the whole reason this is allowed to live in Postgres at all -
|
||||
// migration 011 argues it explicitly against 009's "face images grow with every
|
||||
// visitor who ever walks in" - so it is the one behaviour that must be proved
|
||||
// against the real thing rather than a fake that would simply agree with me.
|
||||
|
||||
func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var site ingest.Site
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO clients (name, slug) VALUES ($1, $1) RETURNING id::text`,
|
||||
name).Scan(&site.ClientID); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
||||
t.Fatalf("seed site: %v", err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO agents (client_id, site_id, mqtt_username)
|
||||
VALUES ($1::uuid, $2::uuid, $3)
|
||||
RETURNING id::text`, site.ClientID, site.SiteID, name).Scan(&site.AgentID); err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
site.Slug = name
|
||||
return site
|
||||
}
|
||||
|
||||
func embedding(seed float32) []float32 {
|
||||
v := make([]float32, contract.EmbeddingDim)
|
||||
for i := range v {
|
||||
v[i] = seed
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// The bound: one person seen many times leaves ONE stored image, not one per
|
||||
// visit. Without this the table grows with footfall, which is precisely the
|
||||
// property that keeps face images out of the database everywhere else.
|
||||
func TestLiveOnlyOneFaceSurvivesPerVisitor(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces-"+stamp())
|
||||
|
||||
var keys []string
|
||||
for i := 0; i < 5; i++ {
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID,
|
||||
[]byte(fmt.Sprintf("jpeg-%d", i)))
|
||||
if err != nil {
|
||||
t.Fatalf("store face %d: %v", i, err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
|
||||
// The SAME person every time: one embedding, so the matcher resolves
|
||||
// them to one visitor.
|
||||
ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: fmt.Sprintf("%s-%d", site.Slug, i),
|
||||
OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second),
|
||||
CameraID: "door",
|
||||
IsNew: i == 0,
|
||||
Quality: 0.8,
|
||||
Similarity: 0.9,
|
||||
Embedding: embedding(0.05),
|
||||
ImageKey: key,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("visit %d: ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stored int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != 1 {
|
||||
t.Fatalf("five visits by one person left %d stored faces - the table "+
|
||||
"grows with footfall, which is exactly what migration 011 promises "+
|
||||
"it does not", stored)
|
||||
}
|
||||
|
||||
// And it is the NEWEST that survived: every surface shows a customer's
|
||||
// latest view, so keeping an older one would quietly show a stale face.
|
||||
var surviving string
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT 'db:' || id::text FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&surviving); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if surviving != keys[len(keys)-1] {
|
||||
t.Errorf("kept %s, want the newest %s", surviving, keys[len(keys)-1])
|
||||
}
|
||||
|
||||
// The superseded keys are blanked, not left dangling. A visit advertising
|
||||
// an image that is not there renders as a broken picture on the one screen
|
||||
// meant to show it.
|
||||
var dangling int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM visits v
|
||||
WHERE v.client_id = $1::uuid AND v.image_key LIKE 'db:%'
|
||||
AND NOT EXISTS (SELECT 1 FROM visit_faces f
|
||||
WHERE 'db:' || f.id::text = v.image_key)`,
|
||||
site.ClientID).Scan(&dangling); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dangling != 0 {
|
||||
t.Errorf("%d visits point at a face that is gone", dangling)
|
||||
}
|
||||
|
||||
// image_deleted_at is the record of an ERASURE and is what an auditor
|
||||
// reads. Ordinary housekeeping must not write into it.
|
||||
var marked int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM visits
|
||||
WHERE client_id = $1::uuid AND image_deleted_at IS NOT NULL`,
|
||||
site.ClientID).Scan(&marked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if marked != 0 {
|
||||
t.Errorf("%d visits were marked as erased by a routine prune", marked)
|
||||
}
|
||||
}
|
||||
|
||||
// Two different people keep one face each. The prune must be scoped to the
|
||||
// person, not to the site - otherwise every new arrival would delete the
|
||||
// previous customer's photo.
|
||||
func TestLiveThePruneIsPerPersonNotPerSite(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces2-"+stamp())
|
||||
|
||||
for i, seed := range []float32{0.05, -0.05} {
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID,
|
||||
[]byte(fmt.Sprintf("person-%d", i)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: fmt.Sprintf("%s-p%d", site.Slug, i),
|
||||
OccurredAt: time.Now().UTC(),
|
||||
CameraID: "door",
|
||||
IsNew: true,
|
||||
Quality: 0.8,
|
||||
Embedding: embedding(seed),
|
||||
ImageKey: key,
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("visit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stored int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != 2 {
|
||||
t.Fatalf("two people should keep one face each, got %d", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent uploads a face BEFORE the server has decided who it is, so a row is
|
||||
// briefly unreferenced by design - and permanently so if the visit that would
|
||||
// have claimed it never arrives. That is a stored photograph of a real person
|
||||
// that nothing points at, which erasure could never reach because it is found
|
||||
// through the visitor and this row has none.
|
||||
func TestLiveAnUnclaimedFaceIsSweptAway(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces3-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("orphan"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Age it past the sweep window rather than sleeping.
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
UPDATE visit_faces SET captured_at = now() - interval '3 days'
|
||||
WHERE 'db:' || id::text = $1`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := st.SweepOrphanFaces(ctx, "1 day")
|
||||
if err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Fatal("the orphan was not swept")
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, site.ClientID, key); err == nil {
|
||||
t.Fatal("the orphan is still readable")
|
||||
}
|
||||
}
|
||||
|
||||
// A face a visit DOES point at must survive the sweep, however old it is. A
|
||||
// regular customer's photo is exactly the row that gets old.
|
||||
func TestLiveTheSweepKeepsAClaimedFace(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces4-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("kept"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: site.Slug + "-keep", OccurredAt: time.Now().UTC(),
|
||||
CameraID: "door", IsNew: true, Quality: 0.8,
|
||||
Embedding: embedding(0.07), ImageKey: key,
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("visit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
UPDATE visit_faces SET captured_at = now() - interval '400 days'
|
||||
WHERE 'db:' || id::text = $1`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := st.SweepOrphanFaces(ctx, "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, site.ClientID, key); err != nil {
|
||||
t.Fatalf("a claimed face was swept away: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An image key travels in API responses. A caller who kept one, or guessed one,
|
||||
// must get nothing rather than another company's customer.
|
||||
func TestLiveAFaceIsNotReadableByAnotherTenant(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
a := seedAgentSite(t, st, "facesa-"+stamp())
|
||||
b := seedAgentSite(t, st, "facesb-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, a.ClientID, a.SiteID, []byte("private"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, b.ClientID, key); err == nil {
|
||||
t.Fatal("another tenant read a stored face")
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, a.ClientID, key); err != nil {
|
||||
t.Fatalf("the owning tenant could not read its own face: %v", err)
|
||||
}
|
||||
}
|
||||
347
server/internal/store/api_team.go
Normal file
347
server/internal/store/api_team.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// Adding people to a company, and taking them out again.
|
||||
//
|
||||
// Registration here is by invitation only. `handlers_team.go` carries the
|
||||
// product argument; what matters at this layer is that every statement is
|
||||
// scoped by the CALLER'S client id, taken from their session, so a manager
|
||||
// cannot invite somebody into, list, or remove a member of a company that is
|
||||
// not theirs by guessing a uuid.
|
||||
|
||||
// CreateInvitation writes a pending invitation for one company.
|
||||
//
|
||||
// The client id is not trusted from a caller anywhere above this, but it is
|
||||
// still joined against `clients` here rather than inserted blind: a foreign-key
|
||||
// violation surfaces as an opaque 500, and a row that names a company which has
|
||||
// since been deleted is worse than a clean refusal.
|
||||
func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) {
|
||||
var out api.Invitation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO invitations (client_id, email, full_name, role, code_hash,
|
||||
invited_by, expires_at)
|
||||
SELECT c.id, $2, $3, $4, $5, $6::uuid, $7
|
||||
FROM clients c
|
||||
WHERE c.id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role,
|
||||
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash,
|
||||
nullUUID(in.InvitedBy), in.ExpiresAt,
|
||||
).Scan(&out.ID, &out.Email, &out.FullName, &out.Role,
|
||||
&out.ExpiresAt, &out.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.Invitation{}, errors.New("no such company")
|
||||
}
|
||||
if err != nil {
|
||||
return api.Invitation{}, fmt.Errorf("create invitation: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PendingInvitations lists the invitations that have been sent and not yet
|
||||
// taken up. Spent and revoked rows are history and are deliberately not here:
|
||||
// the question this list answers is "who is still waiting to join".
|
||||
func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT i.id::text, i.email, i.full_name, i.role,
|
||||
COALESCE(u.full_name, u.email, ''),
|
||||
to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM invitations i
|
||||
LEFT JOIN app_users u ON u.id = i.invited_by
|
||||
WHERE i.client_id = $1::uuid
|
||||
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
||||
AND i.expires_at > now()
|
||||
ORDER BY i.created_at DESC`, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list invitations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.Invitation
|
||||
for rows.Next() {
|
||||
var v api.Invitation
|
||||
if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role,
|
||||
&v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeInvitation withdraws one before it is used.
|
||||
//
|
||||
// Scoped by client in the UPDATE, and it refuses an already-spent invitation
|
||||
// rather than silently doing nothing: "I revoked it" and "somebody had already
|
||||
// joined with it" need opposite follow-up actions from whoever asked.
|
||||
func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE invitations SET revoked_at = now()
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
AND used_at IS NULL AND revoked_at IS NULL`, clientID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke invitation: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("no such pending invitation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvitationByCode is the unauthenticated preview: what a holder may learn
|
||||
// about a code they already have.
|
||||
//
|
||||
// Every way of not being valid returns the same error, so this cannot be used
|
||||
// to tell an expired code from an invented one.
|
||||
func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) {
|
||||
var out api.InvitationPreview
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.name, i.email, i.full_name, i.role
|
||||
FROM invitations i
|
||||
JOIN clients c ON c.id = i.client_id
|
||||
WHERE i.code_hash = $1
|
||||
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
||||
AND i.expires_at > now()`, hash,
|
||||
).Scan(&out.Client, &out.Email, &out.FullName, &out.Role)
|
||||
if err != nil {
|
||||
return api.InvitationPreview{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RedeemInvitation turns a code into an account, in ONE transaction.
|
||||
//
|
||||
// Two properties, and both were learned elsewhere in this system:
|
||||
//
|
||||
// - Single use is enforced BY the update. `used_at IS NULL` and the write are
|
||||
// one statement, so two people racing on one invitation cannot both win.
|
||||
// Check-then-update would be exactly that race, and the loser would get a
|
||||
// second account rather than an error.
|
||||
// - The account and the redemption commit together. A spent invitation with
|
||||
// no user behind it is an invitation nobody can use and nobody can see is
|
||||
// broken; a user with the invitation still open is a second account waiting
|
||||
// to be created by anyone who was forwarded the code.
|
||||
//
|
||||
// The email and the role come from the ROW, never from the request. A code
|
||||
// passed on to a colleague must not become an account for them, and a staff
|
||||
// invitation must not be redeemed as an owner.
|
||||
func (s *Store) RedeemInvitation(ctx context.Context, hash []byte,
|
||||
fullName, passwordHash string) (api.UserRecord, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var clientID, email, role, invitedName string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE invitations SET used_at = now()
|
||||
WHERE code_hash = $1
|
||||
AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now()
|
||||
RETURNING client_id::text, email, role, full_name`, hash,
|
||||
).Scan(&clientID, &email, &role, &invitedName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.UserRecord{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
if err != nil {
|
||||
return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err)
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
// The inviter may have typed a name; use it rather than leaving a
|
||||
// blank row that every screen then renders as an email address.
|
||||
fullName = invitedName
|
||||
}
|
||||
|
||||
var rec api.UserRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||
RETURNING id::text, email, full_name, role`,
|
||||
clientID, email, passwordHash, fullName, role,
|
||||
).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role)
|
||||
if err != nil {
|
||||
return api.UserRecord{}, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
var clientName string
|
||||
if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`,
|
||||
clientID).Scan(&clientName); err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
|
||||
// Recorded against the new account, not the inviter: this is the moment a
|
||||
// person gained access, and the row should name who did.
|
||||
rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Team lists the people in one company.
|
||||
func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM app_users
|
||||
WHERE client_id = $1::uuid
|
||||
ORDER BY active DESC, full_name, email`, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list team: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.TeamMember
|
||||
for rows.Next() {
|
||||
var m api.TeamMember
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateTeamMember changes a role, or deactivates somebody who has left.
|
||||
//
|
||||
// Deactivating REVOKES their sessions in the same transaction. Leaving them
|
||||
// live would mean "remove their access" removed it in twelve hours' time,
|
||||
// whenever their access token happened to expire - which is not what anybody
|
||||
// pressing that button believes they have just done, and is precisely the case
|
||||
// an opaque-token session table exists to handle.
|
||||
func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string,
|
||||
up api.TeamUpdate) (api.TeamMember, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var m api.TeamMember
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE app_users
|
||||
SET role = COALESCE($3, role),
|
||||
active = COALESCE($4, active)
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, userID, up.Role, up.Active,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("update team member: %w", err)
|
||||
}
|
||||
|
||||
if up.Active != nil && !*up.Active {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// OwnerCount counts the active owners of a company.
|
||||
//
|
||||
// Used to refuse the change that locks a company out of its own account: the
|
||||
// last owner may not demote or deactivate themselves. There is no support path
|
||||
// back from that except a shell on the server, which is the thing this whole
|
||||
// surface exists to stop needing.
|
||||
func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM app_users
|
||||
WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ============================================================== sessions ====
|
||||
|
||||
// UserSessions lists one person's live sessions, newest first.
|
||||
func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, device,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
COALESCE(to_char(last_used_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM sessions
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL
|
||||
AND refresh_expires_at > now()
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.DeviceSession
|
||||
for rows.Next() {
|
||||
var d api.DeviceSession
|
||||
if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt,
|
||||
&d.LastUsedAt, &d.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeUserSession signs one device out.
|
||||
//
|
||||
// Scoped by user_id in the UPDATE, so a session id - which is not a secret and
|
||||
// travels in a list - cannot be used to sign somebody else out.
|
||||
func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`,
|
||||
userID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke session: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("no such session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOtherSessions is the "sign out everywhere else" button.
|
||||
//
|
||||
// It keeps the caller's own session deliberately: somebody who has just lost a
|
||||
// phone should not also be signed out of the device they are holding, which
|
||||
// would leave them re-authenticating in the middle of an emergency.
|
||||
func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`,
|
||||
userID, keepSessionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
@@ -150,6 +150,14 @@ func (s *Store) RecordVisit(ctx context.Context, site ingest.Site,
|
||||
visitorID, v.OccurredAt, site.ClientID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Now that we know who this was, drop any face this server was holding
|
||||
// for them from an earlier visit. Only ever one survives per person,
|
||||
// which is what bounds visit_faces to the customer base rather than to
|
||||
// footfall - see migration 011. A bucket deployment writes no such keys
|
||||
// and this does nothing.
|
||||
if err := pruneVisitorFaces(ctx, tx, site.ClientID, visitorID, visitID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
|
||||
Reference in New Issue
Block a user