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:
+25
-6
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user