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
+17 -7
View File
@@ -21,6 +21,13 @@ type AuthorizationResponse struct {
Reason string `json:"reason,omitempty"`
}
type ProjectClaim struct {
ProjectID int `json:"project_id,omitempty"`
Alias string `json:"alias,omitempty"`
RoleID RoleIDs `json:"role_id,omitempty"`
OfficeID int `json:"office_id,omitempty"`
}
// RoleIDs represents one or more role IDs.
// It is defined as a custom type so we can implement flexible JSON unmarshalling
// that accepts a single string ("1"), a single number (1), or an array ([1,2,...]).
@@ -75,9 +82,10 @@ func (r *RoleIDs) UnmarshalJSON(data []byte) error {
}
type Claims struct {
UsersID string `json:"users_id,omitempty"`
EmailAddress string `json:"email_address,omitempty"`
RoleID RoleIDs `json:"role_id"`
UsersID string `json:"users_id,omitempty"`
EmailAddress string `json:"email_address,omitempty"`
RoleID RoleIDs `json:"role_id"`
Projects []ProjectClaim `json:"projects,omitempty"`
jwt.RegisteredClaims
}
@@ -85,6 +93,8 @@ type Claims struct {
func (c *Claims) UnmarshalJSON(data []byte) error {
type Alias Claims
aux := &struct {
UserID string `json:"user_id"`
Email string `json:"email"`
*Alias
}{
Alias: (*Alias)(c),
@@ -93,12 +103,12 @@ func (c *Claims) UnmarshalJSON(data []byte) error {
return err
}
// If UsersID is empty but UserID is set, copy UserID to UsersID
if c.UsersID == "" && c.UsersID != "" {
c.UsersID = c.UsersID
if c.UsersID == "" && aux.UserID != "" {
c.UsersID = aux.UserID
}
// If EmailAddress is empty but Email is set, copy Email to EmailAddress
if c.EmailAddress == "" && c.EmailAddress != "" {
c.EmailAddress = c.EmailAddress
if c.EmailAddress == "" && aux.Email != "" {
c.EmailAddress = aux.Email
}
return nil
}
+5
View File
@@ -66,6 +66,8 @@ type AuthorizationContext struct {
Resource string `json:"resource"`
Action string `json:"action"`
RoleID int `json:"role_id"` // User's role ID
RoleIDs []int `json:"-"`
CandidateRoles []int `json:"-"`
UserAttributes map[string]string `json:"user_attributes"`
ResourceData map[string]string `json:"resource_data"` // Additional resource context
Environment map[string]string `json:"environment"` // Time, location, etc.
@@ -91,12 +93,14 @@ func (ac *AuthorizationContext) UnmarshalJSON(data []byte) error {
var roleInt int
if err := json.Unmarshal(aux.RoleIDRaw, &roleInt); err == nil {
ac.RoleID = roleInt
ac.RoleIDs = []int{roleInt}
} else {
// Try as array of ints (take first element)
var roleArray []int
if err := json.Unmarshal(aux.RoleIDRaw, &roleArray); err == nil {
if len(roleArray) > 0 {
ac.RoleID = roleArray[0]
ac.RoleIDs = roleArray
}
} else {
// Try as string
@@ -108,6 +112,7 @@ func (ac *AuthorizationContext) UnmarshalJSON(data []byte) error {
if convErr != nil {
return fmt.Errorf("invalid role_id: %s", roleStr)
}
ac.RoleIDs = []int{ac.RoleID}
}
} else {
return fmt.Errorf("role_id must be a number, numeric string, or array of numbers")