mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
248 lines
9.0 KiB
Go
248 lines
9.0 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"`
|
|
// 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.NewPublic(errx.ServiceUnavailable, "Account creation is not available on this instance."))
|
|
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.NewPublic(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.NewPublic(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())
|
|
}
|