go-quasar-partial-ssr/backend/internal/http/controllers/authorization.go

224 lines
5.9 KiB
Go

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()
}
}