feat: implement user profile retrieval with roles and permissions caching

This commit is contained in:
Tran Anh Tuan
2026-05-12 14:36:50 +07:00
parent e81a248a61
commit 902caa222f
17 changed files with 671 additions and 19 deletions

View File

@@ -1,10 +1,54 @@
package middlewares
import "github.com/gin-gonic/gin"
import (
"net/http"
"strings"
"wm-backend/pkg/helper"
"wm-backend/response"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. Get the Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.UnauthorizedError(c, http.StatusUnauthorized, "Missing Authorization header")
c.Abort()
return
}
// 2. Extract Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
response.UnauthorizedError(c, http.StatusUnauthorized, "Invalid Authorization header format")
c.Abort()
return
}
tokenString := parts[1]
// 3. Parse JWT token
claims, err := helper.ParseToken(tokenString)
if err != nil {
log.Error().Err(err).Msg("Failed to parse JWT token")
response.UnauthorizedError(c, http.StatusUnauthorized, "Invalid or expired token")
c.Abort()
return
}
// 4. Extract user_id from claims
userID, ok := claims["user_id"].(string)
if !ok || userID == "" {
response.UnauthorizedError(c, http.StatusUnauthorized, "Invalid token: missing user_id")
c.Abort()
return
}
// 5. Set user_id in gin context for downstream handlers
c.Set("user_id", userID)
c.Next()
}
}

View File

