Files
Behavision/server/internal/api/handlers_admin_monitor.go
Suriyakumarvijayanayagam 4db71e9381 A wrong URL answered 500, and only the real database said so
/api/admin/clients/not-a-uuid/sites returned 500. `c.id = $1::uuid` makes
Postgres cast the path segment, and casting a malformed string - or the
empty one the shape check handed back in its place - is an ERROR, not a
miss. `c.id::text = $1` cannot fail: an id that is not a uuid matches
nothing, which is the 404 a wrong URL should get.

The two sibling resolvers were already written this way and correctly
404'd the same input. I applied the rule to two of three places, which is
the shape of a rule that holds until somebody adds the next write path.
The shape check is gone with it - it existed only to produce the empty
string that then broke the cast.

The in-memory fake could not have caught this and did not: it resolves a
merchant with a map lookup, so every handler test passed, including the
one named for the case. That test stays, because 404-not-500 is still the
contract, but the property belongs to Postgres - so
api_admin_monitor_live_test.go asserts it where it lives, over every
free-text identifier these queries take. It skips without
TEST_DATABASE_URL, like the rest of the live store tests.

Also in deploy.sh, found by reading its own output: step 3 reported the
WRONG backup. `ls | tail -1` sorts alphabetically, so pre-...-demo-12
sorts before pre-...-demo-6 and it printed a dump from four days earlier.
A deploy that names the wrong safety net is worse than one that names
none, because that is the file somebody reaches for at the worst possible
moment. It echoes the filename it just wrote, and refuses to continue on
an empty one - pipefail catches a failing pg_dump, but a zero-byte gzip
would still have satisfied it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:14:26 +05:30

182 lines
5.9 KiB
Go

// The platform-admin drill-down: merchant -> shop -> camera.
//
// These exist because the tenant routes cannot serve this screen, and the
// reason is structural rather than incidental. Every tenant handler derives
// the client from the SESSION - that is what makes cross-tenant access
// impossible rather than merely disallowed - and a platform admin has no
// client at all. The three workarounds all make it worse: passing a company id
// to a tenant route puts a caller-chosen tenant back into the one place this
// system refuses to take one, filtering the whole estate in the browser ships
// every merchant's data to render one, and signing in as the owner leaves an
// audit trail naming the wrong person.
//
// So the tenant STORE functions are reused with an explicit client id and the
// scoping the tenant handlers get from the session is done here instead.
package api
import "net/http"
// clientForAdmin resolves {id} to a merchant that exists.
//
// A suspended merchant still resolves: "this company is suspended" is
// precisely what an admin opens the console to look at, and hiding it would
// make the one screen that can fix it the one screen that cannot see it.
func (s *Server) clientForAdmin(w http.ResponseWriter, r *http.Request) (ClientDetail, bool) {
c, err := s.Store.ClientDetail(r.Context(), r.PathValue("id"))
if err != nil {
s.serverError(w, "admin client", err)
return ClientDetail{}, false
}
if c.ID == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such merchant.")
return ClientDetail{}, false
}
return c, true
}
// siteForAdmin resolves {site} within that merchant. A site belonging to
// somebody else is 404 and not an empty list: the caller asked for a named
// thing, and "here are its zero cameras" is a different and wrong answer.
func (s *Server) siteForAdmin(w http.ResponseWriter, r *http.Request, clientID string) (string, bool) {
id, err := s.Store.AdminSiteID(r.Context(), clientID, r.PathValue("site"))
if err != nil {
s.serverError(w, "admin site", err)
return "", false
}
if id == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
return "", false
}
return id, true
}
func (s *Server) handleAdminClient(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleAdminClientSites(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
if err != nil {
s.serverError(w, "admin sites", err)
return
}
if rows == nil {
rows = []SiteHealth{}
}
s.auditAdminRead(r, c.ID, "admin.sites.read", "client", c.ID, len(rows))
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleAdminClientSite(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
// SiteHealth is the one place that knows what "online" means (three missed
// heartbeats, not one) and what cameras_up counts. A second query here
// would be a second definition of a working shop, and the two would drift.
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
if err != nil {
s.serverError(w, "admin site", err)
return
}
for _, row := range rows {
if row.SiteID == siteID {
s.auditAdminRead(r, c.ID, "admin.site.read", "site", siteID, 1)
writeJSON(w, http.StatusOK, row)
return
}
}
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
}
func (s *Server) handleAdminSiteCameras(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
if err != nil {
s.serverError(w, "admin cameras", err)
return
}
s.auditAdminRead(r, c.ID, "admin.cameras.read", "site", siteID, len(rows))
writeJSON(w, http.StatusOK, AdminCameras(rows))
}
func (s *Server) handleAdminSiteCamera(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientForAdmin(w, r)
if !ok {
return
}
siteID, ok := s.siteForAdmin(w, r, c.ID)
if !ok {
return
}
camID, err := s.Store.AdminCameraID(r.Context(), c.ID, siteID, r.PathValue("camera"))
if err != nil {
s.serverError(w, "admin camera", err)
return
}
if camID == "" {
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
return
}
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
if err != nil {
s.serverError(w, "admin camera", err)
return
}
for _, row := range rows {
if row.ID == camID {
s.auditAdminRead(r, c.ID, "admin.camera.read", "camera", camID, 1)
writeJSON(w, http.StatusOK, adminCamera(row))
return
}
}
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
}
func (s *Server) handleAdminMonitoringSummary(w http.ResponseWriter, r *http.Request) {
out, err := s.Store.PlatformSummary(r.Context())
if err != nil {
s.serverError(w, "platform summary", err)
return
}
// No audit row: this is counts across the platform, naming no merchant and
// no person. Logging a header strip that a console refreshes on a timer
// would bury the reads that are actually worth finding.
writeJSON(w, http.StatusOK, out)
}
// auditAdminRead records a platform admin reading inside one merchant.
//
// Below the merchant list, every read is somebody outside a company looking at
// that company's estate. "Who looked at my shops" has to be answerable for the
// same reason it does for face images, and an admin is exactly the account for
// which nothing else in the system would leave a trace.
func (s *Server) auditAdminRead(r *http.Request, clientID, action, entity, entityID string, n int) {
p := PrincipalFrom(r.Context())
s.Store.Audit(r.Context(), AuditEntry{
ClientID: clientID, ActorID: p.UserID, ActorKind: "admin",
Action: action, Entity: entity, EntityID: entityID,
Detail: map[string]any{"path": r.URL.Path, "rows": n},
})
}