drop users.role column: full RBAC through group membership
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s

The role column was a pre-RBAC artifact. All authorization now flows
through explicit group membership and permission grants:

- Everyone group: all users added on creation (no implicit membership)
- Admins group: grants surface.admin.access + all platform permissions
- JWT claims, login response, profile: role field removed
- OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes)
- Admin UI: role dropdown removed, admin managed through groups
- Middleware cache simplified to isActive only

28 files changed, -79 lines net. Zero magic roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:23:12 +00:00
parent e8e45184f7
commit b10f5bee05
28 changed files with 206 additions and 285 deletions

View File

@@ -20,19 +20,17 @@ import (
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// ─── User Status Cache ──────────────────────────────────────
// Short-TTL cache so deactivation and role changes take effect
// within seconds, without a DB query on every single request.
// Short-TTL cache so deactivation takes effect within seconds,
// without a DB query on every single request.
const userCacheTTL = 30 * time.Second
type userCacheEntry struct {
isActive bool
role string
fetchedAt time.Time
}
@@ -64,9 +62,9 @@ func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) {
return e, true
}
func (c *UserStatusCache) set(userID string, active bool, role string) {
func (c *UserStatusCache) set(userID string, active bool) {
c.mu.Lock()
c.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()}
c.entries[userID] = userCacheEntry{isActive: active, fetchedAt: time.Now()}
c.mu.Unlock()
}
@@ -90,44 +88,43 @@ func (c *UserStatusCache) evictExpired() {
// ─── Shared user verification ───────────────────────────────
// verifyUser checks is_active and resolves the current DB role.
// Returns (role, nil) on success or ("", error-already-sent) on failure.
// Callers must abort the request on error.
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) {
// verifyUser checks that the user exists and is active.
// Returns true on success or false (with error already sent) on failure.
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool {
userID := claims.UserID
if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
return "", false
return false
}
return verifyUserByID(c, userID, users, cache)
}
// verifyUserByID checks is_active and resolves the current DB role for a user ID.
// verifyUserByID checks is_active for a user ID.
// Used by both JWT auth (via verifyUser) and ticket auth.
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) (string, bool) {
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) bool {
// Cache hit
if entry, ok := cache.get(userID); ok {
if !entry.isActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return "", false
return false
}
return entry.role, true
return true
}
// Cache miss → DB lookup
user, err := users.GetByID(c.Request.Context(), userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return "", false
return false
}
cache.set(userID, user.IsActive, user.Role)
cache.set(userID, user.IsActive)
if !user.IsActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return "", false
return false
}
return user.Role, true
return true
}
// parseAndValidateJWT parses a raw JWT string and returns claims if valid.
@@ -188,15 +185,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
return
}
// Verify user is active and resolve current role from DB
role, ok := verifyUser(c, claims, users, cache)
if !ok {
return // verifyUser already sent the error response
// Verify user is active
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", role) // from DB, not JWT claims
c.Next()
}
}
@@ -304,14 +299,11 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return
}
// Ticket is valid — resolve role from DB (same as JWT path)
role, ok := verifyUserByID(c, userID, users, cache)
if !ok {
if !verifyUserByID(c, userID, users, cache) {
return
}
c.Set("user_id", userID)
c.Set("role", role)
c.Set("ws_auth", "ticket")
c.Next()
return
@@ -329,14 +321,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return
}
role, ok := verifyUser(c, claims, users, cache)
if !ok {
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "token_legacy")
c.Next()
return
@@ -367,14 +357,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return
}
role, ok := verifyUser(c, claims, users, cache)
if !ok {
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "bearer")
c.Next()
}