package api import ( "net/http" "strings" ) // Platform administration: creating the tenants everything else belongs to. // // Deliberately NOT public registration. An open endpoint that mints tenants is // a far larger thing to have to secure than one behind an account that already // exists, and a stranger creating a tenant on this platform is not a customer - // it is a database row nobody asked for holding a place in a table every query // joins against. // // The `provision` CLI still exists and still works. It is the recovery path: // creating the FIRST platform admin cannot itself require being signed in as // one, and a bootstrap that only works over HTTP is a bootstrap that fails // exactly when HTTP is what is broken. // adminOnly gates the routes below on a platform administrator. // // A platform admin is defined by having NO client - the scope is the absence, // not a flag - so this checks both. A tenant-scoped account with the role // somehow set to "admin" would otherwise read every customer of every client. func (s *Server) adminOnly(next http.HandlerFunc) http.HandlerFunc { return s.authed(func(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.IsAdmin() || p.ClientID != "" { // 404, not 403. A tenant user has no business knowing that a // platform-administration surface exists at all. writeErr(w, http.StatusNotFound, "not_found", "No such page.") return } next(w, r) }) } func (s *Server) handleListClients(w http.ResponseWriter, r *http.Request) { rows, err := s.Store.ListClients(r.Context()) if err != nil { s.serverError(w, "list clients", err) return } if rows == nil { rows = []ClientRow{} } writeJSON(w, http.StatusOK, rows) } func (s *Server) handleCreateClient(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) var in NewClientInput if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } in.CompanyName = clip(trim(in.CompanyName), 200) in.OwnerName = clip(trim(in.OwnerName), 200) in.OwnerEmail = strings.ToLower(trim(in.OwnerEmail)) in.Slug = slugify(in.Slug) if in.Slug == "" { // Derived from the company name when not given, because the slug is a // technical detail (it becomes the MQTT topic prefix) and asking an // operator to invent one is asking them to get it wrong. in.Slug = slugify(in.CompanyName) } switch { case in.CompanyName == "": badRequest(w, "the company needs a name") return case in.OwnerEmail == "": badRequest(w, "an owner email is required - without one nobody can sign in") return case in.Slug == "": badRequest(w, "the company name has no letters or digits to build a short name from") return } out, err := s.Store.CreateClientWithOwner(r.Context(), in) if err != nil { // Duplicate slug and duplicate email are the two an operator can act // on, and both are ordinary typing mistakes rather than faults. if msg, ok := conflictMessage(err); ok { writeErr(w, http.StatusConflict, "conflict", msg) return } s.serverError(w, "create client", err) return } // Creating a tenant is rare and consequential; it should leave a trace with // a name against it. s.Store.Audit(r.Context(), AuditEntry{ ActorID: p.UserID, ActorKind: "user", Action: "client.create", Entity: "client", EntityID: out.ClientID, Detail: map[string]any{"slug": out.Slug, "owner": out.OwnerEmail}, }) writeJSON(w, http.StatusCreated, out) } // slugify turns "Nearle Retail Pvt Ltd" into "nearle-retail-pvt-ltd". // // The result becomes an MQTT topic segment, so it is restricted to characters // that cannot change what a topic means: no '/', no '+', no '#'. func slugify(s string) string { var b strings.Builder lastDash := true // never start with a dash for _, r := range strings.ToLower(strings.TrimSpace(s)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastDash = false case !lastDash: b.WriteByte('-') lastDash = true } } return strings.Trim(b.String(), "-") } func conflictMessage(err error) (string, bool) { msg := err.Error() switch { case strings.Contains(msg, "clients_slug_key"): return "A company with that short name already exists - choose another.", true case strings.Contains(msg, "app_users_email_idx"): return "That email address already has an account.", true } return "", false }