package controllers import ( "encoding/json" "errors" "fmt" "os" "strings" "github.com/gofiber/fiber/v3" "gorm.io/gorm" "server/internal/auth" "server/internal/models" ) type RoleConfig struct { Roles map[string][]string `json:"roles"` Permissions map[string][]string `json:"permissions"` Endpoints map[string]string `json:"endpoints"` } type RoleResolver struct { roleClosure map[string]map[string]struct{} permMap map[string]map[string]struct{} endpointPerm map[string]string } func LoadRoleConfig(path string) (*RoleResolver, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read role config: %w", err) } var cfg RoleConfig if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse role config: %w", err) } res := &RoleResolver{ roleClosure: make(map[string]map[string]struct{}), permMap: make(map[string]map[string]struct{}), endpointPerm: make(map[string]string), } for role := range cfg.Roles { res.roleClosure[role] = make(map[string]struct{}) } // Compute role closure (role implies itself). var dfs func(string, map[string]struct{}) dfs = func(role string, seen map[string]struct{}) { if _, ok := seen[role]; ok { return } seen[role] = struct{}{} if implied, ok := cfg.Roles[role]; ok { for _, r := range implied { dfs(r, seen) } } } for role := range cfg.Roles { set := make(map[string]struct{}) set[role] = struct{}{} dfs(role, set) res.roleClosure[role] = set } // Build permission map including inherited permissions. for role := range cfg.Roles { res.permMap[role] = make(map[string]struct{}) } for role := range cfg.Roles { closure := res.roleClosure[role] for implied := range closure { for _, p := range cfg.Permissions[implied] { res.permMap[role][p] = struct{}{} } } } // Normalise endpoints to "METHOD /path". for key, perm := range cfg.Endpoints { parts := strings.SplitN(key, " ", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid endpoint key %q", key) } method := strings.TrimSpace(strings.ToUpper(parts[0])) path := strings.TrimSpace(parts[1]) res.endpointPerm[method+" "+path] = perm } return res, nil } func (r *RoleResolver) HasRole(userRoles []string, required string) bool { for _, ur := range userRoles { if closure, ok := r.roleClosure[ur]; ok { if _, present := closure[required]; present { return true } } } return false } func (r *RoleResolver) HasPermission(userRoles []string, perm string) bool { for _, ur := range userRoles { if perms, ok := r.permMap[ur]; ok { if _, present := perms[perm]; present { return true } } } return false } func (r *RoleResolver) PermissionForEndpoint(method, path string) (string, bool) { key := strings.ToUpper(method) + " " + path perm, ok := r.endpointPerm[key] return perm, ok } func (r *RoleResolver) RoleDefined(role string) bool { _, ok := r.roleClosure[role] return ok } // RequireRole ensures the authenticated user has the specified role (with inheritance). func RequireRole(resolver *RoleResolver, role string) fiber.Handler { return func(c fiber.Ctx) error { claims, ok := auth.ClaimsFromCtx(c) if !ok { return fiber.NewError(fiber.StatusUnauthorized, "missing claims") } db, err := dbFromCtx(c) if err != nil { return err } var user models.User if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusUnauthorized, "user not found") } return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles") } if !resolver.HasRole(user.Roles, role) { return fiber.NewError(fiber.StatusForbidden, "insufficient permissions") } return c.Next() } } // RequirePermission ensures the authenticated user has the given permission. func RequirePermission(resolver *RoleResolver, perm string) fiber.Handler { return func(c fiber.Ctx) error { claims, ok := auth.ClaimsFromCtx(c) if !ok { return fiber.NewError(fiber.StatusUnauthorized, "missing claims") } db, err := dbFromCtx(c) if err != nil { return err } var user models.User if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusUnauthorized, "user not found") } return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles") } if !resolver.HasPermission(user.Roles, perm) { return fiber.NewError(fiber.StatusForbidden, "insufficient permissions") } return c.Next() } } // RequireEndpointPermission enforces permission mapping defined in role config. // If the endpoint is not configured, or mapped to "*", it allows the request. func RequireEndpointPermission(resolver *RoleResolver, authService *auth.Service) fiber.Handler { return func(c fiber.Ctx) error { perm, ok := resolver.PermissionForEndpoint(c.Method(), c.Path()) if !ok || perm == "*" { return c.Next() } tokenString := c.Get("Auth-Token") if tokenString == "" { return fiber.NewError(fiber.StatusUnauthorized, "missing token header") } claims, err := authService.ValidateAccessToken(tokenString) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) } c.Locals("authClaims", claims) db, err := dbFromCtx(c) if err != nil { return err } var user models.User if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusUnauthorized, "user not found") } return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles") } if !resolver.HasPermission(user.Roles, perm) { return fiber.NewError(fiber.StatusForbidden, "insufficient permissions") } return c.Next() } }