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 +}