refactor(server): rename Org to Instance

Adds migration 0004_org_to_instance, the ScopedCollections list, the
AssertNoScopedCollectionMissed boot check, and moves EnsureAuthIndexes into
its own file.

Two ordering constraints the rename exposed, both now enforced and commented:

- 0004 must run BEFORE EnsureAuthIndexes. The index builder creates
  instances.slug, which would create an empty instances collection and make
  0004 refuse to rename orgs onto an existing target.
- Migrations 0001 to 0003 run BEFORE 0004 and still read and write org_id, so
  they use a private legacyOrg struct rather than shared/models.
This commit is contained in:
2026-07-24 13:58:41 +01:00
parent 43a2fdb3a0
commit 4f041d2f4b
61 changed files with 895 additions and 967 deletions
@@ -12,7 +12,7 @@ import (
)
type cachedOrg struct {
org *models.Org
org *models.Instance
at time.Time
}
@@ -23,9 +23,6 @@ var (
const orgCacheTTL = 60 * time.Second
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
@@ -33,15 +30,13 @@ func appRootLabel() string {
return "vantage"
}
func hostSlug(host string) string {
host = strings.ToLower(host)
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
root := appRootLabel()
parts := strings.Split(host, ".")
if len(parts) < 3 {
return ""
@@ -55,7 +50,7 @@ func hostSlug(host string) string {
return parts[0]
}
func OrgFromHost(c *gin.Context) (*models.Org, bool) {
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
if slug == "" {
return nil, false
@@ -67,10 +62,9 @@ func OrgFromHost(c *gin.Context) (*models.Org, bool) {
}
orgCacheMu.Unlock()
org, err := services.GetOrgBySlug(slug)
org, err := services.GetInstanceBySlug(slug)
if err != nil || org == nil {
return nil, false
}
orgCacheMu.Lock()
+27 -32
View File
@@ -37,7 +37,7 @@ func HandleLocalLogin(c *gin.Context) {
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
@@ -50,13 +50,13 @@ func HandleLocalLogin(c *gin.Context) {
func HandleBootstrapStatus(c *gin.Context) {
var (
n int64
err error
orgName string
n int64
err error
instName string
)
if org, ok := OrgFromHost(c); ok {
n, err = services.CountOrgUsers(org.OrgID)
orgName = org.Name
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
} else {
n, err = services.CountUsers()
}
@@ -64,12 +64,9 @@ func HandleBootstrapStatus(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "org_name": orgName})
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName})
}
func HandleBootstrap(c *gin.Context) {
n, err := services.CountUsers()
if err != nil {
@@ -81,34 +78,34 @@ func HandleBootstrap(c *gin.Context) {
return
}
var body struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
InstanceName string `json:"instance_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 {
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"})
return
}
orgCount, err := services.CountOrgs()
orgCount, err := services.CountInstances()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var org *models.Org
var inst *models.Instance
switch orgCount {
case 0:
org, err = services.CreateOrg(body.OrgName)
inst, err = services.CreateInstance(body.InstanceName)
case 1:
var existing *models.Org
existing, err = services.FirstOrg()
var existing *models.Instance
existing, err = services.FirstInstance()
if err == nil {
org, err = services.AdoptOrg(existing.OrgID, body.OrgName)
inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName)
}
default:
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
"cannot bootstrap: %d organizations already exist but no users do; "+
"create the owner against the intended org rather than through setup, "+
"create the owner against the intended inst rather than through setup, "+
"or remove the unintended orgs and retry", orgCount)})
return
}
@@ -116,20 +113,20 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local")
u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug})
c.JSON(http.StatusCreated, gin.H{"instance": inst, "slug": inst.Slug})
}
func HandleMe(c *gin.Context) {
@@ -143,14 +140,12 @@ func HandleMe(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
org, _ := services.GetOrg(sess.OrgID)
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
inst, _ := services.GetInstance(sess.InstanceID)
c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst})
}
+5 -5
View File
@@ -14,9 +14,9 @@ func GetSessionFromContext(c *gin.Context) *Session {
return sess
}
func OrgID(c *gin.Context) string {
func InstanceID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.OrgID
return s.InstanceID
}
return ""
}
@@ -62,15 +62,15 @@ func Middleware() gin.HandlerFunc {
return
}
if sess.OrgID == "" {
if sess.InstanceID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
return
}
c.Set(ctxSessionKey, sess)
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
+17 -17
View File
@@ -18,9 +18,9 @@ var (
provCache = map[string]*oidc.Provider{}
)
func EvictOIDCProvider(orgID string) {
func EvictOIDCProvider(instanceID string) {
provMu.Lock()
delete(provCache, orgID)
delete(provCache, instanceID)
provMu.Unlock()
}
@@ -32,17 +32,17 @@ func redirectURL(c *gin.Context) string {
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
}
func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetOrgOIDC(orgID)
func providerForOrg(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetInstanceOIDC(instanceID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("org SSO not configured")
return nil, nil, fmt.Errorf("inst SSO not configured")
}
secret, err := services.GetOrgOIDCSecret(orgID)
secret, err := services.GetInstanceOIDCSecret(instanceID)
if err != nil {
return nil, nil, err
}
provMu.Lock()
p := provCache[orgID]
p := provCache[instanceID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
@@ -50,7 +50,7 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
return nil, nil, err
}
provMu.Lock()
provCache[orgID] = p
provCache[instanceID] = p
provMu.Unlock()
}
return p, &oauth2.Config{
@@ -61,13 +61,13 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
}
func HandleOIDCStart(c *gin.Context) {
org, ok := OrgFromHost(c)
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
return
}
ctx := c.Request.Context()
_, oauthCfg, err := providerForOrg(ctx, c, org.OrgID)
_, oauthCfg, err := providerForOrg(ctx, c, inst.InstanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -77,7 +77,7 @@ func HandleOIDCStart(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
return
}
if err := SaveStateOrg(ctx, state, org.OrgID); err != nil {
if err := SaveStateOrg(ctx, state, inst.InstanceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
@@ -86,12 +86,12 @@ func HandleOIDCStart(c *gin.Context) {
func HandleOIDCCallback(c *gin.Context) {
ctx := c.Request.Context()
orgID, ok := ConsumeStateOrg(ctx, c.Query("state"))
instanceID, ok := ConsumeStateOrg(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
provider, oauthCfg, err := providerForOrg(ctx, c, orgID)
provider, oauthCfg, err := providerForOrg(ctx, c, instanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -123,19 +123,19 @@ func HandleOIDCCallback(c *gin.Context) {
email := strings.ToLower(claims.Email)
u, err := services.GetUserByEmail(email)
if err != nil {
u, err = services.CreateUser(orgID, email, "", "member", "oidc")
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.OrgID != orgID {
} else if u.InstanceID != instanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
return
}
sessionID, err := SaveSession(ctx, &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
+10 -10
View File
@@ -16,11 +16,11 @@ const sessionPrefix = "km:session:"
const statePrefix = "km:state:"
type Session struct {
UserID string `json:"user_id"`
OrgID string `json:"org_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
var rdb *redis.Client
@@ -71,14 +71,14 @@ func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveStateOrg(ctx context.Context, state, orgID string) error {
return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err()
func SaveStateOrg(ctx context.Context, state, instanceID string) error {
return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err()
}
func ConsumeStateOrg(ctx context.Context, state string) (string, bool) {
orgID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || orgID == "" {
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || instanceID == "" {
return "", false
}
return orgID, true
return instanceID, true
}