From abcf6aa0122968ca5ba96d1db0810c0c3deacff7 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Mon, 28 Sep 2026 16:01:01 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- server/go.mod | 2 +- server/internal/api/admin_monitor_test.go | 315 ++++++++++++++++++ server/internal/api/api.go | 18 + server/internal/api/fake_test.go | 86 ++++- server/internal/api/handlers_admin_monitor.go | 193 +++++++++++ server/internal/api/types.go | 65 ++++ server/internal/store/api_admin_monitor.go | 125 +++++++ 7 files changed, 794 insertions(+), 10 deletions(-) create mode 100644 server/internal/api/admin_monitor_test.go create mode 100644 server/internal/api/handlers_admin_monitor.go create mode 100644 server/internal/store/api_admin_monitor.go diff --git a/server/go.mod b/server/go.mod index 6c99908..b047708 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,13 +3,13 @@ module github.com/loyaly/behavision-server go 1.25.0 require ( + github.com/anthropics/anthropic-sdk-go v1.69.0 github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/jackc/pgx/v5 v5.10.0 golang.org/x/crypto v0.42.0 ) require ( - github.com/anthropics/anthropic-sdk-go v1.69.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect diff --git a/server/internal/api/admin_monitor_test.go b/server/internal/api/admin_monitor_test.go new file mode 100644 index 0000000..3b5ecdc --- /dev/null +++ b/server/internal/api/admin_monitor_test.go @@ -0,0 +1,315 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +// Two merchants, each with a shop and a camera. The whole point of these tests +// is that the path names the merchant, so a fixture with only one proves +// nothing about scoping. +const ( + merchantA = "11111111-1111-4111-8111-aaaaaaaaaaaa" + merchantB = "22222222-2222-4222-8222-bbbbbbbbbbbb" + shopA = "33333333-3333-4333-8333-aaaaaaaaaaaa" + shopB = "44444444-4444-4444-8444-bbbbbbbbbbbb" +) + +func seedTwoMerchants(fs *fakeStore) { + seedPlatformAdmin(fs) + fs.clientRows = map[string]ClientDetail{ + merchantA: {ClientRow: ClientRow{ID: merchantA, Slug: "acme", Name: "Acme Retail", + Active: true, Sites: 1, Users: 2}, OwnerEmail: "owner@acme.com", OwnerName: "Asha"}, + merchantB: {ClientRow: ClientRow{ID: merchantB, Slug: "rival", Name: "Rival Stores", + Active: true, Sites: 1, Users: 1}, OwnerEmail: "owner@rival.com"}, + } + fs.sites = []SiteHealth{ + {SiteID: shopA, Slug: "chennai", Name: "Acme Chennai", CamerasUp: 1, CamerasTotal: 1}, + {SiteID: shopB, Slug: "mumbai", Name: "Rival Mumbai", CamerasUp: 0, CamerasTotal: 1}, + } + fs.siteOwner = map[string]string{shopA: merchantA, shopB: merchantB} + yes := true + fs.cameras = []Camera{ + {ID: "cam-a", SiteID: shopA, CameraID: "entrance", Label: "Front door", + Host: "192.168.1.121", Port: 554, Path: "/ch0_1.264", + Username: "admin", HasPassword: true, Enabled: true, Connected: &yes}, + {ID: "cam-b", SiteID: shopB, CameraID: "entrance", Label: "Rival door", + Host: "10.0.0.9", Port: 554, Username: "root", HasPassword: true}, + } + fs.cameraRefs = map[string]cameraRef{ + "cam-a": {client: merchantA, site: shopA, engineID: "entrance"}, + "cam-b": {client: merchantB, site: shopB, engineID: "entrance"}, + } +} + +// adminReads picks out the rows this surface writes. Signing in audits too, +// so a bare count would couple these tests to unrelated behaviour. +func adminReads(fs *fakeStore) []AuditEntry { + var out []AuditEntry + for _, a := range fs.audits { + if strings.HasPrefix(a.Action, "admin.") { + out = append(out, a) + } + } + return out +} + +func adminGet(t *testing.T, s *Server, token, path string) (int, string) { + t.Helper() + rec := do(t, s, "GET", path, token, nil) + return rec.Code, rec.Body.String() +} + +// ------------------------------------------------- the surface is invisible + +func TestAMerchantTokenGets404FromEveryAdminDrilldownRoute(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + seedUser(fs) // an ordinary manager inside another tenant + sess := login(t, s, "manager@acme.com", "correct horse battery") + + for _, path := range []string{ + "/api/admin/clients/" + merchantA, + "/api/admin/clients/" + merchantA + "/sites", + "/api/admin/clients/" + merchantA + "/sites/" + shopA, + "/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras", + "/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras/cam-a", + "/api/admin/monitoring/summary", + } { + if code, body := adminGet(t, s, sess.Token, path); code != http.StatusNotFound { + t.Errorf("%s: got %d, want 404 (never 403 - a tenant must not learn "+ + "this surface exists): %s", path, code, body) + } + } +} + +// ------------------------------------------------------------- scoping + +func TestSitesAreScopedToTheMerchantInThePath(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites") + if code != http.StatusOK { + t.Fatalf("got %d: %s", code, body) + } + var rows []SiteHealth + if err := json.Unmarshal([]byte(body), &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].SiteID != shopA { + t.Fatalf("got %d rows %+v, want only merchant A's shop", len(rows), rows) + } + if strings.Contains(body, "Rival") { + t.Errorf("another merchant's shop leaked into the response: %s", body) + } +} + +// The uuid branch is the one that matters. A tenant resolver hands a uuid back +// untouched and lets `client_id = $1` downstream do the scoping, which is safe +// only because the client id comes from a session. Here the caller names both. +func TestAnotherMerchantsShopIs404NotAnEmptyList(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + for _, path := range []string{ + "/api/admin/clients/" + merchantA + "/sites/" + shopB, + "/api/admin/clients/" + merchantA + "/sites/" + shopB + "/cameras", + "/api/admin/clients/" + merchantA + "/sites/mumbai/cameras", + } { + code, body := adminGet(t, s, sess.Token, path) + if code != http.StatusNotFound { + t.Errorf("%s: got %d, want 404 - an empty list says 'this shop has "+ + "nothing' when the truth is 'not your shop': %s", path, code, body) + } + } +} + +func TestACameraFromAnotherShopIs404(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + // cam-b exists, and its engine id "entrance" is the same string as cam-a's + // - a camera id is unique per SITE, not per tenant, so the chain has to be + // checked rather than the name trusted. + code, _ := adminGet(t, s, sess.Token, + "/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras/cam-b") + if code != http.StatusNotFound { + t.Errorf("got %d, want 404 for a camera belonging to another shop", code) + } +} + +func TestAnUnknownOrMalformedMerchantIs404NotAServerError(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + for _, id := range []string{ + "99999999-9999-4999-8999-999999999999", // well formed, no such row + "not-a-uuid", // would be a Postgres cast error + } { + code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+id+"/sites") + if code != http.StatusNotFound { + t.Errorf("merchant %q: got %d, want 404: %s", id, code, body) + } + } +} + +// ------------------------------------------------------------- redaction + +// The one that would be a real leak. An RTSP host next to a username is most +// of a live path into a customer's camera, and a platform admin browsing +// another company's estate has no business with either. +func TestAdminCameraRowsCarryNoCredentialFields(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + code, body := adminGet(t, s, sess.Token, + "/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras") + if code != http.StatusOK { + t.Fatalf("got %d: %s", code, body) + } + // Asserted on the raw JSON, not on a struct: decoding into AdminCamera + // would discard exactly the fields this test exists to catch. + for _, banned := range []string{ + "host", "192.168.1.121", "port", "554", "path", "ch0_1.264", + "username", "admin", "has_password", "password", + } { + if strings.Contains(body, banned) { + t.Errorf("admin camera row contains %q: %s", banned, body) + } + } + // And it still answers the question the screen asks. + for _, want := range []string{"entrance", "Front door", "connected"} { + if !strings.Contains(body, want) { + t.Errorf("admin camera row is missing %q: %s", want, body) + } + } +} + +// Connected is a pointer for a reason: null means no shop PC has ever reported, +// false means it is not connecting, and those send an installer to two +// different places. `omitempty` would collapse both into absent. +func TestAdminCameraKeepsConnectedAsThreeStates(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + fs.cameras[0].Connected = nil + sess := login(t, s, "root@loyaly.ai", "admin123") + + _, body := adminGet(t, s, sess.Token, + "/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras") + if !strings.Contains(body, `"connected":null`) { + t.Errorf(`want "connected":null for a camera no PC has reported on: %s`, body) + } +} + +// ------------------------------------------------------------- audit + +func TestEveryAdminReadBelowTheMerchantListIsAudited(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + base := "/api/admin/clients/" + merchantA + for _, path := range []string{ + base + "/sites", + base + "/sites/" + shopA, + base + "/sites/" + shopA + "/cameras", + base + "/sites/" + shopA + "/cameras/cam-a", + } { + if code, body := adminGet(t, s, sess.Token, path); code != http.StatusOK { + t.Fatalf("%s: got %d: %s", path, code, body) + } + } + // Filtered by action: signing in writes its own audit row, and counting + // every row would make this test pass or fail on unrelated behaviour. + reads := adminReads(fs) + if len(reads) != 4 { + t.Fatalf("got %d admin read rows, want one per read below the merchant "+ + "list (all audits: %+v)", len(reads), fs.audits) + } + for _, a := range reads { + if a.ClientID != merchantA { + t.Errorf("audit row names client %q, want the merchant being looked at", a.ClientID) + } + if a.ActorID != "admin-1" || a.ActorKind != "admin" { + t.Errorf("audit row must name the admin who looked: %+v", a) + } + } +} + +// Counts across the platform name no merchant and no person, and a console +// refreshes them on a timer. Logging that would bury the reads worth finding. +func TestTheSummaryIsNotAudited(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + if code, body := adminGet(t, s, sess.Token, "/api/admin/monitoring/summary"); code != http.StatusOK { + t.Fatalf("got %d: %s", code, body) + } + if n := len(adminReads(fs)); n != 0 { + t.Errorf("got %d admin read rows for a counts-only header strip, want 0", n) + } +} + +// ------------------------------------------------------------- suspended + +// "This company is suspended" is precisely what an admin opens the console to +// look at. Hiding it would make the one screen that can fix it the one screen +// that cannot see it. +func TestASuspendedMerchantStaysReadable(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + row := fs.clientRows[merchantA] + row.Active = false + fs.clientRows[merchantA] = row + sess := login(t, s, "root@loyaly.ai", "admin123") + + code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA) + if code != http.StatusOK { + t.Fatalf("got %d, want a suspended merchant to still read: %s", code, body) + } + if !strings.Contains(body, `"active":false`) { + t.Errorf("the response must say it is suspended: %s", body) + } + if code, _ := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites"); code != http.StatusOK { + t.Errorf("sites of a suspended merchant: got %d, want 200", code) + } +} + +func TestMerchantDetailCarriesTheOwner(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + sess := login(t, s, "root@loyaly.ai", "admin123") + + code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA) + if code != http.StatusOK { + t.Fatalf("got %d: %s", code, body) + } + // Who to contact is the whole reason this is not just the list row. + if !strings.Contains(body, "owner@acme.com") { + t.Errorf("merchant detail must name the owner: %s", body) + } +} + +// An empty list must serialise as [] and not null, or a console that maps over +// the response breaks on a merchant with no shops - which is every merchant on +// the day they are created. +func TestAMerchantWithNoShopsReturnsAnEmptyArray(t *testing.T) { + s, fs := newServer(t) + seedTwoMerchants(fs) + fs.siteOwner = map[string]string{shopB: merchantB} // A now owns nothing + sess := login(t, s, "root@loyaly.ai", "admin123") + + code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites") + if code != http.StatusOK || strings.TrimSpace(body) != "[]" { + t.Errorf("got %d %q, want 200 []", code, strings.TrimSpace(body)) + } +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 3b77e8c..a775b74 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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) diff --git a/server/internal/api/fake_test.go b/server/internal/api/fake_test.go index 14dca04..f4d93c3 100644 --- a/server/internal/api/fake_test.go +++ b/server/internal/api/fake_test.go @@ -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) ( diff --git a/server/internal/api/handlers_admin_monitor.go b/server/internal/api/handlers_admin_monitor.go new file mode 100644 index 0000000..888c1a4 --- /dev/null +++ b/server/internal/api/handlers_admin_monitor.go @@ -0,0 +1,193 @@ +// 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 +} diff --git a/server/internal/api/types.go b/server/internal/api/types.go index 573f91d..44c71e1 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -434,6 +434,71 @@ type ClientRow struct { CreatedAt string `json:"created_at"` } +// ClientDetail is one merchant for the platform-admin console: the list row +// plus the owner, which is who a support conversation actually starts with. +type ClientDetail struct { + ClientRow + OwnerEmail string `json:"owner_email,omitempty"` + OwnerName string `json:"owner_name,omitempty"` +} + +// PlatformSummary is the estate-wide header strip. Counts only. +type PlatformSummary struct { + CamerasTotal int `json:"cameras_total"` + CamerasOnline int `json:"cameras_online"` + MerchantsActive int `json:"merchants_active"` + SitesTotal int `json:"sites_total"` + EventsToday int `json:"events_today"` + AsOf string `json:"as_of"` +} + +// AdminCamera is a camera as a PLATFORM ADMIN may see it, and it is a separate +// type from Camera for the same reason AgentCamera is. +// +// It carries no host, port, path, username or has_password. A tenant seeing +// those for their own camera is correct - it is their camera and their form +// edits it. 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 would leave +// "remember to redact, on every path, forever" as the only thing preventing a +// leak; a type that cannot express them cannot forget. +type AdminCamera struct { + ID string `json:"id"` + SiteID string `json:"site_id"` + Site string `json:"site,omitempty"` + CameraID string `json:"camera_id"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + + // Connected stays a POINTER: null is "no shop PC has reported on this + // yet", false is "not connecting", and those send an installer to two + // different places. + Connected *bool `json:"connected"` + LastSeenAt string `json:"last_seen_at,omitempty"` + SnapshotAt string `json:"snapshot_at,omitempty"` + Check CameraCheck `json:"check"` +} + +// adminCamera redacts one camera for the admin console. +func adminCamera(c Camera) AdminCamera { + return AdminCamera{ + ID: c.ID, SiteID: c.SiteID, Site: c.Site, + CameraID: c.CameraID, Label: c.Label, Enabled: c.Enabled, + Connected: c.Connected, LastSeenAt: c.LastSeenAt, + SnapshotAt: c.SnapshotAt, Check: c.Check, + } +} + +// AdminCameras redacts a list, and returns an empty slice rather than nil so +// the response is [] and not null. +func AdminCameras(in []Camera) []AdminCamera { + out := make([]AdminCamera, 0, len(in)) + for _, c := range in { + out = append(out, adminCamera(c)) + } + return out +} + // ---------------------------------------------------------------- cameras // Camera is one camera as head office sees it: how it is configured, and diff --git a/server/internal/store/api_admin_monitor.go b/server/internal/store/api_admin_monitor.go new file mode 100644 index 0000000..3a33b7b --- /dev/null +++ b/server/internal/store/api_admin_monitor.go @@ -0,0 +1,125 @@ +// Platform-admin reads BELOW the merchant level: one company, its shops, its +// cameras, and the estate-wide totals. +// +// Every function here takes the merchant's client id as an ARGUMENT, because +// the caller is a platform admin who has no client of their own. That is the +// whole reason these exist rather than reusing the tenant handlers: those +// derive the tenant from the session, and an admin session carries none. The +// tenant STORE functions already take a client id explicitly, so this file +// adds the scoping the tenant handlers get for free and nothing else. +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/loyaly/behavision-server/internal/api" +) + +// ClientDetail is one merchant, with the owner a support conversation starts +// from. ListClients cannot carry it: an owner lookup per row would be a query +// per merchant on a screen that only needs the name. +func (s *Store) ClientDetail(ctx context.Context, clientID string) (api.ClientDetail, error) { + var c api.ClientDetail + var at time.Time + err := s.pool.QueryRow(ctx, ` + SELECT c.id::text, c.slug, c.name, c.active, c.created_at, + (SELECT count(*) FROM sites si WHERE si.client_id = c.id), + (SELECT count(*) FROM app_users au WHERE au.client_id = c.id), + COALESCE((SELECT au.email FROM app_users au + WHERE au.client_id = c.id AND au.role = 'owner' + AND au.active ORDER BY au.created_at LIMIT 1), ''), + COALESCE((SELECT au.full_name FROM app_users au + WHERE au.client_id = c.id AND au.role = 'owner' + AND au.active ORDER BY au.created_at LIMIT 1), '') + FROM clients c + WHERE c.id = $1::uuid`, clientID). + Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users, + &c.OwnerEmail, &c.OwnerName) + if errors.Is(err, pgx.ErrNoRows) { + return api.ClientDetail{}, nil + } + if err != nil { + return api.ClientDetail{}, err + } + c.CreatedAt = at.UTC().Format(time.RFC3339) + return c, nil +} + +// AdminSiteID resolves a shop reference WITHIN one merchant, by slug or uuid. +// +// The uuid branch is the point. The tenant resolver returns a uuid untouched +// and lets every downstream query's `client_id = $1` do the scoping, which is +// sound there because the client id comes from the session and cannot be +// chosen. Here the caller names BOTH, so an unowned uuid would otherwise reach +// a query that quietly returns nothing - an empty shop rather than "no such +// shop". Resolving through the database with both halves is what makes a +// broken chain a 404. +func (s *Store) AdminSiteID(ctx context.Context, clientID, ref string) (string, error) { + var id string + // `id::text = $2`, never `id = $2::uuid`. Using one parameter as both text + // and uuid in the same statement is how Postgres ends up deducing two + // types for it and refusing the whole query - the identical shape that + // broke `'Visitor ' || $2::text` beside `number = $2`, which compiled, + // passed every in-memory test and failed on the first real database. + // Casting the COLUMN keeps one type per parameter, and it cannot raise an + // invalid-uuid error on a malformed path segment either: it simply misses, + // which is the 404 the caller should get anyway. + err := s.pool.QueryRow(ctx, ` + SELECT id::text FROM sites + WHERE client_id = $1::uuid + AND (slug = $2 OR id::text = $2)`, + clientID, ref).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return id, err +} + +// AdminCameraID resolves a camera within one shop of one merchant. +// +// All three links are checked in the one statement, so there is no ordering in +// which a caller learns that a camera exists somewhere else. +func (s *Store) AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, ` + SELECT c.id::text + FROM site_cameras c + JOIN sites si ON si.id = c.site_id + WHERE si.client_id = $1::uuid + AND c.site_id = $2::uuid + AND c.deleted_at IS NULL + AND (c.camera_id = $3 OR c.id::text = $3)`, + clientID, siteID, ref).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return id, err +} + +// PlatformSummary is counts and nothing else. +// +// Deliberately not a list: it backs a header strip, and an endpoint that +// returns every camera on the platform to render four numbers is one that gets +// slower with every customer signed. +func (s *Store) PlatformSummary(ctx context.Context) (api.PlatformSummary, error) { + var out api.PlatformSummary + err := s.pool.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM site_cameras WHERE deleted_at IS NULL), + (SELECT count(*) FROM site_cameras + WHERE deleted_at IS NULL AND connected IS TRUE), + (SELECT count(*) FROM clients WHERE active), + (SELECT count(*) FROM sites), + (SELECT count(*) FROM visits WHERE occurred_at >= date_trunc('day', now())) + `).Scan(&out.CamerasTotal, &out.CamerasOnline, &out.MerchantsActive, + &out.SitesTotal, &out.EventsToday) + if err != nil { + return api.PlatformSummary{}, err + } + out.AsOf = time.Now().UTC().Format(time.RFC3339) + return out, nil +}