Six read-only routes: merchant detail, its shops, one shop, its cameras, one camera, and the platform totals. The console drills down merchant -> store -> camera and every level below the first showed 'Backend integration required'. They cannot be the tenant routes, 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 each make it worse: passing a company id to a tenant route puts a caller-chosen tenant back in the one place this system refuses to take one, filtering the estate in the browser ships every merchant's data to render one, and signing in as the owner audits the wrong person. So the tenant STORE functions are reused with an explicit client id - they already take one - and the scoping the tenant handlers get from the session is done in the handler instead. AdminCamera is a separate type from Camera, for the same reason AgentCamera is. It cannot carry host, port, path, username or has_password. A tenant seeing those for their own camera is correct; a platform admin browsing another company's estate is a different question, and an RTSP host with a username beside it is most of a live path into a customer's camera. Blanking fields on a shared struct leaves 'remember to redact, on every path, forever' as the only thing preventing a leak. The test asserts on the raw JSON, because decoding into the struct would discard exactly what it is looking for. An unowned site is 404, never an empty list. The tenant resolver returns a uuid untouched and lets client_id = downstream scope it, which is sound only because that id comes from a session; here the caller names both halves, so an unowned uuid would reach a query that quietly returns nothing - 'this shop has no cameras' when the truth is 'not your shop'. Both resolvers check the whole chain in one statement. Two things the in-memory fake could not have caught, so neither was left to it. The fake ignored clientID in SiteHealth and Cameras, which would have made every cross-merchant test pass while returning another company's shops; it is client-aware now for these paths. And the SQL was written to make the documented $2-deduced-as-two-types bug impossible rather than to be caught by a database later: id::text = $2 in place of id = $2::uuid, one type per parameter, which also turns a malformed path segment into the 404 it should be instead of a cast error. Every read below the merchant list writes an audit row naming the admin and the merchant - an admin is the one account for which nothing else here leaves a trace. The counts-only summary does not: a console refreshes it on a timer, and logging that buries the reads worth finding. A suspended merchant stays readable, because that is precisely what an admin opens the console to look at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
194 lines
6.3 KiB
Go
194 lines
6.3 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(), pathUUID(r, "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},
|
|
})
|
|
}
|
|
|
|
// pathUUID reads a path segment that must be a uuid, returning "" otherwise so
|
|
// the lookup misses and the caller answers 404. Handing a malformed string to
|
|
// Postgres as a uuid is an error, not a miss, and would surface as a 500 on
|
|
// what is really just a wrong URL.
|
|
func pathUUID(r *http.Request, name string) string {
|
|
v := r.PathValue(name)
|
|
if !looksLikeUUID(v) {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|