Files
warmbly/internal/api/handler/admin_tester.go
T

254 lines
9.1 KiB
Go

package handler
import (
"crypto/rand"
"math/big"
"net/http"
"net/mail"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/argon2"
"github.com/warmbly/warmbly/internal/pkg/displayname"
)
// A tester account is one an operator hands to somebody outside the team: a
// vendor's reviewer during an OAuth verification, an auditor, a support
// engineer. It is an ordinary account with its own workspace, marked exempt
// from the emailed login code because the holder cannot read this instance's
// mail. That exemption is what makes it findable later, so the list below is
// the same set the instance check warns about.
type adminCreateTesterRequest struct {
Email string `json:"email"`
OrgName string `json:"org_name"`
Reason string `json:"reason"`
// OrgID joins the tester to a workspace that already exists instead of
// minting an empty one. That is what a vendor's reviewer needs: an OAuth
// verification is judged on the app doing real work, and a workspace with
// no mailbox in it shows none of that. RoleID is then required, because
// this is the one path that grants workspace access without anybody in
// that workspace asking for it, and a default would be a permission
// nobody chose.
OrgID *uuid.UUID `json:"organization_id"`
RoleID *uuid.UUID `json:"role_id"`
}
type adminCreateTesterResponse struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
OrgID uuid.UUID `json:"organization_id"`
// Joined reports whether the tester landed in an existing workspace rather
// than one made for it, so the panel can say which and the operator is not
// left guessing what they just handed out.
Joined bool `json:"joined_existing"`
// Password is returned once and never stored in a readable form. Losing it
// means making another tester, which is cheap.
Password string `json:"password"`
}
// testerPasswordAlphabet leaves out the characters that are misread when a
// password is copied by hand off a screen or out of a form.
const testerPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func testerPassword() (string, error) {
const length = 20
b := make([]byte, length)
max := big.NewInt(int64(len(testerPasswordAlphabet)))
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = testerPasswordAlphabet[n.Int64()]
}
return "Tester-" + string(b), nil
}
// AdminCreateTester creates the account, its workspace and the exemption in
// one call, so an operator never has to reach for the CLI to let a reviewer in.
func (h *Handler) AdminCreateTester(c *gin.Context) {
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req adminCreateTesterRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "an email and a reason are required"))
return
}
parsed, perr := mail.ParseAddress(strings.TrimSpace(req.Email))
if perr != nil {
errx.JSON(c, errx.New(errx.BadRequest, "that is not a valid email address"))
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
errx.JSON(c, errx.New(errx.BadRequest, "a reason is required, so the account is answerable later"))
return
}
if h.UserRepo == nil || h.OrganizationService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "account creation is not available on this instance"))
return
}
if _, nerr := displayname.Validate("Workspace name", req.OrgName, displayname.Workspace, true); nerr != nil {
errx.JSON(c, nerr)
return
}
if req.OrgID != nil && req.RoleID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "joining an existing workspace needs a role, so the access granted is one somebody chose"))
return
}
if existing, lerr := h.UserRepo.GetUserByEmail(c.Request.Context(), parsed.Address); lerr == nil && existing != nil {
errx.JSON(c, errx.New(errx.BadRequest, "an account with that address already exists"))
return
}
password, gerr := testerPassword()
if gerr != nil {
errx.JSON(c, errx.New(errx.Internal, "could not generate a password"))
return
}
hash, herr := argon2.Hash(password)
if herr != nil {
errx.JSON(c, errx.New(errx.Internal, "could not hash the password"))
return
}
// One transaction, so a failure cannot leave an account that holds no
// exemption: that account would be invisible to the tester list,
// un-retryable because the address was taken, and reachable by whoever
// held the password.
created, cerr := h.UserRepo.CreateExemptUser(c.Request.Context(), parsed, hash, reason, adminID)
if cerr != nil {
errx.JSON(c, errx.New(errx.Internal, "could not create the account"))
return
}
// A tester joined to an existing workspace gets no workspace of its own on
// purpose. Owning one would leave the reviewer a member of two, and the
// dashboard only skips its workspace picker when there is exactly one, so
// the first thing they would meet is a chooser naming an empty workspace.
var orgID uuid.UUID
joined := req.OrgID != nil
if joined {
member, merr := h.OrganizationService.AttachTester(c.Request.Context(), *req.OrgID, created.ID, *adminID, *req.RoleID)
if merr != nil {
h.undoHalfMadeTester(c, created.ID, merr)
return
}
orgID = member.OrganizationID
} else {
orgName := strings.TrimSpace(req.OrgName)
if orgName == "" {
orgName = "Tester workspace"
}
org, oerr := h.OrganizationService.Create(c.Request.Context(), created.ID, orgName)
if oerr != nil {
h.undoHalfMadeTester(c, created.ID, oerr)
return
}
orgID = org.ID
if h.TrialService != nil {
// Best effort: without it the workspace has no subscription row and
// reads as unpaid, which is recoverable from the admin panel.
_ = h.TrialService.StartFreeTrialWithOrg(c.Request.Context(), created.ID, org.ID)
}
}
entry := map[string]any{
"email": created.Email, "reason": reason, "organization_id": orgID.String(),
"joined_existing": joined,
}
if joined {
// The role is the whole of what this tester can reach, so it belongs in
// the audit row rather than only in the members table it can be
// changed out of later.
entry["role_id"] = req.RoleID.String()
}
h.logTesterAction(c, *adminID, created.ID, "create_tester", entry)
c.JSON(http.StatusOK, adminCreateTesterResponse{
UserID: created.ID,
Email: created.Email,
OrgID: orgID,
Joined: joined,
Password: password,
})
}
// undoHalfMadeTester removes an account whose workspace step failed. Leaving it
// would take the address without giving anybody anything: revoking the
// exemption does not free the address, and a retry fails the existing-email
// check, so the operator would have nowhere to go. The delete is guarded on the
// account holding no membership, so it cannot remove a tester that did join.
//
// The original error is what the operator sees, because a seat limit and a role
// that does not exist are both things they can fix and neither is a 500. Only a
// cleanup that itself fails changes the answer, since that is the one case
// where something was left behind.
func (h *Handler) undoHalfMadeTester(c *gin.Context, userID uuid.UUID, cause *errx.Error) {
if derr := h.UserRepo.DeleteOrphanExemptUser(c.Request.Context(), userID); derr != nil {
errx.JSON(c, errx.New(errx.Internal,
cause.Message+"; the half-made account could not be removed either, and it is listed under Testers"))
return
}
errx.JSON(c, cause)
}
// AdminListTesters returns every account holding a login-code exemption, which
// is the set an operator needs to review and prune.
func (h *Handler) AdminListTesters(c *gin.Context) {
if h.UserRepo == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "accounts are not available on this instance"))
return
}
list, err := h.UserRepo.ListLoginCodeExempt(c.Request.Context())
if err != nil {
errx.JSON(c, errx.New(errx.Internal, "could not list tester accounts"))
return
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// AdminRevokeTester drops the exemption. The account stays, so anything it
// created is still attributable; it simply stops bypassing the login code.
func (h *Handler) AdminRevokeTester(c *gin.Context) {
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
userID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "that is not a user id"))
return
}
if h.UserRepo == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "accounts are not available on this instance"))
return
}
if err := h.UserRepo.SetLoginCodeExempt(c.Request.Context(), userID, false, "", nil); err != nil {
errx.JSON(c, errx.New(errx.Internal, "could not revoke the exemption"))
return
}
h.logTesterAction(c, *adminID, userID, "revoke_tester", nil)
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
func (h *Handler) logTesterAction(c *gin.Context, adminID, userID uuid.UUID, action string, details map[string]any) {
if h.AdminService == nil {
return
}
h.AdminService.LogAdminAction(c.Request.Context(), adminID, action, "user", &userID,
details, c.ClientIP(), c.Request.UserAgent())
}