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:
2026-09-28 16:01:01 +05:30
parent f61da2eeed
commit abcf6aa012
7 changed files with 794 additions and 10 deletions

View File

@@ -41,13 +41,24 @@ type fakeStore struct {
byAccess map[string]string // access hash hex -> session id
byRefresh map[string]string
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
enrolment map[string]Enrolment
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
// The admin drill-down is the one surface where the fake MUST know which
// merchant owns what. Everywhere else the client id comes from the session
// and every query scopes on it, so a fake that ignores it still exercises
// the handler. Here the client id comes from the PATH and the scoping is
// the thing under test - a fake that ignored it would pass the
// cross-merchant tests while returning another company's shops.
// Camera ownership already has a home: cameraRefs, read through the
// cameraOwner method below.
siteOwner map[string]string // site id -> client id
clientRows map[string]ClientDetail
enrolment map[string]Enrolment
// Recorded calls, so a test can assert what the handler asked for rather
// than only what it returned.
@@ -288,8 +299,65 @@ func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) er
return nil
}
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
return f.sites, nil
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth, error) {
// Scoped only when a test has declared ownership; otherwise every existing
// tenant test would have to grow a fixture it does not care about.
if f.siteOwner == nil {
return f.sites, nil
}
var out []SiteHealth
for _, s := range f.sites {
if f.siteOwner[s.SiteID] == clientID {
out = append(out, s)
}
}
return out, nil
}
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.clientRows[clientID], nil
}
func (f *fakeStore) AdminSiteID(_ context.Context, clientID, ref string) (string, error) {
for _, s := range f.sites {
if s.SiteID != ref && s.Slug != ref {
continue
}
if f.siteOwner != nil && f.siteOwner[s.SiteID] != clientID {
return "", nil // owned by somebody else: a miss, not a match
}
return s.SiteID, nil
}
return "", nil
}
func (f *fakeStore) AdminCameraID(_ context.Context, clientID, siteID, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.ID != ref && c.CameraID != ref {
continue
}
if c.SiteID != siteID {
return "", nil
}
if f.cameraRefs != nil && f.cameraOwner(c.ID) != clientID {
return "", nil
}
return c.ID, nil
}
return "", nil
}
func (f *fakeStore) PlatformSummary(_ context.Context) (PlatformSummary, error) {
f.mu.Lock()
defer f.mu.Unlock()
return PlatformSummary{
CamerasTotal: len(f.cameras), MerchantsActive: len(f.clientRows),
SitesTotal: len(f.sites), AsOf: "2026-09-28T00:00:00Z",
}, nil
}
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (