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
+62 -7
View File
@@ -84,12 +84,21 @@ func AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
ctx.Environment = make(map[string]string)
}
// containsRole checks if a role exists in a slice of roles
claimRoles := collectClaimRoles(claims)
requestedRoles := collectRequestedRoles(&ctx)
if len(requestedRoles) == 0 {
requestedRoles = claimRoles
}
if !containsRole([]int(claims.RoleID), ctx.RoleID) {
validRoles := intersectRoles(requestedRoles, claimRoles)
if len(validRoles) == 0 {
helper.RespondWithError(w, http.StatusForbidden, "Role ID mismatch")
return
}
ctx.CandidateRoles = validRoles
ctx.RoleID = validRoles[0]
ctx.RoleIDs = validRoles
log.Print("User role verified: ", ctx.RoleID)
// Perform authorization
log.Printf("[Handler] Performing authorization check for user=%s, resource=%s, action=%s", ctx.UsersID, ctx.Resource, ctx.Action)
@@ -121,11 +130,57 @@ func AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
}
}
func containsRole(roles []int, role int) bool {
for _, r := range roles {
if r == role {
return true
func collectClaimRoles(claims *models.Claims) []int {
unique := make(map[int]struct{})
roles := make([]int, 0, len(claims.RoleID))
for _, role := range claims.RoleID {
if _, exists := unique[role]; !exists {
unique[role] = struct{}{}
roles = append(roles, role)
}
}
return false
for _, project := range claims.Projects {
for _, role := range project.RoleID {
if _, exists := unique[role]; !exists {
unique[role] = struct{}{}
roles = append(roles, role)
}
}
}
return roles
}
func collectRequestedRoles(ctx *models.AuthorizationContext) []int {
if len(ctx.RoleIDs) > 0 {
return append([]int(nil), ctx.RoleIDs...)
}
if ctx.RoleID != 0 {
return []int{ctx.RoleID}
}
return nil
}
func intersectRoles(requested, available []int) []int {
availableSet := make(map[int]struct{}, len(available))
for _, role := range available {
availableSet[role] = struct{}{}
}
unique := make(map[int]struct{})
result := make([]int, 0, len(requested))
for _, role := range requested {
if _, ok := availableSet[role]; !ok {
continue
}
if _, seen := unique[role]; seen {
continue
}
unique[role] = struct{}{}
result = append(result, role)
}
return result
}
+49
View File
@@ -375,3 +375,52 @@ func TestAuthorizeHandlerWithResourceData(t *testing.T) {
t.Errorf("Handler returned bad request with valid ResourceData")
}
}
func TestCollectClaimRolesIncludesProjectRoles(t *testing.T) {
claims := &models.Claims{
RoleID: models.RoleIDs{2},
Projects: []models.ProjectClaim{
{ProjectID: 7, RoleID: models.RoleIDs{2, 4}},
{ProjectID: 8, RoleID: models.RoleIDs{5}},
},
}
roles := collectClaimRoles(claims)
if len(roles) != 3 {
t.Fatalf("expected 3 unique roles, got %d (%v)", len(roles), roles)
}
if roles[0] != 2 || roles[1] != 4 || roles[2] != 5 {
t.Fatalf("unexpected role order/content: %v", roles)
}
}
func TestIntersectRolesReturnsOverlap(t *testing.T) {
requested := []int{4, 9, 4, 2}
available := []int{2, 4, 5}
result := intersectRoles(requested, available)
if len(result) != 2 {
t.Fatalf("expected 2 matching roles, got %d (%v)", len(result), result)
}
if result[0] != 4 || result[1] != 2 {
t.Fatalf("unexpected intersection result: %v", result)
}
}
func TestCollectRequestedRolesFromArray(t *testing.T) {
ctx := &models.AuthorizationContext{
RoleIDs: []int{3, 7},
RoleID: 3,
}
result := collectRequestedRoles(ctx)
if len(result) != 2 {
t.Fatalf("expected 2 requested roles, got %d (%v)", len(result), result)
}
if result[0] != 3 || result[1] != 7 {
t.Fatalf("unexpected requested roles: %v", result)
}
}