POST /api/customers and POST /api/visitors/{id}/merge. They ship together
because the first creates the need for the second: a customer typed in at
a counter has no face template, so when a camera sees that person later
the matcher has nothing to compare against and enrols them as somebody
new. That is the design working, not failing - and it means every
hand-created customer is a duplicate waiting to happen. Shipping the
create alone would manufacture duplicates into the state CLAUDE.md
already flags: "there is no merge endpoint server-side, so its
duplicates would be unrecoverable."
The number comes from clients.visitor_seq, taken exactly as RecordVisit
takes it. Two sources of visitor numbers that could disagree would be
worse than none: V-42 has to mean one person whichever way they arrived.
The label is the typed name, or "Visitor N" when they gave none - the
same string the engine writes, so a record created by hand is
indistinguishable from an enrolled one afterwards.
The merge is one transaction over FIVE tables, and the count is the
point. visits, purchases, visitor_embeddings, consents and
visitor_profiles all reference visitors ON DELETE CASCADE, so a table
this forgets to re-point is not an error - those rows are destroyed with
the source and nobody finds out until a customer's history is short.
visitor_profiles is UNIQUE on visitor_id, so the two cannot simply both
move and something has to win. Blanks on the survivor are filled from the
source and nothing it already holds is overwritten, which is exactly
right for the case this exists for: a hand-typed name and phone joining
the face that was recognised a week later.
Policies carried over from the edge gallery's merge, which had to settle
all of this once already: a human-assigned name outranks an auto
"Visitor N" whichever direction the operator merged; visit_count is
recomputed with COUNT(*) and never summed, because the stored counter may
be stale and the row count cannot be; first_seen_at takes the earlier of
the two, since it is one person and always was.
Two things that are this side's own:
- The source is deleted for real, not soft-deleted. A tombstone would
leave its number resolving to a record holding nothing, which reads as
"this customer exists and has never been here" - a worse answer than
"no such customer".
- The response names the RETIRED reference. Staff write V-42 on cards and
read it aloud; a merge that does not say which one stopped working
leaves somebody to discover it at a counter.
Manager and above, not staff. Apart from erasure this is the only
irreversible operation on a customer: two people welded together cannot
be separated, because nothing records which visit came from whom. It logs
at WARNING and writes an audit row for the same reason.
Also fixed while here: two s.Log.Printf calls - one of them mine, from
the password endpoint - that would panic on a nil logger. The package has
a nil-guarded s.logf and those were the only two not using it. The
password one sat in an error path no test reaches, which is exactly where
that bug waits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
93 lines
3.5 KiB
Go
93 lines
3.5 KiB
Go
// Changing your own password.
|
|
//
|
|
// This did not exist, and the cost of that was measured rather than guessed:
|
|
// rotating three production accounts took a shell on the server, three round
|
|
// trips, and briefly left a PLATFORM ADMIN - the account that reads every
|
|
// company on the estate - with a password anyone watching could guess, because
|
|
// a placeholder in a pasted command was taken literally.
|
|
//
|
|
// A manager could always reset somebody ELSE's password, and a platform admin
|
|
// could be reset by nobody at all: they have no client, so the team routes are
|
|
// not theirs, and `provision user` on the host was the only way. For a product
|
|
// that puts accounts on shop-floor PCs and staff phones, "change my password"
|
|
// is not a feature, it is the thing that makes every other credential decision
|
|
// recoverable.
|
|
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// handleChangePassword is on `authed`, NOT `tenantOnly`.
|
|
//
|
|
// A session is not a company's data. A platform admin has no client and must
|
|
// still be able to change their own password - they are precisely the account
|
|
// for which there was no other route.
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
|
|
var in ChangePassword
|
|
if err := decode(w, r, &in); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
|
|
// The CURRENT password is required, and that is the whole security
|
|
// argument. An access token lives twelve hours and travels on shop-floor
|
|
// devices; without this, anyone holding a stolen one could set a new
|
|
// password and own the account permanently rather than for the rest of
|
|
// the day.
|
|
rec, err := s.Store.UserByEmail(r.Context(), p.Email)
|
|
if err != nil {
|
|
s.serverError(w, "change password", err)
|
|
return
|
|
}
|
|
if !rec.Found || !auth.VerifyPassword(rec.PasswordHash, in.CurrentPassword) {
|
|
// Deliberately not throttled separately: this needs a live session, so
|
|
// it is not reachable by anyone guessing from outside, and the login
|
|
// throttle already governs getting one.
|
|
writeErr(w, http.StatusForbidden, "wrong_password",
|
|
"That is not your current password.")
|
|
return
|
|
}
|
|
if in.CurrentPassword == in.NewPassword {
|
|
badRequest(w, "the new password is the same as the old one")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(in.NewPassword)
|
|
if err != nil {
|
|
// HashPassword enforces the length floor, and its message names it.
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
if err := s.Store.SetUserPassword(r.Context(), p.UserID, hash); err != nil {
|
|
s.serverError(w, "change password", err)
|
|
return
|
|
}
|
|
|
|
// Every OTHER session goes, and the caller's stays. Somebody changing
|
|
// their password because they think it is known must not have to guess
|
|
// whether the change took effect on the device that already had it - and
|
|
// must not be signed out of the one in their hand while they deal with it.
|
|
revoked, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID)
|
|
if err != nil {
|
|
// The password IS changed. Reporting a failure here would tell the
|
|
// user to try again, and the retry would fail on the current password
|
|
// they just replaced.
|
|
s.logf("change password: revoke other sessions: %v", err)
|
|
}
|
|
|
|
s.Store.Audit(r.Context(), AuditEntry{
|
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
|
Action: "auth.change_password", Entity: "user", EntityID: p.UserID,
|
|
Detail: map[string]any{"sessions_revoked": revoked},
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"changed": true,
|
|
"sessions_revoked": revoked,
|
|
})
|
|
}
|