package store import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/loyaly/behavision-server/internal/api" ) // SetAgentAPIToken stores the hash of a store PC's HTTPS credential. // // Hashed, not encrypted, unlike the broker password: this one is never handed // back out. It is shown once at enrolment and the agent keeps it, so a database // dump contains nothing usable. func (s *Store) SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error { _, err := s.pool.Exec(ctx, `UPDATE agents SET api_token_hash = $2 WHERE id = $1::uuid`, agentID, hash) return err } func (s *Store) AgentByToken(ctx context.Context, hash []byte) (api.AgentPrincipal, error) { var ap api.AgentPrincipal err := s.pool.QueryRow(ctx, ` SELECT a.id::text, a.client_id::text, a.site_id::text, a.mqtt_username, c.slug, si.slug FROM agents a JOIN sites si ON si.id = a.site_id AND si.active JOIN clients c ON c.id = a.client_id AND c.active WHERE a.api_token_hash = $1`, hash). Scan(&ap.AgentID, &ap.ClientID, &ap.SiteID, &ap.Slug, &ap.Client, &ap.Site) if errors.Is(err, pgx.ErrNoRows) { return api.AgentPrincipal{}, errors.New("no such agent") } return ap, err } // VisitorImageKey is the most recent surviving photo of one person. // // image_deleted_at is checked, not just image_key: a key that has been erased // is still in the row as the record that it WAS erased, and handing it to the // presigner would produce a link to an object that is gone - or, worse, to one // that was re-created under the same name. func (s *Store) VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error) { var key string err := s.pool.QueryRow(ctx, ` SELECT image_key FROM visits WHERE client_id = $1 AND visitor_id = $2::uuid AND image_key <> '' AND image_deleted_at IS NULL ORDER BY occurred_at DESC LIMIT 1`, clientID, visitorID).Scan(&key) if errors.Is(err, pgx.ErrNoRows) { return "", nil } return key, err } // VisitorImageKeys is every object belonging to one person - the first step of // an erasure request. func (s *Store) VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error) { rows, err := s.pool.Query(ctx, ` SELECT image_key FROM visits WHERE client_id = $1 AND visitor_id = $2::uuid AND image_key <> '' AND image_deleted_at IS NULL`, clientID, visitorID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var k string if err := rows.Scan(&k); err != nil { return nil, err } out = append(out, k) } return out, rows.Err() } // ForgetVisitor is the database half of erasure. // // What goes and what stays is a deliberate line: // // - the biometric template is DELETED outright, not flagged. Template // inversion reconstructs a recognisable face from an ArcFace embedding, so // a soft-deleted vector is a retained photograph by another name. // - the profile goes: a name, a phone number and a date of birth are exactly // what the request is about. // - visits STAY, with the person unlinked. They are the shop's own footfall // history, and silently changing last quarter's numbers because one // customer exercised a right is both wrong and detectable. // - the visitors row stays with deleted_at set, so the same face cannot be // re-enrolled as a brand new person the next time they walk in. func (s *Store) ForgetVisitor(ctx context.Context, clientID, visitorID string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) //nolint:errcheck var exists bool err = tx.QueryRow(ctx, `SELECT true FROM visitors WHERE id = $1::uuid AND client_id = $2`, visitorID, clientID).Scan(&exists) if errors.Is(err, pgx.ErrNoRows) { return errors.New("no such visitor") } if err != nil { return err } if _, err := tx.Exec(ctx, ` DELETE FROM visitor_embeddings WHERE visitor_id = $1::uuid AND client_id = $2`, visitorID, clientID); err != nil { return fmt.Errorf("delete templates: %w", err) } if _, err := tx.Exec(ctx, ` DELETE FROM visitor_profiles WHERE visitor_id = $1::uuid AND client_id = $2`, visitorID, clientID); err != nil { return fmt.Errorf("delete profile: %w", err) } // The consent record itself survives as a revocation. Deleting it would // destroy the proof of what we were permitted to do and when, which is the // thing an auditor actually asks for. if _, err := tx.Exec(ctx, ` UPDATE consents SET revoked_at = COALESCE(revoked_at, now()) WHERE visitor_id = $1::uuid AND client_id = $2`, visitorID, clientID); err != nil { return fmt.Errorf("revoke consents: %w", err) } // The objects are already gone from storage by the time this runs; this // records that, and stops anything presigning a dead key. if _, err := tx.Exec(ctx, ` UPDATE visits SET image_deleted_at = now() WHERE client_id = $1 AND visitor_id = $2::uuid AND image_key <> '' AND image_deleted_at IS NULL`, clientID, visitorID); err != nil { return fmt.Errorf("mark images deleted: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE visitors SET deleted_at = now(), label = 'Erased' WHERE id = $1::uuid AND client_id = $2`, visitorID, clientID); err != nil { return fmt.Errorf("mark visitor erased: %w", err) } return tx.Commit(ctx) }