feat(authz): support multi-role claim evaluation and role-aware permission checks

Parse and normalize user and project role claims (role_id + projects[].role_id)
Intersect requested roles with JWT-available roles before authorization
Evaluate permissions across candidate roles in both cached and non-cached flows
Fix claim field fallbacks (user_id/email) and role ID log formatting
Update tests and SQL mock expectations for new role-resolution behavior
This commit is contained in:
2026-02-27 08:39:33 +08:00
parent ae1831e61f
commit 6262c875b7
11 changed files with 293 additions and 127 deletions
+25 -6
View File
@@ -23,13 +23,32 @@ func Authorize(ctx *models.AuthorizationContext) (*models.AuthorizationResult, e
}
log.Printf("[AuthZ Step 0] User found: role_id=%d", user.RoleID)
log.Printf("[AuthZ Step 1] Checking if role_id=%d has permission for resource=%s, action=%s", user.RoleID, ctx.Resource, ctx.Action)
permission, err := repository.GetPermissionByResourceActionAndRole(ctx.Resource, ctx.Action, user.RoleID)
if err != nil {
log.Printf("✗ Permission not found or not granted to role_id=%d for resource=%s, action=%s: %v", user.RoleID, ctx.Resource, ctx.Action, err)
roleCandidates := getRoleCandidates(ctx)
if len(roleCandidates) == 0 && user.RoleID != 0 {
roleCandidates = []int{user.RoleID}
}
var permission *models.Permission
permissionFound := false
for _, roleID := range roleCandidates {
log.Printf("[AuthZ Step 1] Checking if role_id=%d has permission for resource=%s, action=%s", roleID, ctx.Resource, ctx.Action)
lookupPermission, lookupErr := repository.GetPermissionByResourceActionAndRole(ctx.Resource, ctx.Action, roleID)
if lookupErr != nil {
log.Printf("[AuthZ Step 1] Permission not granted for role_id=%d, trying next role: %v", roleID, lookupErr)
continue
}
permission = lookupPermission
ctx.RoleID = roleID
permissionFound = true
break
}
if !permissionFound {
log.Printf("✗ Permission not found or not granted for role candidates=%v, resource=%s, action=%s", roleCandidates, ctx.Resource, ctx.Action)
return &models.AuthorizationResult{
Allowed: false,
Message: fmt.Sprintf("Permission not granted to your role: %v", err),
Message: "Permission not granted to your role",
}, nil
}
log.Printf("[AuthZ Step 1] Permission found: ID=%d, Name=%s", permission.ID, permission.PermissionName)
@@ -73,7 +92,7 @@ func Authorize(ctx *models.AuthorizationContext) (*models.AuthorizationResult, e
log.Printf("[DEBUG] No policies loaded for permissionID=%d", permission.ID)
}
log.Printf("[AuthZ Step 4] Using RoleID: %s (from context or user record)", ctx.RoleID)
log.Printf("[AuthZ Step 4] Using RoleID: %d (from context or user record)", ctx.RoleID)
allowed, reason := EvaluatePolicies(policies, ctx)
result := &models.AuthorizationResult{
+55 -33
View File
@@ -5,7 +5,6 @@ import (
"authorization/models"
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
@@ -35,24 +34,21 @@ func TestAuthorize_PermissionNotFound(t *testing.T) {
UsersID: "user123",
Resource: "nonexistent",
Action: "read",
RoleID: 1,
ResourceData: make(map[string]string),
Environment: make(map[string]string),
}
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
// Mock permission query with role check
mock.ExpectQuery("SELECT p.role_permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
WithArgs("nonexistent", "read", 1).
WillReturnError(errors.New("permission not found"))
@@ -77,19 +73,16 @@ func TestAuthorize_Success(t *testing.T) {
UsersID: "user123",
Resource: "document",
Action: "read",
RoleID: 1,
ResourceData: make(map[string]string),
Environment: make(map[string]string),
}
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
@@ -97,7 +90,7 @@ func TestAuthorize_Success(t *testing.T) {
permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}).
AddRow(1, "read_document", "Read document permission", "document", "read")
mock.ExpectQuery("SELECT p.id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
WithArgs("document", "read", 1).
WillReturnRows(permRows)
@@ -137,19 +130,16 @@ func TestAuthorize_UserAttributesError(t *testing.T) {
UsersID: "user123",
Resource: "document",
Action: "read",
RoleID: 1,
ResourceData: make(map[string]string),
Environment: make(map[string]string),
}
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
@@ -157,7 +147,7 @@ func TestAuthorize_UserAttributesError(t *testing.T) {
permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}).
AddRow(1, "read_document", "Read document permission", "document", "read")
mock.ExpectQuery("SELECT p.id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
WithArgs("document", "read", 1).
WillReturnRows(permRows)
@@ -184,19 +174,16 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) {
UsersID: "user123",
Resource: "document",
Action: "read",
RoleID: 1,
ResourceData: make(map[string]string),
Environment: make(map[string]string),
}
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
@@ -204,7 +191,7 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) {
permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}).
AddRow(1, "read_document", "Read document permission", "document", "read")
mock.ExpectQuery("SELECT p.id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
WithArgs("document", "read", 1).
WillReturnRows(permRows)
@@ -230,3 +217,38 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) {
t.Error("Expected access denied")
}
}
func TestGetRoleCandidates_Priority(t *testing.T) {
t.Run("uses candidate roles first", func(t *testing.T) {
ctx := &models.AuthorizationContext{
CandidateRoles: []int{4, 2},
RoleIDs: []int{3},
RoleID: 1,
}
roles := getRoleCandidates(ctx)
if len(roles) != 2 || roles[0] != 4 || roles[1] != 2 {
t.Fatalf("unexpected roles: %v", roles)
}
})
t.Run("falls back to role ids array", func(t *testing.T) {
ctx := &models.AuthorizationContext{
RoleIDs: []int{3, 7},
}
roles := getRoleCandidates(ctx)
if len(roles) != 2 || roles[0] != 3 || roles[1] != 7 {
t.Fatalf("unexpected roles: %v", roles)
}
})
t.Run("falls back to single role", func(t *testing.T) {
ctx := &models.AuthorizationContext{RoleID: 9}
roles := getRoleCandidates(ctx)
if len(roles) != 1 || roles[0] != 9 {
t.Fatalf("unexpected roles: %v", roles)
}
})
}
+48 -20
View File
@@ -248,29 +248,44 @@ func AuthorizeWithCache(s *models.CachedAuthorizationService, ctx *models.Author
}
log.Printf("[AuthZ Step 0] User found: role_id=%d", user.RoleID)
// Step 1: Check if the user's role has the permission (not just if permission exists)
// Use role-aware cache key: roleID:resource:action
cacheKey := fmt.Sprintf("%d:%s:%s", ctx.RoleID, ctx.Resource, ctx.Action)
log.Printf("[AuthZ Step 1] Looking up permission in cache with role: %s", cacheKey)
permission, exists := getPermissionFromCache(s, cacheKey)
roleCandidates := getRoleCandidates(ctx)
var permission *models.Permission
permissionFound := false
if !exists {
// Cache miss - try database lookup with role check
log.Printf("[AuthZ Step 1] Cache miss - querying database for role_id=%d, resource=%s, action=%s", ctx.RoleID, ctx.Resource, ctx.Action)
permission, err = repository.GetPermissionByResourceActionAndRole(ctx.Resource, ctx.Action, ctx.RoleID)
if err != nil {
log.Printf("✗ [AuthZ Step 1] Permission not found or not granted to role_id=%d for resource=%s, action=%s: %v", ctx.RoleID, ctx.Resource, ctx.Action, err)
return &models.AuthorizationResult{
Allowed: false,
Message: "Permission not granted to your role",
}, nil
for _, roleID := range roleCandidates {
cacheKey := fmt.Sprintf("%d:%s:%s", roleID, ctx.Resource, ctx.Action)
log.Printf("[AuthZ Step 1] Looking up permission in cache with role: %s", cacheKey)
cachedPermission, exists := getPermissionFromCache(s, cacheKey)
if exists {
permission = cachedPermission
ctx.RoleID = roleID
permissionFound = true
log.Printf("✓ [AuthZ Step 1] Permission found in cache for role_id=%d: ID=%d, Name=%s", roleID, permission.ID, permission.PermissionName)
break
}
log.Printf("✓ [AuthZ Step 1] Permission found in DB: ID=%d, Name=%s", permission.ID, permission.PermissionName)
// Cache the result for future use
log.Printf("[AuthZ Step 1] Cache miss - querying database for role_id=%d, resource=%s, action=%s", roleID, ctx.Resource, ctx.Action)
dbPermission, lookupErr := repository.GetPermissionByResourceActionAndRole(ctx.Resource, ctx.Action, roleID)
if lookupErr != nil {
log.Printf("[AuthZ Step 1] Permission not granted for role_id=%d, trying next role: %v", roleID, lookupErr)
continue
}
permission = dbPermission
ctx.RoleID = roleID
permissionFound = true
log.Printf("✓ [AuthZ Step 1] Permission found in DB for role_id=%d: ID=%d, Name=%s", roleID, permission.ID, permission.PermissionName)
storePermissionInCache(s, cacheKey, permission)
} else {
log.Printf("✓ [AuthZ Step 1] Permission found in cache: ID=%d, Name=%s", permission.ID, permission.PermissionName)
break
}
if !permissionFound {
log.Printf("✗ [AuthZ Step 1] Permission not granted for any role candidate=%v, resource=%s, action=%s", roleCandidates, ctx.Resource, ctx.Action)
return &models.AuthorizationResult{
Allowed: false,
Message: "Permission not granted to your role",
}, nil
}
// Step 2: Get user attributes (with distributed cache)
@@ -305,7 +320,7 @@ func AuthorizeWithCache(s *models.CachedAuthorizationService, ctx *models.Author
log.Printf("[DEBUG] No policies loaded for permissionID=%d", permission.ID)
}
log.Printf("[AuthZ Step 4] Using RoleID: %s (from context or user record)", ctx.RoleID)
log.Printf("[AuthZ Step 4] Using RoleID: %d (from context or user record)", ctx.RoleID)
allowed, reason := EvaluatePolicies(policies, ctx)
result := &models.AuthorizationResult{
@@ -338,6 +353,19 @@ func AuthorizeWithCache(s *models.CachedAuthorizationService, ctx *models.Author
return result, nil
}
func getRoleCandidates(ctx *models.AuthorizationContext) []int {
if len(ctx.CandidateRoles) > 0 {
return ctx.CandidateRoles
}
if len(ctx.RoleIDs) > 0 {
return ctx.RoleIDs
}
if ctx.RoleID != 0 {
return []int{ctx.RoleID}
}
return nil
}
// InvalidateUserCache clears cache for a specific user from both Redis and local cache
func InvalidateUserCache(s *models.CachedAuthorizationService, userID string) {
// Clear from Redis
+11 -18
View File
@@ -137,11 +137,11 @@ func TestRefreshCache(t *testing.T) {
// Only policies are preloaded during cache refresh
// Mock policy attributes query
policyRows := sqlmock.NewRows([]string{"id", "attribute_name", "attribute_type", "comparison", "attribute_value", "permission_id"}).
policyRows := sqlmock.NewRows([]string{"policy_attributes_id", "attribute_name", "attribute_type", "comparison", "attribute_value", "permission_id"}).
AddRow(1, "department", "user", "=", "engineering", 1).
AddRow(2, "region", "user", "=", "01", 2)
mock.ExpectQuery("SELECT id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM policy_attributes ORDER BY permission_id, id").
mock.ExpectQuery("SELECT policy_attributes_id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM policy_attributes ORDER BY permission_id, policy_attributes_id").
WillReturnRows(policyRows)
refreshCache(service)
@@ -218,15 +218,11 @@ func TestAuthorizeWithCache_Success(t *testing.T) {
// Add empty policies
service.PolicyCache[1] = []models.PolicyAttribute{}
// Mock user query (needed to get role_id)
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
@@ -242,6 +238,7 @@ func TestAuthorizeWithCache_Success(t *testing.T) {
UsersID: "user123",
Resource: "document",
Action: "read",
RoleID: 1,
ResourceData: make(map[string]string),
Environment: make(map[string]string),
}
@@ -274,19 +271,15 @@ func TestAuthorizeWithCache_PermissionNotFound(t *testing.T) {
}
// Mock user query
userRows := sqlmock.NewRows([]string{"users_id", "first_name", "middle_initial", "last_name", "suffix", "email_address",
"home_address", "contact_number",
"role_id", "is_deleted", "created_at", "updated_at"}).
AddRow("user123", "John", "", "Doe", "", "john@example.com",
"EMP123", "Y", "Y", "123 Street", "09123456789", "device1",
1, "N", "secret", "Y", time.Now(), time.Now())
userRows := sqlmock.NewRows([]string{"users_id", "email_address"}).
AddRow("user123", "john@example.com")
mock.ExpectQuery("SELECT users_id, first_name, middle_initial, last_name, suffix, email_address").
mock.ExpectQuery("SELECT users_id, email_address").
WithArgs("user123").
WillReturnRows(userRows)
// Permission not in cache, so will query DB and fail
mock.ExpectQuery("SELECT p.id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp").
WithArgs("nonexistent", "read", 1).
WillReturnError(errors.New("permission not found"))
+1 -1
View File
@@ -137,7 +137,7 @@ func evaluatePolicy(policyAttribute models.PolicyAttribute, ctx *models.Authoriz
policyAttribute.AttributeName == "region" &&
(ctx.RoleID == 1 || ctx.RoleID == 2) {
fmt.Printf("[POLICY EVALUATION] Type: %s, Attribute: %s\n", policyAttribute.AttributeType, policyAttribute.AttributeName)
fmt.Printf(" Skipped for roleID: %s (Super | System Admin bypass)\n\n", ctx.RoleID)
fmt.Printf(" Skipped for roleID: %d (Super | System Admin bypass)\n\n", ctx.RoleID)
return true, ""
}
+3 -3
View File
@@ -919,12 +919,12 @@ func TestEvaluatePolicies_RegionBypassForAdminRoles(t *testing.T) {
description: "Super Admin role string should bypass region check",
},
{
name: "Admin role does not bypass region check",
name: "Admin role bypasses region check",
roleID: 2,
userRegion: "03",
resourceRegion: "01",
shouldBeAllowed: false,
description: "Admin role string should not bypass region check",
shouldBeAllowed: true,
description: "Admin role should bypass region check",
},
}