claudecodeui/workspace/backend/internal/auth/host_access.go

75 lines
1.8 KiB
Go
Raw Normal View History

package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"dragonrelay/internal/db"
)
// LevelRank returns a numeric rank for a host-access level so that levels can
// be compared ordinally. Unknown strings map to 0, which is below every named
// level.
func LevelRank(level string) int {
switch level {
case "view":
return 1
case "connect":
return 2
case "manage":
return 3
default:
return 0
}
}
// RequireHostAccess returns a Gin middleware that enforces per-host ACL checks.
// The host IP is read from the ":ip" route parameter.
//
// Resolution order:
// 1. No claims in context → 401 Unauthorized
// 2. Role "admin" → always allowed (bypass ACL)
// 3. Role "viewer" AND minLevel
// is higher than "view" → 403 Forbidden (viewers are capped)
// 4. ACL lookup for (user, host) → 403 if not found or rank too low
func RequireHostAccess(store *db.Store, minLevel string) gin.HandlerFunc {
return func(c *gin.Context) {
claims := GetClaims(c)
if claims == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// Admins bypass all per-host ACL rules.
if claims.Role == "admin" {
c.Next()
return
}
// Viewers are hard-capped at the "view" level; reject any request
// that requires a higher privilege.
if claims.Role == "viewer" && LevelRank(minLevel) > LevelRank("view") {
c.AbortWithStatus(http.StatusForbidden)
return
}
// Look up the requesting user.
user, err := store.GetUserByUsername(claims.Username)
if err != nil || user == nil {
c.AbortWithStatus(http.StatusForbidden)
return
}
// Check the host ACL entry.
hostIP := c.Param("ip")
level, ok, err := store.GetHostAccess(user.ID, hostIP)
if err != nil || !ok || LevelRank(level) < LevelRank(minLevel) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}