Files
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

350 lines
12 KiB
Go

// Package bootstrap provisions the first owner of a fresh install.
//
// Without it the only documented path was: register through the public form,
// read the emailed code out of a mail catcher, then run a psql UPDATE from the
// host. That is three manual steps, it depends on working mail, and the window
// between "instance is reachable" and "an admin exists" is claimable by anyone
// who finds the URL first, which is a recurring CVE class in self-hosted
// software.
//
// Two mechanisms, both standard in the field:
//
// - WARMBLY_BOOTSTRAP_EMAIL plus WARMBLY_BOOTSTRAP_PASSWORD_HASH, read only
// while the users table is empty (authentik, n8n and NocoDB all do this).
// - Otherwise a single-use setup token printed once to the logs, which is
// Zulip's realm-creation link, invalidated the moment it is used.
package bootstrap
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/mail"
"os"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/trial"
"github.com/warmbly/warmbly/internal/app/user"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/argon2"
"github.com/warmbly/warmbly/internal/pkg/crypt"
"github.com/warmbly/warmbly/internal/repository"
)
// SetupTokenTTL bounds the window in which the printed token can be claimed.
const SetupTokenTTL = 24 * time.Hour
const setupTokenKey = "bootstrap:setup_token"
type Service struct {
users repository.UserRepository
userSvc user.UserService
orgSvc organization.OrganizationService
trialSvc trial.TrialService
adminRepo repository.AdminRepository
cache *cache.Cache
}
func NewService(
users repository.UserRepository,
userSvc user.UserService,
orgSvc organization.OrganizationService,
trialSvc trial.TrialService,
adminRepo repository.AdminRepository,
cache *cache.Cache,
) *Service {
return &Service{
users: users,
userSvc: userSvc,
orgSvc: orgSvc,
trialSvc: trialSvc,
adminRepo: adminRepo,
cache: cache,
}
}
// Run is called once at boot, after migrations. It is a no-op on an instance
// that already has users, so it is safe on every restart.
func (s *Service) Run(ctx context.Context) error {
empty, err := s.users.IsEmpty(ctx)
if err != nil {
return fmt.Errorf("bootstrap: checking for existing users: %w", err)
}
if !empty {
// Say why no claim link appeared. Staying silent here is what turns a
// re-used database into an unexplained 403 on the signup form.
s.explainClaimed(ctx)
return nil
}
email := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_EMAIL"))
if email != "" {
return s.createOwner(ctx, email)
}
return s.printSetupToken(ctx)
}
func (s *Service) createOwner(ctx context.Context, address string) error {
parsed, err := mail.ParseAddress(address)
if err != nil {
return fmt.Errorf("bootstrap: WARMBLY_BOOTSTRAP_EMAIL is not a valid address: %w", err)
}
hash := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_PASSWORD_HASH"))
if hash == "" {
// A plaintext password is accepted as a convenience, with the same
// warning authentik gives: environment variables leak through process
// listings, orchestrator APIs and crash dumps.
plain := os.Getenv("WARMBLY_BOOTSTRAP_PASSWORD")
if plain == "" {
return fmt.Errorf("bootstrap: WARMBLY_BOOTSTRAP_EMAIL is set but neither WARMBLY_BOOTSTRAP_PASSWORD_HASH nor WARMBLY_BOOTSTRAP_PASSWORD is")
}
log.Printf("Warning: WARMBLY_BOOTSTRAP_PASSWORD is a plaintext password in an environment variable. Prefer WARMBLY_BOOTSTRAP_PASSWORD_HASH.")
hashed, herr := argon2.Hash(plain)
if herr != nil {
return fmt.Errorf("bootstrap: hashing the password: %w", herr)
}
hash = hashed
}
u, uerr := s.users.CreateUser(ctx, parsed, hash)
if uerr != nil {
return fmt.Errorf("bootstrap: creating the owner: %w", uerr)
}
if err := s.userSvc.SaveUser(ctx, u); err != nil {
return fmt.Errorf("bootstrap: saving the owner: %w", err)
}
orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
if orgName == "" {
orgName = defaultOrgName(u.FirstName)
}
org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
if orgErr != nil {
return fmt.Errorf("bootstrap: creating the organization: %w", orgErr)
}
if s.trialSvc != nil {
_ = s.trialSvc.StartFreeTrialWithOrg(ctx, u.ID, org.ID)
}
// The bootstrap account is the platform operator, so it gets full admin.
// This is the only path that grants admin without an existing admin.
if err := s.adminRepo.GrantBootstrapAdmin(ctx, u.ID, uint32(models.AllAdminPermissions)); err != nil {
return fmt.Errorf("bootstrap: granting admin: %w", err)
}
log.Printf("Bootstrap: created owner %s with full admin permissions and organization %q.", parsed.Address, orgName)
return nil
}
// IssueSetupLink mints a single-use claim link for the first account and
// returns it. Only its hash is stored, so reading the database does not yield a
// usable token, and claiming it deletes the key. Minting again replaces any
// outstanding token, so an old link stops working.
//
// It refuses on a claimed instance: a second owner must never be mintable
// without an existing account.
func (s *Service) IssueSetupLink(ctx context.Context) (string, error) {
if s.cache == nil {
return "", fmt.Errorf("bootstrap: no cache is configured, so a setup token cannot be stored (check REDIS)")
}
empty, err := s.users.IsEmpty(ctx)
if err != nil {
return "", fmt.Errorf("bootstrap: checking for existing users: %w", err)
}
if !empty {
return "", fmt.Errorf("bootstrap: this instance already has accounts, so no setup link is issued. Add an account with `warmblyctl user create --email you@example.com --admin`")
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
token := hex.EncodeToString(buf)
if err := s.cache.SetEx(ctx, setupTokenKey, hashToken(token), SetupTokenTTL).Err(); err != nil {
return "", fmt.Errorf("bootstrap: storing the setup token: %w", err)
}
return fmt.Sprintf("%s/setup?token=%s", config.AppBaseURL(), token), nil
}
// printSetupToken mints the claim link at boot and prints it once.
func (s *Service) printSetupToken(ctx context.Context) error {
if s.cache == nil {
// The worst possible silence: without a cache the instance can neither
// be claimed nor registered against, and nothing else says so.
log.Printf("Bootstrap: no accounts exist and no cache is configured, so no setup link can be issued. Check REDIS, then run `warmblyctl setup-link`.")
return nil
}
url, err := s.IssueSetupLink(ctx)
if err != nil {
return err
}
log.Printf("\n"+
"┌──────────────────────────────────────────────────────────────────────\n"+
"│ No accounts exist yet. Claim this instance with the link below.\n"+
"│\n"+
"│ %s\n"+
"│\n"+
"│ Single use, valid for %s. It is printed only once, and only its hash\n"+
"│ is stored.\n"+
"│\n"+
"│ Lost it? Print a new one: warmblyctl setup-link\n"+
"│ Unattended instead? Set WARMBLY_BOOTSTRAP_EMAIL and\n"+
"│ WARMBLY_BOOTSTRAP_PASSWORD_HASH before the first start.\n"+
"└──────────────────────────────────────────────────────────────────────\n",
url, SetupTokenTTL)
return nil
}
// explainClaimed logs why a claim link was not issued, and what to run
// instead. Best effort: never blocks boot.
func (s *Service) explainClaimed(ctx context.Context) {
count, err := s.users.CountUsers(ctx)
if err != nil {
return
}
registration := config.LoadAuthPolicy(false).Registration
advice := "Registration is open, so you can create an account from the sign-in page."
if registration != config.RegistrationOpen {
advice = fmt.Sprintf("Registration is %s (DISABLE_REGISTRATION), so the signup form will refuse\n"+
"│ new accounts. Add one with:\n"+
"│\n"+
"│ warmblyctl user create --email you@example.com --admin", registration)
}
log.Printf("\n"+
"┌──────────────────────────────────────────────────────────────────────\n"+
"│ First run: skipped. This instance already has %d account(s), so no\n"+
"│ setup link is issued.\n"+
"│\n"+
"│ %s\n"+
"│\n"+
"│ See where you stand: warmblyctl status\n"+
"└──────────────────────────────────────────────────────────────────────\n",
count, advice)
}
// Required reports whether this instance still needs to be claimed. Drives the
// setup page and the `setup_required` flag on GET /auth/config.
func (s *Service) Required(ctx context.Context) bool {
empty, err := s.users.IsEmpty(ctx)
return err == nil && empty
}
// Claim exchanges the printed setup token for the owner account.
//
// The token is consumed before the account is created, not after: two requests
// racing with the same token must not both succeed, and Redis GETDEL is the
// atomic step that decides which one wins.
func (s *Service) Claim(ctx context.Context, token, address, password, firstName, lastName string) (*models.User, *errx.Error) {
if s.cache == nil || token == "" {
return nil, errx.ErrSetupToken
}
parsed, perr := mail.ParseAddress(address)
if perr != nil {
return nil, errx.ErrEmail
}
if !crypt.ValidatePassword(password) {
return nil, errx.ErrPassword
}
// Refuse on an instance that already has accounts, even with a valid
// token: a stale link out of an old log must never mint a second owner.
empty, eerr := s.users.IsEmpty(ctx)
if eerr != nil {
sentry.CaptureException(eerr)
return nil, errx.InternalError()
}
if !empty {
return nil, errx.ErrSetupComplete
}
// Read the remaining lifetime before consuming the key: restoring a losing
// token with a fresh TTL would let anyone hold the claim window open
// indefinitely by posting a wrong token once a day, and /auth/setup is
// public.
remaining, _ := s.cache.PTTL(ctx, setupTokenKey).Result()
stored, gerr := s.cache.GetDel(ctx, setupTokenKey).Result()
if gerr != nil || stored == "" || stored != hashToken(token) {
// Put a valid-but-losing token back only when the value did not match,
// so a typo does not burn the real one, and only for the time it had
// left.
if gerr == nil && stored != "" && stored != hashToken(token) && remaining > 0 {
_ = s.cache.SetEx(ctx, setupTokenKey, stored, remaining).Err()
}
return nil, errx.ErrSetupToken
}
hash, herr := argon2.Hash(password)
if herr != nil {
sentry.CaptureException(herr)
return nil, errx.InternalError()
}
u, uerr := s.users.CreateUser(ctx, parsed, hash)
if uerr != nil {
sentry.CaptureException(uerr)
return nil, errx.InternalError()
}
if firstName != "" {
if err := s.users.UpdateProfile(ctx, u.ID, firstName, lastName); err == nil {
u.FirstName, u.LastName = firstName, lastName
}
}
if err := s.userSvc.SaveUser(ctx, u); err != nil {
return nil, err
}
orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
if orgName == "" {
orgName = defaultOrgName(u.FirstName)
}
org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
if orgErr != nil {
sentry.CaptureException(orgErr)
return nil, errx.InternalError()
}
if s.trialSvc != nil {
_ = s.trialSvc.StartFreeTrialWithOrg(ctx, u.ID, org.ID)
}
if err := s.adminRepo.GrantBootstrapAdmin(ctx, u.ID, uint32(models.AllAdminPermissions)); err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
log.Printf("Setup: %s claimed this instance and is now its owner and platform admin.", parsed.Address)
return u, nil
}
func defaultOrgName(firstName string) string {
if firstName == "" {
return "My Organization"
}
return firstName + "'s Organization"
}
func hashToken(token string) string {
// argon2 is overkill for a 256-bit random token, so the shared SHA-256
// helper is the right cost here.
return crypt.SHA256(token)
}