refactor(sitesvc): rename Org to Instance, refuse an unmigrated database
The signup form's JSON field becomes instance_name, and the pending-signup document field with it. That collection is sitesvc-private and expires after 24 hours, so no migration is needed, but in-flight signups written before the deploy will fail verification.
This commit is contained in:
@@ -28,6 +28,13 @@ func main() {
|
||||
}
|
||||
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
|
||||
|
||||
guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
guardErr := store.RequireMigratedDatabase(guardCtx)
|
||||
guardCancel()
|
||||
if guardErr != nil {
|
||||
log.Fatalf("database check failed: %v", guardErr)
|
||||
}
|
||||
|
||||
if err := store.EnsureIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure indexes: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]*window
|
||||
@@ -51,8 +47,6 @@ func (l *limiter) allow(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (l *limiter) gc(now time.Time) {
|
||||
if now.Sub(l.lastGC) < l.window {
|
||||
return
|
||||
|
||||
@@ -22,10 +22,10 @@ const (
|
||||
)
|
||||
|
||||
type signupBody struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,7 +41,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var problems []fieldError
|
||||
|
||||
orgName, nameErr := text("org_name", body.OrgName, true, maxShort)
|
||||
instanceName, nameErr := text("instance_name", body.InstanceName, true, maxShort)
|
||||
if nameErr != nil {
|
||||
problems = append(problems, *nameErr)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := store.CreatePending(ctx, orgName, addr, body.Password)
|
||||
token, err := store.CreatePending(ctx, instanceName, addr, body.Password)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
@@ -95,7 +95,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
writeFieldErrors(w, []fieldError{{
|
||||
Field: "org_name",
|
||||
Field: "instance_name",
|
||||
Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "),
|
||||
}})
|
||||
return
|
||||
@@ -108,7 +108,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
|
||||
if err := s.mail.SendVerification(addr, instanceName, link, store.PendingTTL); err != nil {
|
||||
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
@@ -136,7 +136,7 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
org, err := store.Verify(ctx, token)
|
||||
inst, err := store.Verify(ctx, token)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrBadToken):
|
||||
s.verifyPage(w, http.StatusGone, "Link expired",
|
||||
@@ -157,23 +157,23 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID)
|
||||
log.Printf("verify: provisioned instance %s (%s)", inst.Slug, inst.InstanceID)
|
||||
|
||||
if login := s.loginURL(org.Slug); login != "" {
|
||||
if login := s.loginURL(inst.Slug); login != "" {
|
||||
http.Redirect(w, r, login, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.verifyPage(w, http.StatusOK, "Organisation ready",
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", inst.Name))
|
||||
}
|
||||
|
||||
// loginURL is the org-specific sign-in URL a verified owner is sent to. Each
|
||||
// org lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each
|
||||
// instance lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// must be substituted per signup rather than pointing at one shared address.
|
||||
//
|
||||
// APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the
|
||||
// org's slug; a value without one is treated as a literal (a single shared
|
||||
// instance's slug; a value without one is treated as a literal (a single shared
|
||||
// login page) so a plain URL still works. An empty value falls back to the
|
||||
// confirmation page.
|
||||
func (s *Server) loginURL(slug string) string {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
|
||||
|
||||
const (
|
||||
@@ -23,9 +21,6 @@ type fieldError struct {
|
||||
|
||||
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
|
||||
|
||||
|
||||
|
||||
|
||||
func text(name, value string, required bool, max int) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
@@ -55,9 +50,6 @@ func email(name, value string) (string, *fieldError) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func oneOf(name, value string, allowed []string) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
for _, a := range allowed {
|
||||
|
||||
@@ -150,7 +150,7 @@ func sanitizeHeader(v string) string {
|
||||
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
|
||||
}
|
||||
|
||||
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
|
||||
func (c Config) SendVerification(to, instanceName, link string, ttl time.Duration) error {
|
||||
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
|
||||
|
||||
Open this link:
|
||||
@@ -161,7 +161,7 @@ The link works once and expires in %d hours. Until you use it, no account
|
||||
exists nothing has been created and the address is not registered.
|
||||
|
||||
If you did not request this, ignore this email and nothing will happen.
|
||||
`, orgName, link, int(ttl.Hours()))
|
||||
`, instanceName, link, int(ttl.Hours()))
|
||||
|
||||
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type PendingSignup struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
PendingID string `bson:"pending_id"`
|
||||
OrgName string `bson:"org_name"`
|
||||
InstanceName string `bson:"instance_name"`
|
||||
Email string `bson:"email"`
|
||||
PasswordHash string `bson:"password_hash"`
|
||||
TokenHash string `bson:"token_hash"`
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
const PendingTTL = 24 * time.Hour
|
||||
|
||||
// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
|
||||
@@ -36,15 +35,6 @@ var (
|
||||
|
||||
var database *mongo.Database
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func Connect(uri string) error {
|
||||
cs, err := connstring.ParseAndValidate(uri)
|
||||
if err != nil {
|
||||
@@ -68,7 +58,6 @@ func Connect(uri string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func DatabaseName() string {
|
||||
if database == nil {
|
||||
return ""
|
||||
@@ -82,7 +71,7 @@ func EnsureIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// users.email and orgs.slug are declared in the shared module so both
|
||||
// users.email and instances.slug are declared in the shared module so both
|
||||
// services agree. Re-declaring at boot means sitesvc does not depend on the
|
||||
// control plane having started first.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
|
||||
@@ -95,8 +84,7 @@ func EnsureIndexes() error {
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{Keys: bson.D{{Key: "email", Value: 1}}},
|
||||
|
||||
|
||||
|
||||
{
|
||||
Keys: bson.D{{Key: "expires_at", Value: 1}},
|
||||
Options: options.Index().SetExpireAfterSeconds(0),
|
||||
@@ -108,13 +96,46 @@ func EnsureIndexes() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireMigratedDatabase refuses to start against a control-plane database
|
||||
// that has not run migration 0004.
|
||||
//
|
||||
// Provisioning into `orgs` while the control plane reads `instances` would
|
||||
// create tenants nobody can see — the exact skew failure the shared module was
|
||||
// built to prevent. Failing to start is strictly better.
|
||||
func RequireMigratedDatabase(ctx context.Context) error {
|
||||
names, err := database.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
|
||||
var hasInstances, hasOrgs bool
|
||||
for _, n := range names {
|
||||
switch n {
|
||||
case "instances":
|
||||
hasInstances = true
|
||||
case "orgs":
|
||||
hasOrgs = true
|
||||
}
|
||||
}
|
||||
|
||||
// A brand-new database has neither. That is fine — whichever service starts
|
||||
// first creates `instances`.
|
||||
if !hasInstances && !hasOrgs {
|
||||
return nil
|
||||
}
|
||||
if !hasInstances {
|
||||
return errors.New("instances collection not found; deploy the control plane first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EmailTaken(ctx context.Context, email string) (bool, error) {
|
||||
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func CreatePending(ctx context.Context, orgName, email, password string) (string, error) {
|
||||
if _, err := provision.BaseSlug(orgName); err != nil {
|
||||
func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) {
|
||||
if _, err := provision.BaseSlug(instanceName); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
|
||||
@@ -136,8 +157,6 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
|
||||
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -145,7 +164,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
|
||||
now := time.Now().UTC()
|
||||
pending := models.PendingSignup{
|
||||
PendingID: uuid.NewString(),
|
||||
OrgName: strings.TrimSpace(orgName),
|
||||
InstanceName: strings.TrimSpace(instanceName),
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
TokenHash: hashToken(raw),
|
||||
@@ -158,12 +177,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) {
|
||||
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) {
|
||||
var pending models.PendingSignup
|
||||
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
|
||||
"token_hash": hashToken(rawToken),
|
||||
@@ -176,24 +190,24 @@ func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org, err := provision.CreateOrg(ctx, database, pending.OrgName)
|
||||
inst, err := provision.CreateInstance(ctx, database, pending.InstanceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The password was hashed when the signup was recorded; only the hash
|
||||
// survives to this point.
|
||||
_, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email,
|
||||
_, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email,
|
||||
pending.PasswordHash, sharedmodels.RoleOwner, "local")
|
||||
if err != nil {
|
||||
// Leaving an org behind would permanently occupy a slug nobody owns.
|
||||
if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil {
|
||||
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
|
||||
// Leaving an instance behind would permanently occupy a slug nobody owns.
|
||||
if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil {
|
||||
log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user