Files
warmbly/internal/api/handler/admin_tester.go
T
Matthew Meszaros 43dcbde06c feat: tester accounts, creatable from the admin panel (#483)
* feat: excuse one named account from the emailed login code, so a vendor reviewer who cannot read this instance's mail can sign in without turning codes off for everyone, with the reason recorded beside it and every run of warmblyctl status naming the accounts that hold one

* feat: create and manage tester accounts from the admin panel, so letting a reviewer in is a form rather than a shell, with the password shown once and every live exemption listed on one page because forgetting one is the way this goes wrong

* fix: give tester management its own permission bit rather than borrowing ban_users, create the account and its exemption in one transaction so no invisible orphan survives a failure, require an accountable operator on the CLI grant, stop a halted row scan reading as the whole exempt list, and show a failed query as an error instead of as no testers

* chore: re-run CI after the aggregator tripped on a cancelled job from the branch update, with every underlying job green

* chore: retrigger CI, the previous run sat queued indefinitely while other branches ran

* feat: roll back a half-created tester when its workspace step fails and backfill the manage-testers bit onto admins already holding every other permission, so the address is not left taken by an unusable account and the new routes are not 403 for the existing admin

* feat: make the 000151 manage-testers backfill one-way, because clearing bit 22 on the way down would also revoke it from an admin granted it explicitly afterwards and the up migration would not restore that
2026-09-13 03:07:10 -07:00

194 lines
6.6 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"
)
// 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"`
}
type adminCreateTesterResponse struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
OrgID uuid.UUID `json:"organization_id"`
// 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 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
}
orgName := strings.TrimSpace(req.OrgName)
if orgName == "" {
orgName = "Tester workspace"
}
org, oerr := h.OrganizationService.Create(c.Request.Context(), created.ID, orgName)
if oerr != nil {
// Undo the account rather than leave the address taken by something
// unusable: revoking would not free it, and a retry would fail on the
// existing-email check, so the operator would have nowhere to go.
if derr := h.UserRepo.DeleteOrphanExemptUser(c.Request.Context(), created.ID); derr != nil {
errx.JSON(c, errx.New(errx.Internal,
"the workspace could not be created and the half-made account could not be removed; it is listed under Testers"))
return
}
errx.JSON(c, errx.New(errx.Internal, "could not create the workspace, so nothing was created. Try again."))
return
}
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)
}
h.logTesterAction(c, *adminID, created.ID, "create_tester", map[string]any{
"email": created.Email, "reason": reason, "organization_id": org.ID.String(),
})
c.JSON(http.StatusOK, adminCreateTesterResponse{
UserID: created.ID,
Email: created.Email,
OrgID: org.ID,
Password: password,
})
}
// 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())
}