package api import ( "net/http" "strings" "time" "github.com/loyaly/behavision-server/internal/auth" ) // Adding people to a company: invitations, registration, and the team list. // // Registration is by INVITATION, and that is the same decision handlers_admin.go // records for creating a company: an endpoint a stranger can call to create an // account is a far larger thing to secure than one reachable only through // somebody who already has one. What was missing was not the openness - it was // that a tenant could not add a SECOND person at all except by somebody with a // shell on the server running `provision user`. A shop with an owner and four // staff either shared one password between five people or raised a ticket per // person, and a phone app for shop-floor staff could not exist while there was // only ever one account to sign in as. // // So: a manager mints a code, hands it over, and the holder chooses their own // password. The code carries the address and the role; the request carries only // the password and a name. That split is load-bearing and is why this is not // simply "create a user with these fields" - see handleRegister. const ( // Long enough to reach somebody who is not at work today, short enough that // a code left in a chat thread is worthless before anyone scrolls back to // it. An expired invitation costs one click to reissue. invitationTTL = 7 * 24 * time.Hour maxInvitation = 30 * 24 * time.Hour ) // handleInvite mints one invitation. // // Manager and above. Not staff: the holder of a code gets an account inside // this company, so it is a credential, not a convenience. func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Your account cannot invite people to this company.") return } var body struct { Email string `json:"email"` FullName string `json:"full_name"` Role string `json:"role"` Days int `json:"expires_in_days"` } if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } email := auth.NormalizeEmail(body.Email) if email == "" || !strings.Contains(email, "@") { badRequest(w, "an email address is required - it is what they will sign in with") return } role := strings.ToLower(trim(body.Role)) if role == "" { role = "staff" } // 'admin' is absent on purpose. A platform administrator is defined by // having no company at all, so an invitation could never mint a real one - // what it could do is create the tenant-scoped row with role='admin' that // adminOnly exists to reject, and a role nothing can use is a trap rather // than a feature. switch role { case "owner", "manager", "staff": default: badRequest(w, "role must be owner, manager or staff") return } // Only an owner may create another owner. A manager promoting somebody past // themselves is an escalation, and it is the one shape of this endpoint // that would matter if a manager account were ever taken over. if role == "owner" && p.Role != "owner" && p.Role != "admin" { writeErr(w, http.StatusForbidden, "forbidden", "Only an owner can invite another owner.") return } ttl := invitationTTL if body.Days > 0 { ttl = time.Duration(body.Days) * 24 * time.Hour if ttl > maxInvitation { ttl = maxInvitation } } code, err := auth.NewEnrolmentCode() if err != nil { s.serverError(w, "mint invitation", err) return } inv, err := s.Store.CreateInvitation(r.Context(), NewInvitation{ ClientID: p.ClientID, Email: email, FullName: clip(trim(body.FullName), 200), Role: role, CodeHash: auth.HashToken(auth.NormalizeCode(code)), InvitedBy: p.UserID, ExpiresAt: s.now().Add(ttl), }) if err != nil { s.serverError(w, "create invitation", err) return } // The plaintext exists here and in this response, and nowhere else. Like // every other secret this system mints, it is shown once: one a support // engineer can look up later is one anybody with support access can redeem. inv.Code = code s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "team.invite", Entity: "invitation", EntityID: inv.ID, Detail: map[string]any{"email": email, "role": role}, }) writeJSON(w, http.StatusCreated, inv) } func (s *Server) handleInvitations(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Your account cannot see this company's invitations.") return } rows, err := s.Store.PendingInvitations(r.Context(), p.ClientID) if err != nil { s.serverError(w, "list invitations", err) return } if rows == nil { rows = []Invitation{} } writeJSON(w, http.StatusOK, rows) } func (s *Server) handleRevokeInvitation(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Your account cannot withdraw invitations.") return } id := r.PathValue("id") if !looksLikeUUID(id) { writeErr(w, http.StatusNotFound, "not_found", "No such invitation.") return } if err := s.Store.RevokeInvitation(r.Context(), p.ClientID, id); err != nil { // Already used or already withdrawn. Reported rather than swallowed: // "I cancelled it" and "somebody had already joined with it" need // opposite next steps from whoever pressed the button. writeErr(w, http.StatusNotFound, "not_found", "That invitation is no longer pending.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "team.invite.revoke", Entity: "invitation", EntityID: id, }) w.WriteHeader(http.StatusNoContent) } // handleInvitationPreview lets a client show what a code is for before asking // somebody to choose a password. // // Unauthenticated, because the holder has no account yet - that is the whole // point - and it discloses only what the code itself already asserts: the // company, the address it was issued for, and the role. Unknown, expired, spent // and revoked are one identical answer, exactly as enrolment already does: // telling them apart only helps somebody guessing codes, and the holder's next // step is the same in all four cases. func (s *Server) handleInvitationPreview(w http.ResponseWriter, r *http.Request) { code := auth.NormalizeCode(r.URL.Query().Get("code")) if code == "" { badRequest(w, "a code is required") return } prev, err := s.Store.InvitationByCode(r.Context(), auth.HashToken(code)) if err != nil { writeErr(w, http.StatusNotFound, "invalid_code", "That invitation code is not valid. Ask for a new one.") return } writeJSON(w, http.StatusOK, prev) } // handleRegister turns a code into an account and signs the person in. // // Unauthenticated for the same reason `POST /api/agent/enrol` is: whoever is // doing this has no account yet, and requiring one first would mean shipping a // password to everybody who needs one. // // The email and the role come from the INVITATION, never from this body. A code // forwarded to a colleague must not become an account for them, and a staff // invitation must not be redeemed as an owner - which is exactly what a // caller-supplied role would allow. The only things the request decides are the // password and the display name. // // It returns a Session, identical in shape to login. A new member's next screen // is the app, not a sign-in form they have to fill in with the password they // chose four seconds ago. func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { var body struct { Code string `json:"code"` FullName string `json:"full_name"` Password string `json:"password"` Device string `json:"device"` } if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } code := auth.NormalizeCode(body.Code) if code == "" { badRequest(w, "an invitation code is required") return } // Throttled on the code, by IP. Redeeming is the one unauthenticated write // in this package that creates a row, so an unbounded one is a way to grind // through the code space and to fill a table while doing it. _, perIP := s.throttles() ipKey := clientIP(r) if !perIP.Allow(ipKey) { writeErr(w, http.StatusTooManyRequests, "too_many_attempts", "Too many attempts. Wait a few minutes and try again.") return } if err := auth.CheckPasswordPolicy(body.Password); err != nil { badRequest(w, err.Error()) return } hash, err := auth.HashPassword(body.Password) if err != nil { s.serverError(w, "hash password", err) return } rec, err := s.Store.RedeemInvitation(r.Context(), auth.HashToken(code), clip(trim(body.FullName), 200), hash) if err != nil { perIP.Fail(ipKey) if msg, ok := conflictMessage(err); ok { // The address already has an account somewhere on the platform. // Worth saying plainly: the fix is to sign in, not to try again. writeErr(w, http.StatusConflict, "conflict", msg) return } writeErr(w, http.StatusNotFound, "invalid_code", "That invitation code is not valid. Ask for a new one.") return } perIP.Reset(ipKey) sess, err := s.mint(r, rec, body.Device) if err != nil { // The account exists and the invitation is spent. Say so rather than // implying nothing happened - the recovery is to sign in, and telling // them to redeem again would fail forever. s.logf("ERROR register: created %s but could not start a session: %v", rec.ID, err) writeErr(w, http.StatusInternalServerError, "server_error", "Your account was created but we could not sign you in. Please sign in.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user", Action: "team.register", Entity: "user", EntityID: rec.ID, Detail: map[string]any{"role": rec.Role, "device": trim(body.Device)}, }) s.logf("registered %s (%s) into client %s", rec.Email, rec.Role, rec.ClientID) writeJSON(w, http.StatusCreated, sess) } func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "This account does not belong to a company.") return } rows, err := s.Store.Team(r.Context(), p.ClientID) if err != nil { s.serverError(w, "list team", err) return } if rows == nil { rows = []TeamMember{} } writeJSON(w, http.StatusOK, rows) } // handleUpdateTeamMember changes a role, or turns an account off. // // Deactivating is the "they have left" button, and the store revokes their // sessions in the same transaction: an access token lives twelve hours, so // without that, removing somebody's access would remove it sometime tomorrow. func (s *Server) handleUpdateTeamMember(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Your account cannot change who works here.") return } id := r.PathValue("id") if !looksLikeUUID(id) { writeErr(w, http.StatusNotFound, "not_found", "No such team member.") return } var up TeamUpdate if err := decode(w, r, &up); err != nil { badRequest(w, err.Error()) return } if up.Role == nil && up.Active == nil { badRequest(w, "nothing to change - send a role, an active flag, or both") return } if up.Role != nil { role := strings.ToLower(trim(*up.Role)) switch role { case "owner", "manager", "staff": default: badRequest(w, "role must be owner, manager or staff") return } if role == "owner" && p.Role != "owner" && p.Role != "admin" { writeErr(w, http.StatusForbidden, "forbidden", "Only an owner can make somebody else an owner.") return } up.Role = &role } // The company must keep an owner. Losing the last one leaves a tenant // nobody can administer, and the only way back is a shell on the server - // which is the thing this whole surface exists to stop needing. demoting := up.Role != nil && *up.Role != "owner" disabling := up.Active != nil && !*up.Active if demoting || disabling { if last, err := s.lastOwner(r, id); err != nil { s.serverError(w, "count owners", err) return } else if last { writeErr(w, http.StatusConflict, "last_owner", "This is the company's only owner. Make somebody else an owner first.") return } } m, err := s.Store.UpdateTeamMember(r.Context(), p.ClientID, id, up) if err != nil { writeErr(w, http.StatusNotFound, "not_found", "No such team member.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "team.update", Entity: "user", EntityID: id, Detail: map[string]any{"role": m.Role, "active": m.Active}, }) writeJSON(w, http.StatusOK, m) } // lastOwner reports whether the named member is the only active owner left. func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) { p := PrincipalFrom(r.Context()) rows, err := s.Store.Team(r.Context(), p.ClientID) if err != nil { return false, err } owners, isOwner := 0, false for _, m := range rows { if m.Role == "owner" && m.Active { owners++ if m.ID == userID { isOwner = true } } } return isOwner && owners == 1, nil } // handleCreateMember is a manager creating a salesperson's login directly and // handing it over - the path for somebody being set up before their first // shift, without a phone in hand. // // Same rules as an invitation for who may create whom: manager and above, and // only an owner mints an owner. Same rule as the platform admin creating a // merchant for the password: generated unless given, returned exactly once. func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or owner can add team members.") return } var in NewMemberInput if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } in.Email = auth.NormalizeEmail(in.Email) if in.Email == "" || !strings.Contains(in.Email, "@") { badRequest(w, "an email address is required - it is what they will sign in with") return } in.FullName = clip(trim(in.FullName), 200) in.Role = strings.ToLower(trim(in.Role)) if in.Role == "" { in.Role = "staff" } switch in.Role { case "owner", "manager", "staff": default: badRequest(w, "role must be owner, manager or staff") return } if in.Role == "owner" && p.Role != "owner" && p.Role != "admin" { writeErr(w, http.StatusForbidden, "forbidden", "Only an owner can create another owner.") return } password := in.Password if password == "" { generated, err := auth.RandomPassword() if err != nil { s.serverError(w, "generate password", err) return } password = generated } hash, err := auth.HashPassword(password) if err != nil { // The policy message ("at least 8 characters") is written for the // person who typed it, so it goes out as-is. badRequest(w, err.Error()) return } m, err := s.Store.CreateMember(r.Context(), p.ClientID, in, hash) if err != nil { if msg, ok := conflictMessage(err); ok { writeErr(w, http.StatusConflict, "conflict", msg) return } s.serverError(w, "create member", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "team.create", Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "role": m.Role}, }) // The plaintext exists here and in this response, and nowhere else. writeJSON(w, http.StatusCreated, NewMemberResult{TeamMember: m, Password: password}) } // handleResetPassword is a manager resetting a member's password: the // salesperson forgot it, or lost the phone it was on. Returns the new one // once, and signs the member out everywhere - see the store for why those are // one operation. // // Deliberately not self-service and not "send an email": a shop-floor account // often has no mailbox anyone checks, and the person who can vouch for the // salesperson standing in front of them is their manager. func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or owner can reset a team member's password.") return } userID := r.PathValue("id") var in PasswordReset if err := decodeOptional(w, r, &in); err != nil { badRequest(w, err.Error()) return } password := in.Password if password == "" { generated, err := auth.RandomPassword() if err != nil { s.serverError(w, "generate password", err) return } password = generated } hash, err := auth.HashPassword(password) if err != nil { badRequest(w, err.Error()) return } m, err := s.Store.ResetMemberPassword(r.Context(), p.ClientID, userID, hash) if err != nil { // A user id from another tenant matches nothing, so it reads as 404 - // a tenant user has no business learning the id was real. writeErr(w, http.StatusNotFound, "not_found", "No such team member.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "team.reset_password", Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email}, }) writeJSON(w, http.StatusOK, PasswordReset{Password: password}) }