@@ -16,8 +16,8 @@ type AdminConfig struct {
}
type JWTConfig struct {
SecretKey string
ExpirationHours int
SecretKey string `mapstructure:"secretkey"`
ExpirationHours int `mapstructure:"expirehours"`
}
type DatabaseConfig struct {

View File

@@ -0,0 +1,7 @@
package models
// Permission represents a system permission (e.g., "read:warehouse", "write:component")
type Permission struct {
Name string `json:"name"`
Description string `json:"description"`
}

View File

@@ -3,6 +3,25 @@ package responses
type BodyRegisterResponse struct {
ID string `json:"id"`
}
type BodyLoginResponse struct {
Token string `json:"token"`
}
// RoleItem represents a role assigned to the user in the profile response.
type RoleItem struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// BodyProfileResponse is the response body for GET /profile.
type BodyProfileResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"fullName"`
IsActive bool `json:"isActive"`
Roles []RoleItem `json:"roles"`
Permissions []string `json:"permissions"`
}

View File

@@ -7,6 +7,7 @@ import (
"wm-backend/internal/models"
db "wm-backend/sqlc_gen"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
@@ -43,3 +44,20 @@ func CreateUser(ctx context.Context, queries *db.Queries, params db.CreateUserPa
}
return id.String(), nil
}
// GetUserByID retrieves a user by their ID using SQLC-generated queries.
// Returns nil, nil if no user is found.
func GetUserByID(ctx context.Context, queries *db.Queries, id string) (*models.User, error) {
uid, err := uuid.Parse(id)
if err != nil {
return nil, err
}
user, err := queries.GetUserByID(ctx, uid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return mapper.ToDomainUser(user), nil
}

View File

@@ -0,0 +1,29 @@
package repositories
import (
"context"
"wm-backend/internal/models"
db "wm-backend/sqlc_gen"
"github.com/google/uuid"
)
// GetPermissionsByUserID retrieves all permission names for a given user.
func GetPermissionsByUserID(ctx context.Context, queries *db.Queries, userID string) ([]models.Permission, error) {
uid, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
rows, err := queries.GetPermissionsByUserID(ctx, uid)
if err != nil {
return nil, err
}
permissions := make([]models.Permission, 0, len(rows))
for _, row := range rows {
permissions = append(permissions, models.Permission{
Name: row.Name,
Description: row.Description.String,
})
}
return permissions, nil
}

View File

@@ -1 +1,79 @@
package redis
import (
"context"
"encoding/json"
"fmt"
"time"
"wm-backend/global"
"github.com/rs/zerolog/log"
)
const (
userRBACPrefix = "user:rbac:"
defaultTTL = 60 * time.Minute
)
// RBACCachedData represents the cached roles and permissions for a user.
type RBACCachedData struct {
Roles []RoleCacheItem `json:"roles"`
Permissions []string `json:"permissions"`
}
// RoleCacheItem represents a cached role item.
type RoleCacheItem struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func buildKey(userID string) string {
return fmt.Sprintf("%s%s", userRBACPrefix, userID)
}
// CacheUserPermissions stores the user's roles and permissions in Redis with TTL.
func CacheUserPermissions(ctx context.Context, userID string, data RBACCachedData, ttl time.Duration) error {
jsonData, err := json.Marshal(data)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to marshal RBAC data for caching")
return err
}
if err := global.Cache.Set(ctx, buildKey(userID), jsonData, ttl).Err(); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to cache user permissions in Redis")
return err
}
return nil
}
// GetCachedUserPermissions retrieves the cached RBAC data from Redis.
// Returns (nil, false) if not found or on error (graceful fallback).
func GetCachedUserPermissions(ctx context.Context, userID string) (*RBACCachedData, bool) {
val, err := global.Cache.Get(ctx, buildKey(userID)).Result()
if err != nil {
// Key doesn't exist or Redis error; silently fallback to DB
return nil, false
}
var data RBACCachedData
if err := json.Unmarshal([]byte(val), &data); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to unmarshal cached RBAC data")
return nil, false
}
return &data, true
}
// RefreshTTL resets the TTL on a cached RBAC entry when a cache hit occurs.
func RefreshTTL(ctx context.Context, userID string, ttl time.Duration) {
if err := global.Cache.Expire(ctx, buildKey(userID), ttl).Err(); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to refresh TTL for RBAC cache")
}
}
// DeleteUserPermissionsCache removes the cached RBAC data for a user (for cache invalidation).
func DeleteUserPermissionsCache(ctx context.Context, userID string) {
if err := global.Cache.Del(ctx, buildKey(userID)).Err(); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to delete RBAC cache")
}
}

View File

@@ -5,6 +5,8 @@ import (
"wm-backend/internal/mapper"
"wm-backend/internal/models"
db "wm-backend/sqlc_gen"
"github.com/google/uuid"
)
func CreateRole(ctx context.Context, queries *db.Queries, body models.Role) (models.Role, error) {
@@ -14,3 +16,24 @@ func CreateRole(ctx context.Context, queries *db.Queries, body models.Role) (mod
}
return *mapper.ToDomainRole(role), nil
}
// GetUserRolesByUserID retrieves all roles assigned to a user with role info.
func GetUserRolesByUserID(ctx context.Context, queries *db.Queries, userID string) ([]models.Role, error) {
uid, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
rows, err := queries.GetUserRolesByUserID(ctx, uid)
if err != nil {
return nil, err
}
roles := make([]models.Role, 0, len(rows))
for _, row := range rows {
roles = append(roles, models.Role{
ID: row.RoleID.String(),
Name: row.RoleName,
Description: row.RoleDescription.String,
})
}
return roles, nil
}

View File

@@ -29,6 +29,13 @@ func NewRouter() *gin.Engine {
auth.POST(constants.API_PATH_AUTH_LOGIN, utils.AsyncHandler(services.Login))
}
// Protected routes (require JWT authentication)
protected := v1.Group("")
protected.Use(middlewares.AuthMiddleware())
{
protected.GET(constants.API_PATH_PROFILE, utils.AsyncHandler(services.GetProfile))
}
warehouse := v1.Group(constants.API_GROUP_WAREHOUSE)
{
warehouse.GET("", utils.AsyncHandler(services.WareHouseList))

View File

@@ -1,12 +1,15 @@
package services
import (
"context"
"net/http"
"time"
"wm-backend/global"
"wm-backend/internal/models"
"wm-backend/internal/models/requests"
"wm-backend/internal/models/responses"
"wm-backend/internal/repositories"
redisRepo "wm-backend/internal/repositories/redis"
"wm-backend/pkg/helper"
"wm-backend/response"
db "wm-backend/sqlc_gen"
@@ -120,9 +123,51 @@ func Login(c *gin.Context) error {
return nil
}
// 5. Return token
// 5. Cache roles & permissions in Redis
go cacheUserRBAC(user.ID)
// 6. Return token
response.Ok(c, "Login successful", responses.BodyLoginResponse{
Token: token,
})
return nil
}
// cacheUserRBAC fetches roles and permissions for a user and stores them in Redis.
func cacheUserRBAC(userID string) {
ctx := context.Background()
roles, err := repositories.GetUserRolesByUserID(ctx, global.Queries, userID)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to fetch roles for caching")
return
}
permissions, err := repositories.GetPermissionsByUserID(ctx, global.Queries, userID)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to fetch permissions for caching")
return
}
roleItems := make([]redisRepo.RoleCacheItem, 0, len(roles))
permNames := make([]string, 0, len(permissions))
for _, r := range roles {
roleItems = append(roleItems, redisRepo.RoleCacheItem{
ID: r.ID,
Name: r.Name,
Description: r.Description,
})
}
for _, p := range permissions {
permNames = append(permNames, p.Name)
}
data := redisRepo.RBACCachedData{
Roles: roleItems,
Permissions: permNames,
}
if err := redisRepo.CacheUserPermissions(ctx, userID, data, 60*time.Minute); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to cache RBAC data")
}
}

View File

@@ -0,0 +1,121 @@
package services
import (
"context"
"net/http"
"time"
"wm-backend/global"
"wm-backend/internal/models/responses"
"wm-backend/internal/repositories"
redisRepo "wm-backend/internal/repositories/redis"
"wm-backend/response"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
// GetProfile returns the authenticated user's profile including roles and permissions.
// It first attempts to read from Redis cache; on miss it queries the database.
//
// @Summary Get current user profile
// @Description Returns user info with roles and permissions (requires auth)
// @Tags auth
// @Security BearerAuth
// @Produce json
// @Success 200 {object} response.SuccessResponse{data=responses.BodyProfileResponse}
// @Failure 401 {object} response.ErrorResponse
// @Router /profile [get]
func GetProfile(c *gin.Context) error {
userID := c.GetString("user_id")
ctx := c.Request.Context()
var roles []responses.RoleItem
var permissions []string
// 1. Try Redis cache first (graceful fallback on error)
cachedData, found := redisRepo.GetCachedUserPermissions(ctx, userID)
if found {
// Cache hit - refresh TTL
redisRepo.RefreshTTL(ctx, userID, 60*time.Minute)
roles = make([]responses.RoleItem, 0, len(cachedData.Roles))
for _, r := range cachedData.Roles {
roles = append(roles, responses.RoleItem{
ID: r.ID,
Name: r.Name,
Description: r.Description,
})
}
permissions = cachedData.Permissions
} else {
// 2. Cache miss - fetch from DB
dbRoles, err := repositories.GetUserRolesByUserID(ctx, global.Queries, userID)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to fetch roles from DB")
response.InternalServerError(c, http.StatusInternalServerError)
return nil
}
roles = make([]responses.RoleItem, 0, len(dbRoles))
for _, r := range dbRoles {
roles = append(roles, responses.RoleItem{
ID: r.ID,
Name: r.Name,
Description: r.Description,
})
}
dbPerms, err := repositories.GetPermissionsByUserID(ctx, global.Queries, userID)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to fetch permissions from DB")
response.InternalServerError(c, http.StatusInternalServerError)
return nil
}
permissions = make([]string, 0, len(dbPerms))
for _, p := range dbPerms {
permissions = append(permissions, p.Name)
}
// 3. Save to Redis cache for future requests (use background context)
cacheRoles := make([]redisRepo.RoleCacheItem, 0, len(dbRoles))
for _, r := range dbRoles {
cacheRoles = append(cacheRoles, redisRepo.RoleCacheItem{
ID: r.ID,
Name: r.Name,
Description: r.Description,
})
}
go func() {
bgCtx := context.Background()
if err := redisRepo.CacheUserPermissions(bgCtx, userID, redisRepo.RBACCachedData{
Roles: cacheRoles,
Permissions: permissions,
}, 60*time.Minute); err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to cache RBAC data in profile")
}
}()
}
// 4. Fetch user info from DB
user, err := repositories.GetUserByID(ctx, global.Queries, userID)
if err != nil {
log.Error().Err(err).Str("userID", userID).Msg("Failed to fetch user from DB")
response.InternalServerError(c, http.StatusInternalServerError)
return nil
}
if user == nil {
response.NotFoundError(c, http.StatusNotFound, "User not found")
return nil
}
// 5. Return response
response.Ok(c, "Profile fetched", responses.BodyProfileResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
FullName: user.FullName,
IsActive: user.IsActive,
Roles: roles,
Permissions: permissions,
})
return nil
}