fix: validate roles, guard last owner, scope bootstrap status
Security review of e70b2f0. The UI gating was correctly backed by
RequireRole everywhere; these are the missing validation gaps behind it.
- UpdateUserRole and createOrgUser accepted any role string verbatim, so
an admin could self-promote to owner, create an owner outright, or set
a junk role that silently stripped a user's access. Roles are now
whitelisted, only an owner may grant or remove the owner role, and an
actor cannot change their own.
- Neither demote nor delete guarded the last owner, so an org could reach
zero owners. Both now refuse when no owner would remain, returning 409.
Self-delete rejected.
- CountUsers counted across all orgs, so a locked-out org could never
re-bootstrap once another tenant existed, and the unauthenticated
bootstrap-status endpoint reported instance-wide state. It now answers
per-org on an org host, falling back to global only on the apex.
- HandleMe repeats the middleware's host/org check; it sits outside the
middleware so it can still return its own 401.
- Post-bootstrap now sends the new owner to their org host's login page.
The session cookie is deliberately scoped to the exact host, so the old
redirect landed them unauthenticated.
- AuthProvider renders an error state instead of mounting the shell with
a null user when /auth/me fails for a reason other than 401.
- api.ts unwraps {"error": ...} so these messages render as text.
This commit is contained in:
@@ -46,8 +46,20 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleBootstrapStatus answers "does the caller need to run setup". It is
|
||||
// unauthenticated, so it must not report instance-wide state to whoever asks:
|
||||
// on an org host the answer is scoped to that org, and only the apex — the
|
||||
// genuine first-run entry point — gets the global "no users anywhere" answer.
|
||||
func HandleBootstrapStatus(c *gin.Context) {
|
||||
n, err := services.CountUsers()
|
||||
var (
|
||||
n int64
|
||||
err error
|
||||
)
|
||||
if org, ok := OrgFromHost(c); ok {
|
||||
n, err = services.CountOrgUsers(org.OrgID)
|
||||
} else {
|
||||
n, err = services.CountUsers()
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -55,6 +67,9 @@ func HandleBootstrapStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0})
|
||||
}
|
||||
|
||||
// HandleBootstrap creates the very first org and its owner, so its guard stays
|
||||
// deliberately global: it may run once on an empty instance and never again,
|
||||
// regardless of which host it is called on.
|
||||
func HandleBootstrap(c *gin.Context) {
|
||||
n, err := services.CountUsers()
|
||||
if err != nil {
|
||||
@@ -106,6 +121,14 @@ func HandleMe(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
// /auth/me is registered outside Middleware, so it repeats the middleware's
|
||||
// host/org match itself. Without this, a session for org A presented on org
|
||||
// B's host would render the shell while every /api call 403s.
|
||||
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
org, _ := services.GetOrg(sess.OrgID)
|
||||
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user