The admin console could list merchants and see nothing inside them
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
This commit is contained in:
@@ -137,6 +137,13 @@ type Store interface {
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
|
||||
// The admin drill-down. Each takes the merchant's client id explicitly,
|
||||
// because the caller is a platform admin whose session carries none.
|
||||
ClientDetail(ctx context.Context, clientID string) (ClientDetail, error)
|
||||
AdminSiteID(ctx context.Context, clientID, ref string) (string, error)
|
||||
AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error)
|
||||
PlatformSummary(ctx context.Context) (PlatformSummary, error)
|
||||
// SetClientActive suspends or reinstates a company. Suspending revokes every
|
||||
// session its users hold in the same transaction - login and ingest already
|
||||
// refuse an inactive client, but a live access token would otherwise keep
|
||||
@@ -353,6 +360,17 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
|
||||
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
|
||||
|
||||
// The admin drill-down: merchant -> shop -> camera. Read-only, scoped by
|
||||
// the merchant named in the path rather than by a session that has none,
|
||||
// with every read below the merchant list audited and cameras redacted to
|
||||
// a type that cannot carry an RTSP host or username.
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}", s.adminOnly(s.handleAdminClient))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites", s.adminOnly(s.handleAdminClientSites))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}", s.adminOnly(s.handleAdminClientSite))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras", s.adminOnly(s.handleAdminSiteCameras))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras/{camera}", s.adminOnly(s.handleAdminSiteCamera))
|
||||
mux.HandleFunc("GET /api/admin/monitoring/summary", s.adminOnly(s.handleAdminMonitoringSummary))
|
||||
|
||||
// Not session-authenticated: this is how a PC with no credentials gets
|
||||
// some. The enrolment token is the credential.
|
||||
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
|
||||
|
||||
Reference in New Issue
Block a user