feat: login page says a locked instance is suspended instead of drawing the form
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 4m4s

This commit is contained in:
2026-09-11 09:05:44 +00:00
parent 2d96cb4224
commit 5ca705c88c
8 changed files with 180 additions and 16 deletions
+34 -10
View File
@@ -68,17 +68,40 @@ func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
return InstanceForHost(c.Request.Host)
}
// instanceBySlug is a variable so tests can stub the Mongo read.
var instanceBySlug = services.GetInstanceBySlugIncludingLocked
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
//
// An instance Vantage HQ has locked under a dispute resolves to nothing, so
// every caller refuses it without knowing locks exist. HostLocked is the one
// place that may tell the two apart, for the login page's message.
func InstanceForHost(host string) (*models.Instance, bool) {
slug := hostSlug(host)
if slug == "" {
inst := slugInstance(hostSlug(host))
if inst == nil || inst.LockedAt != nil {
return nil, false
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
return inst, true
}
inst, err := services.GetInstanceBySlug(slug)
// HostLocked reports whether the host names an instance Vantage HQ has locked.
// It shares InstanceForHost's cache, so a lock and an unlock are seen within
// the same 60 seconds by both.
func HostLocked(host string) bool {
inst := slugInstance(hostSlug(host))
return inst != nil && inst.LockedAt != nil
}
// slugInstance reads the instance a slug names, locked or not, through the
// cache.
func slugInstance(slug string) *models.Instance {
if slug == "" {
return nil
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst
}
inst, err := instanceBySlug(slug)
if err != nil || inst == nil {
// Negative entries are cached too. Without them an unknown but
// well-formed host costs a Mongo query per anonymous request, which
@@ -86,10 +109,10 @@ func InstanceForHost(host string) (*models.Instance, bool) {
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
return nil, false
return nil
}
storeInstance(slug, inst)
return inst, true
return inst
}
// SoleInstance resolves the one instance of a deployment that has exactly one.
@@ -99,7 +122,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
// more than one instance exists.
func SoleInstance() (*models.Instance, bool) {
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
return inst, inst != nil
return inst, inst != nil && inst.LockedAt == nil
}
n, err := services.CountInstances()
if err != nil || n != 1 {
@@ -112,7 +135,8 @@ func SoleInstance() (*models.Instance, bool) {
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
// A locked sole instance is refused like any other locked instance.
return inst, inst.LockedAt == nil
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
@@ -0,0 +1,56 @@
package auth
import (
"errors"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// stubSlugs replaces the slug lookup and empties the host cache for one test.
func stubSlugs(t *testing.T, bySlug map[string]*models.Instance) {
t.Helper()
prev := instanceBySlug
instanceBySlug = func(slug string) (*models.Instance, error) {
if inst, ok := bySlug[slug]; ok {
return inst, nil
}
return nil, errors.New("not found")
}
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
t.Cleanup(func() {
instanceBySlug = prev
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
})
}
func TestLockedHostResolvesToNothingButReportsLocked(t *testing.T) {
at := time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC)
stubSlugs(t, map[string]*models.Instance{
"acme": {InstanceID: "i-acme", Slug: "acme", LockedAt: &at},
"open": {InstanceID: "i-open", Slug: "open"},
})
if _, ok := InstanceForHost("acme.vantage.example.com"); ok {
t.Fatal("a locked instance must not resolve")
}
if !HostLocked("acme.vantage.example.com") {
t.Fatal("a locked instance's host must report locked")
}
if inst, ok := InstanceForHost("open.vantage.example.com"); !ok || inst.InstanceID != "i-open" {
t.Fatalf("unlocked instance: got %v, %v", inst, ok)
}
if HostLocked("open.vantage.example.com") {
t.Fatal("an unlocked instance is not locked")
}
if HostLocked("missing.vantage.example.com") || HostLocked("example.com") {
t.Fatal("a host naming no instance is not locked")
}
}
+36
View File
@@ -1,6 +1,7 @@
package auth
import (
"errors"
"fmt"
"net/http"
@@ -31,10 +32,18 @@ func SetSessionCookie(c *gin.Context, sessionID string) {
//
// Anything else is refused rather than guessed. Picking an instance on someone's
// behalf is how you sign them into the wrong tenant.
//
// An instance Vantage HQ has locked answers ErrInstanceLocked on both paths.
// Without the check on the fallback, a single-instance deployment whose host
// resolver hides the locked instance fell through to "the sole instance" and
// served its sign-in page anyway.
func resolveLoginInstance(c *gin.Context) (string, error) {
if inst, ok := InstanceFromHost(c); ok {
return inst.InstanceID, nil
}
if HostLocked(c.Request.Host) {
return "", ErrInstanceLocked
}
n, err := services.CountInstances()
if err != nil {
return "", err
@@ -48,9 +57,22 @@ func resolveLoginInstance(c *gin.Context) (string, error) {
if err != nil {
return "", err
}
if inst.LockedAt != nil {
return "", ErrInstanceLocked
}
return inst.InstanceID, nil
}
// ErrInstanceLocked is a sign-in to an instance Vantage HQ has locked under an
// account dispute. The login page shows it as a message in place of the form.
var ErrInstanceLocked = errors.New("access to this instance is suspended")
// loginLocked reports whether this request's sign-in target is locked.
func loginLocked(c *gin.Context) bool {
_, err := resolveLoginInstance(c)
return errors.Is(err, ErrInstanceLocked)
}
func HandleLocalLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
@@ -61,6 +83,10 @@ func HandleLocalLogin(c *gin.Context) {
return
}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -101,6 +127,10 @@ func HandleListPublicProviders(c *gin.Context) {
out := []publicProvider{}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusOK, gin.H{"local_enabled": false, "providers": out, "locked": true})
return
}
if err != nil {
// An unresolvable instance is not an error the login page can act on:
// it still has to render a password form. Answer the safe shape.
@@ -127,6 +157,12 @@ func HandleBootstrapStatus(c *gin.Context) {
err error
instName string
)
// Checked first: a locked instance must not fall through to the global
// user count, which answers needs_setup false and draws the sign-in form.
if loginLocked(c) {
c.JSON(http.StatusOK, gin.H{"needs_setup": false, "locked": true})
return
}
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
+4
View File
@@ -88,6 +88,10 @@ func loadProvider(instanceID, providerID string) (*models.AuthProvider, string,
func HandleSSOStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
if HostLocked(c.Request.Host) {
c.Redirect(http.StatusFound, "/login?error=instance_locked")
return
}
c.Redirect(http.StatusFound, "/login?error=unknown_host")
return
}
+15
View File
@@ -35,6 +35,21 @@ func GetInstanceBySlug(slug string) (*models.Instance, error) {
return &o, nil
}
// GetInstanceBySlugIncludingLocked is GetInstanceBySlug without the lock
// filter. Only the host resolver calls it, so it can tell "no such instance"
// from "instance locked by Vantage HQ" and let the login page say which; it
// still refuses a locked instance itself.
func GetInstanceBySlugIncludingLocked(slug string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func ListInstanceIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()