Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -4,14 +4,19 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// RequireAdmin returns middleware that restricts access to admin users.
|
||||
// Must be used after Auth() middleware which sets "role" in context.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
// RequireAdmin returns middleware that restricts access to users with the
|
||||
// surface.admin.access permission (i.e. members of the Admins group).
|
||||
// Must be used after Auth() middleware which sets "user_id" in context.
|
||||
func RequireAdmin(stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "admin access required",
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/config"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/store"
|
||||
@@ -60,37 +61,34 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve user role — redirect (not JSON) on any failure.
|
||||
var role string
|
||||
// Check user is active — redirect (not JSON) on failure.
|
||||
if entry, hit := cache.get(claims.UserID); hit {
|
||||
if !entry.isActive {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
}
|
||||
role = entry.role
|
||||
} else {
|
||||
user, err := users.GetByID(c.Request.Context(), claims.UserID)
|
||||
if err != nil || !user.IsActive {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
}
|
||||
cache.set(claims.UserID, user.IsActive, user.Role)
|
||||
role = user.Role
|
||||
cache.set(claims.UserID, user.IsActive)
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminPage aborts with 403 if the user isn't an admin.
|
||||
// RequireAdminPage aborts with 403 if the user lacks surface.admin.access.
|
||||
// Use after AuthOrRedirect for admin-only page routes.
|
||||
func RequireAdminPage() gin.HandlerFunc {
|
||||
func RequireAdminPage(stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
|
||||
c.String(http.StatusForbidden, "Admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -12,17 +12,11 @@ import (
|
||||
const permCacheKey = "resolved_permissions"
|
||||
|
||||
// RequirePermission returns middleware that enforces a named permission.
|
||||
// Admin users bypass the check entirely. Permission resolution is cached in
|
||||
// the request context — computed at most once per request regardless of how
|
||||
// many RequirePermission middlewares are chained.
|
||||
// Permission resolution is cached in the request context — computed at most
|
||||
// once per request regardless of how many RequirePermission middlewares are
|
||||
// chained. Admins receive permissions through the Admins group.
|
||||
func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role == "admin" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[perm] {
|
||||
|
||||
Reference in New Issue
Block a user