Files
Behavision/server/internal/api/handlers_admin_clients.go
Suriyakumarvijayanayagam 8786a5b0b4 The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).

Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.

Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:36:21 +05:30

260 lines
8.5 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/auth"
)
// The platform administrator's remaining shell-only jobs, as endpoints:
// suspend or reinstate a company, reset its owner's password, delete it.
//
// All three are behind adminOnly (a principal with the role AND no client), and
// all three read the company id from the path. None takes a client id from a
// body - the same rule every tenant handler follows.
// PATCH /api/admin/clients/{id} {"active": false}
//
// Suspension is the reversible step and it is complete: login refuses the
// company's users, the broker's visits are dropped at ingest, and every live
// session is revoked in the same transaction. Without the last, "suspend" would
// mean "suspend some time tomorrow", which is not what anybody pressing it
// believes they did.
func (s *Server) handleSetClientActive(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
var in struct {
Active *bool `json:"active"`
}
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
if in.Active == nil {
badRequest(w, `"active" is required: true to reinstate, false to suspend`)
return
}
row, revoked, err := s.Store.SetClientActive(r.Context(), r.PathValue("id"), *in.Active)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
return
}
s.serverError(w, "set client active", err)
return
}
action := "client.suspended"
if *in.Active {
action = "client.reinstated"
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: action,
Entity: "client", EntityID: row.ID,
Detail: map[string]any{"slug": row.Slug, "sessions_revoked": revoked},
})
writeJSON(w, http.StatusOK, map[string]any{"client": row, "sessions_revoked": revoked})
}
// POST /api/admin/clients/{id}/owner-password {"email": "…"}
//
// The support case this exists for: the owner has locked themselves out and
// there is nobody above them in the company to reset it. The new password is
// generated, shown once, and every session that owner held is revoked. `email`
// picks the owner when the company has more than one; with exactly one it may
// be omitted.
func (s *Server) handleResetOwnerPassword(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
clientID := r.PathValue("id")
var in struct {
Email string `json:"email"`
}
if err := decodeOptional(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
owners, err := s.Store.ClientOwners(r.Context(), clientID)
if err != nil {
s.serverError(w, "list owners", err)
return
}
var target *TeamMember
switch {
case len(owners) == 0:
writeErr(w, http.StatusNotFound, "not_found", "That company has no active owner.")
return
case in.Email != "":
want := auth.NormalizeEmail(in.Email)
for i := range owners {
if owners[i].Email == want {
target = &owners[i]
}
}
if target == nil {
writeErr(w, http.StatusNotFound, "not_found", "No active owner with that address.")
return
}
case len(owners) == 1:
target = &owners[0]
default:
emails := make([]string, 0, len(owners))
for _, o := range owners {
emails = append(emails, o.Email)
}
badRequest(w, "That company has several owners; say which with \"email\": "+strings.Join(emails, ", "))
return
}
password, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
s.serverError(w, "hash password", err)
return
}
m, err := s.Store.ResetMemberPassword(r.Context(), clientID, target.ID, hash)
if err != nil {
s.serverError(w, "reset owner password", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: "admin.reset_owner_password",
Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "client_id": clientID},
})
writeJSON(w, http.StatusOK, map[string]any{"email": m.Email, "password": password})
}
// DELETE /api/admin/clients/{id} {"confirm": "<slug>"}
//
// Irreversible, and the data is biometric, so it is deliberately hard to do by
// accident: the company must already be suspended, and the request must repeat
// the slug. Objects go first - once the rows are gone nothing knows which files
// to remove - then the broker logins, then the rows (everything cascades from
// clients). A storage failure aborts before anything else is touched.
func (s *Server) handleDeleteClient(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
clientID := r.PathValue("id")
var in struct {
Confirm string `json:"confirm"`
}
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
rows, err := s.Store.ListClients(r.Context())
if err != nil {
s.serverError(w, "list clients", err)
return
}
var row *ClientRow
for i := range rows {
if rows[i].ID == clientID {
row = &rows[i]
}
}
if row == nil {
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
return
}
if row.Active {
writeErr(w, http.StatusConflict, "still_active",
"Suspend the company first (PATCH active=false). Deleting is the second step, not the first.")
return
}
if strings.TrimSpace(in.Confirm) != row.Slug {
badRequest(w, "Repeat the company's slug in \"confirm\" to delete it.")
return
}
keys, err := s.Store.ClientImageKeys(r.Context(), clientID)
if err != nil {
s.serverError(w, "list images for client delete", err)
return
}
if s.Blob != nil {
for _, key := range keys {
if isDBKey(key) {
continue // goes with the rows
}
if err := s.Blob.Delete(r.Context(), key); err != nil {
s.logf("ERROR delete client %s: cannot delete %s: %v", row.Slug, key, err)
writeErr(w, http.StatusBadGateway, "storage_error",
"A stored photo could not be deleted, so the company was not deleted. Try again.")
return
}
}
}
_, brokerUsers, err := s.Store.DeleteClient(r.Context(), clientID)
if err != nil {
s.serverError(w, "delete client", err)
return
}
if s.Broker != nil {
for _, u := range brokerUsers {
if err := s.Broker.DeleteSite(r.Context(), u); err != nil {
// The rows are gone and the login cannot publish anywhere the
// server will accept (ingest resolves the site and finds none),
// so this is a leftover to tidy, not a failure to report as one.
s.logf("delete client %s: broker login %s not removed: %v", row.Slug, u, err)
}
}
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: "client.deleted",
Entity: "client", EntityID: clientID,
Detail: map[string]any{"slug": row.Slug, "images_deleted": len(keys), "broker_logins": brokerUsers},
})
s.logf("WARNING company %s deleted by %s: %d images, %d broker logins", row.Slug, p.UserID, len(keys), len(brokerUsers))
writeJSON(w, http.StatusOK, map[string]any{"deleted": row.Slug, "images_deleted": len(keys)})
}
// DELETE /api/sites/{site} - an owner removes a shop opened by mistake.
//
// Only a shop with no visits and no cameras. A shop with history holds the
// tenant's footfall and, through its visits, faces; taking that away is an
// erasure decision, not a tidy-up, and there is no endpoint for it yet.
func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if p.Role != "owner" || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can remove a shop.")
return
}
site, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
username, err := s.Store.DeleteEmptySite(r.Context(), p.ClientID, site)
if err != nil {
if errors.Is(err, ErrSiteInUse) {
writeErr(w, http.StatusConflict, "in_use",
"This shop has cameras or visits, so it cannot simply be removed. Remove its cameras first; a shop with visit history is kept.")
return
}
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
s.serverError(w, "delete site", err)
return
}
if s.Broker != nil && username != "" {
if err := s.Broker.DeleteSite(r.Context(), username); err != nil {
s.logf("delete site %s: broker login %s not removed: %v", site, username, err)
}
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.deleted", Entity: "site", EntityID: site,
})
w.WriteHeader(http.StatusNoContent)
}
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
// under it.
var ErrSiteInUse = errors.New("site has cameras or visits")