From 49e623dfeb13bb2dbaeff2c75fb45b1d8d5d1bf9 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:44:44 +1300 Subject: [PATCH] feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) --- .../endpointgroups/endpointgroup_update.go | 8 - api/http/handler/endpoints/endpoint_delete.go | 6 - api/internal/authorization/authorizations.go | 194 +-------- .../users/teams/ItemView/ItemView.tsx | 5 +- .../KubernetesSidebar/KubernetesSidebar.tsx | 1 + pkg/authorization/resolver.go | 175 ++++++++ pkg/authorization/resolver_test.go | 405 ++++++++++++++++++ 7 files changed, 590 insertions(+), 204 deletions(-) create mode 100644 pkg/authorization/resolver.go create mode 100644 pkg/authorization/resolver_test.go diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index d2260b8f7..344980ee7 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -202,14 +202,6 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin endpointsChanged = true } } - - // Update user authorizations when endpoints are added/removed from the group - // since group membership affects access control - if endpointsChanged { - if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil { - return nil, httperror.InternalServerError("Unable to update user authorizations", err) - } - } } // Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled) diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 83728bf1c..8cbcedd15 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p handler.ProxyManager.DeleteEndpointProxy(endpoint.ID) - if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { - if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil { - log.Warn().Err(err).Msg("Unable to update user authorizations") - } - } - if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil { log.Warn().Err(err).Msg("Unable to remove environment relation from the database") } diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go index 1e412a382..590bc75e8 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te } } - return service.UpdateUsersAuthorizationsTx(tx) + return nil } // RemoveUserAccessPolicies will remove all existing access policies associated to the specified user @@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us return nil } -// UpdateUserAuthorizations will update the authorizations for the provided userid -func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error { - err := service.updateUserAuthorizations(tx, userID) - if err != nil { - return err - } - - return nil -} - -// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. +// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations. +// +// Deprecated: This function previously populated the User.EndpointAuthorizations field which is +// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess. func (service *Service) UpdateUsersAuthorizations() error { - return service.UpdateUsersAuthorizationsTx(service.dataStore) -} - -func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error { - users, err := tx.User().ReadAll() - if err != nil { - return err - } - - for _, user := range users { - err := service.updateUserAuthorizations(tx, user.ID) - if err != nil { - return err - } - } - return nil } -func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error { - user, err := tx.User().Read(userID) - if err != nil { - return err - } - - endpointAuthorizations, err := service.getAuthorizations(tx, user) - if err != nil { - return err - } - - user.EndpointAuthorizations = endpointAuthorizations - - return tx.User().Update(userID, user) -} - -func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) { - endpointAuthorizations := portainer.EndpointAuthorizations{} - if user.Role == portainer.AdministratorRole { - return endpointAuthorizations, nil - } - - userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) - if err != nil { - return endpointAuthorizations, err - } - - endpoints, err := tx.Endpoint().Endpoints() - if err != nil { - return endpointAuthorizations, err - } - - endpointGroups, err := tx.EndpointGroup().ReadAll() - if err != nil { - return endpointAuthorizations, err - } - - roles, err := tx.Role().ReadAll() - if err != nil { - return endpointAuthorizations, err - } - - endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships) - - return endpointAuthorizations, nil -} - -func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations { - endpointAuthorizations := make(portainer.EndpointAuthorizations) - - groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{} - groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{} - for _, endpointGroup := range endpointGroups { - groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies - groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies - } - - for _, endpoint := range endpoints { - authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - - continue - } - - authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - - continue - } - - authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - - continue - } - - authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - } - } - - return endpointAuthorizations -} - -func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - policy, ok := endpoint.UserAccessPolicies[user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - for _, membership := range memberships { - policy, ok := endpoint.TeamAccessPolicies[membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - for _, membership := range memberships { - policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations { - var associatedRoles []portainer.Role - - for _, id := range roleIdentifiers { - for _, role := range roles { - if role.ID == id { - associatedRoles = append(associatedRoles, role) - - break - } - } - } - - var authorizations portainer.Authorizations - highestPriority := 0 - for _, role := range associatedRoles { - if role.Priority > highestPriority { - highestPriority = role.Priority - authorizations = role.Authorizations - } - } - - return authorizations -} - func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) { user, err := tx.User().Read(userID) if err != nil { diff --git a/app/react/portainer/users/teams/ItemView/ItemView.tsx b/app/react/portainer/users/teams/ItemView/ItemView.tsx index 5137faf0e..286c25e72 100644 --- a/app/react/portainer/users/teams/ItemView/ItemView.tsx +++ b/app/react/portainer/users/teams/ItemView/ItemView.tsx @@ -37,7 +37,10 @@ export function ItemView() { <> diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx index 3c0be3ce5..37a97c284 100644 --- a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -105,6 +105,7 @@ export function KubernetesSidebar({ environmentId }: Props) { to="kubernetes.moreResources.jobs" pathOptions={{ includePaths: [ + 'kubernetes.moreResources.jobs', 'kubernetes.moreResources.serviceAccounts', 'kubernetes.moreResources.clusterRoles', 'kubernetes.moreResources.roles', diff --git a/pkg/authorization/resolver.go b/pkg/authorization/resolver.go new file mode 100644 index 000000000..b0b6fe101 --- /dev/null +++ b/pkg/authorization/resolver.go @@ -0,0 +1,175 @@ +package authorization + +import ( + portainer "github.com/portainer/portainer/api" +) + +// ResolvedAccess represents the result of dynamic authorization resolution. +// It contains both the computed role and its authorizations for convenience. +type ResolvedAccess struct { + Role *portainer.Role + Authorizations portainer.Authorizations +} + +// ResolverInput contains all the data needed to resolve user access to an endpoint. +// This struct is used to pass data to the resolution functions without requiring +// database access, making it easier to test and allowing callers to control data fetching. +type ResolverInput struct { + User *portainer.User + Endpoint *portainer.Endpoint + EndpointGroup portainer.EndpointGroup + UserMemberships []portainer.TeamMembership + Roles []portainer.Role +} + +// ComputeBaseRole computes the user's role on an endpoint from base access settings. +// It checks access in precedence order: +// 1. User → Endpoint direct access +// 2. User → Endpoint Group access (inherited) +// 3. User's Teams → Endpoint access +// 4. User's Teams → Endpoint Group access (inherited) +// +// Returns the first matching role, or nil if no access is configured. +func ComputeBaseRole(input ResolverInput) *portainer.Role { + group := input.EndpointGroup + + // 1. Check user → endpoint direct access + if role := GetRoleFromUserAccessPolicies( + input.User.ID, + input.Endpoint.UserAccessPolicies, + input.Roles, + ); role != nil { + return role + } + + // 2. Check user → endpoint group access (inherited) + if role := GetRoleFromUserAccessPolicies( + input.User.ID, + group.UserAccessPolicies, + input.Roles, + ); role != nil { + return role + } + + // 3. Check user's teams → endpoint access + if role := GetRoleFromTeamAccessPolicies( + input.UserMemberships, + input.Endpoint.TeamAccessPolicies, + input.Roles, + ); role != nil { + return role + } + + // 4. Check user's teams → endpoint group access (inherited) + if role := GetRoleFromTeamAccessPolicies( + input.UserMemberships, + group.TeamAccessPolicies, + input.Roles, + ); role != nil { + return role + } + + return nil +} + +// ResolveUserEndpointAccess resolves a user's effective access to an endpoint. +// In CE, this returns the base role computed from endpoint/group access settings. +// EE extends this to also consider applied RBAC policies. +// +// Returns nil if the user has no access to the endpoint. +func ResolveUserEndpointAccess(input ResolverInput) *ResolvedAccess { + role := ComputeBaseRole(input) + if role == nil { + return nil + } + + return &ResolvedAccess{ + Role: role, + Authorizations: role.Authorizations, + } +} + +// GetRoleFromUserAccessPolicies returns the role for a user from user access policies. +// Returns nil if the user is not in the policies. +func GetRoleFromUserAccessPolicies( + userID portainer.UserID, + policies portainer.UserAccessPolicies, + roles []portainer.Role, +) *portainer.Role { + if policies == nil { + return nil + } + + policy, ok := policies[userID] + if !ok { + return nil + } + + return FindRoleByID(policy.RoleID, roles) +} + +// GetRoleFromTeamAccessPolicies returns the highest priority role for a user +// based on their team memberships and the team access policies. +// If a user belongs to multiple teams with access, the role with highest priority wins. +// Returns nil if none of the user's teams have access. +func GetRoleFromTeamAccessPolicies( + memberships []portainer.TeamMembership, + policies portainer.TeamAccessPolicies, + roles []portainer.Role, +) *portainer.Role { + if policies == nil || len(memberships) == 0 { + return nil + } + + // Collect all roles from team memberships + var matchingRoles []*portainer.Role + for _, membership := range memberships { + policy, ok := policies[membership.TeamID] + if !ok { + continue + } + + role := FindRoleByID(policy.RoleID, roles) + if role != nil { + matchingRoles = append(matchingRoles, role) + } + } + + if len(matchingRoles) == 0 { + return nil + } + + // Return the role with highest priority + return GetHighestPriorityRole(matchingRoles) +} + +// GetHighestPriorityRole returns the role with the highest priority from a slice. +// In Portainer's role system, higher priority numbers = higher priority (lower access usually gives higher priority). +// Current role priorities from highest to lowest: Read-only User (6), Standard User (5), +// Namespace Operator (4), Helpdesk (3), Operator (2), Environment Administrator (1). +// Returns nil if the slice is empty. +func GetHighestPriorityRole(roles []*portainer.Role) *portainer.Role { + if len(roles) == 0 { + return nil + } + + highest := roles[0] + for _, role := range roles[1:] { + if role.Priority > highest.Priority { + highest = role + } + } + + return highest +} + +// FindRoleByID finds a role by its ID in a slice of roles. +// Returns nil if the role is not found. +func FindRoleByID(roleID portainer.RoleID, roles []portainer.Role) *portainer.Role { + for i := range roles { + if roles[i].ID == roleID { + return &roles[i] + } + } + return nil +} diff --git a/pkg/authorization/resolver_test.go b/pkg/authorization/resolver_test.go new file mode 100644 index 000000000..3a7227c62 --- /dev/null +++ b/pkg/authorization/resolver_test.go @@ -0,0 +1,405 @@ +package authorization + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +// Test role fixtures +// In Portainer's role system, higher priority numbers = higher priority (more powerful). +// Order from highest to lowest: Read-only (4), Helpdesk (3), Operator (2), Admin (1). +var ( + roleAdmin = portainer.Role{ + ID: 1, + Name: "Environment Administrator", + Priority: 1, + Authorizations: portainer.Authorizations{"admin": true}, + } + roleOperator = portainer.Role{ + ID: 2, + Name: "Operator", + Priority: 2, + Authorizations: portainer.Authorizations{"operator": true}, + } + roleHelpdesk = portainer.Role{ + ID: 3, + Name: "Helpdesk", + Priority: 3, + Authorizations: portainer.Authorizations{"helpdesk": true}, + } + roleReadOnly = portainer.Role{ + ID: 4, + Name: "Read-only", + Priority: 4, + Authorizations: portainer.Authorizations{"readonly": true}, + } + + allRoles = []portainer.Role{roleAdmin, roleOperator, roleHelpdesk, roleReadOnly} +) + +func TestComputeBaseRole_UserEndpointAccess(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 1, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleOperator.ID}, + }, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: portainer.EndpointGroup{}, + UserMemberships: []portainer.TeamMembership{}, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + + is.NotNil(role) + is.Equal(roleOperator.ID, role.ID) + is.Equal("Operator", role.Name) +} + +func TestComputeBaseRole_UserGroupAccess(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{}, // No direct access + } + groups := []portainer.EndpointGroup{ + { + ID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleHelpdesk.ID}, // User has access via group + }, + }, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groups[0], + UserMemberships: []portainer.TeamMembership{}, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + + is.NotNil(role) + is.Equal(roleHelpdesk.ID, role.ID) + is.Equal("Helpdesk", role.Name) +} + +func TestComputeBaseRole_TeamEndpointAccess(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 1, + UserAccessPolicies: portainer.UserAccessPolicies{}, // No user access + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleReadOnly.ID}, // Team 100 has access + }, + } + memberships := []portainer.TeamMembership{ + {UserID: 1, TeamID: 100}, // User is in team 100 + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: portainer.EndpointGroup{}, + UserMemberships: memberships, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + + is.NotNil(role) + is.Equal(roleReadOnly.ID, role.ID) +} + +func TestComputeBaseRole_TeamGroupAccess(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, // No direct team access + } + groups := []portainer.EndpointGroup{ + { + ID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleOperator.ID}, // Team 100 has group access + }, + }, + } + memberships := []portainer.TeamMembership{ + {UserID: 1, TeamID: 100}, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groups[0], + UserMemberships: memberships, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + + is.NotNil(role) + is.Equal(roleOperator.ID, role.ID) +} + +func TestComputeBaseRole_Precedence(t *testing.T) { + is := assert.New(t) + + t.Run("User endpoint access takes precedence over group access", func(t *testing.T) { + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleOperator.ID}, // Direct access + }, + } + groups := []portainer.EndpointGroup{ + { + ID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleAdmin.ID}, // Group access (higher role, but lower precedence) + }, + }, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groups[0], + Roles: allRoles, + } + + role := ComputeBaseRole(input) + is.NotNil(role) + is.Equal(roleOperator.ID, role.ID, "Direct endpoint access should take precedence") + }) + + t.Run("User access takes precedence over team access", func(t *testing.T) { + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 1, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleHelpdesk.ID}, + }, + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleAdmin.ID}, // Team has higher role + }, + } + memberships := []portainer.TeamMembership{ + {UserID: 1, TeamID: 100}, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + UserMemberships: memberships, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + is.NotNil(role) + is.Equal(roleHelpdesk.ID, role.ID, "User access should take precedence over team access") + }) + + t.Run("Team endpoint access takes precedence over team group access", func(t *testing.T) { + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 10, + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleReadOnly.ID}, // Direct team endpoint access + }, + } + groups := []portainer.EndpointGroup{ + { + ID: 10, + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleAdmin.ID}, // Team group access (higher role) + }, + }, + } + memberships := []portainer.TeamMembership{ + {UserID: 1, TeamID: 100}, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groups[0], + UserMemberships: memberships, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + is.NotNil(role) + is.Equal(roleReadOnly.ID, role.ID, "Team endpoint access should take precedence over team group access") + }) +} + +func TestComputeBaseRole_NoAccess(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + } + groups := []portainer.EndpointGroup{ + { + ID: 10, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + }, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groups[0], + UserMemberships: []portainer.TeamMembership{}, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + is.Nil(role) +} + +func TestComputeBaseRole_MultipleTeams_HighestPriorityWins(t *testing.T) { + is := assert.New(t) + + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: 1, + TeamAccessPolicies: portainer.TeamAccessPolicies{ + 100: {RoleID: roleReadOnly.ID}, // Highest priority (4) + 200: {RoleID: roleAdmin.ID}, // Lowest priority (1) + 300: {RoleID: roleOperator.ID}, // Medium priority (2) + }, + } + memberships := []portainer.TeamMembership{ + {UserID: 1, TeamID: 100}, + {UserID: 1, TeamID: 200}, + {UserID: 1, TeamID: 300}, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: portainer.EndpointGroup{}, + UserMemberships: memberships, + Roles: allRoles, + } + + role := ComputeBaseRole(input) + + is.NotNil(role) + is.Equal(roleReadOnly.ID, role.ID, "Highest priority role should be selected when user is in multiple teams") +} + +func TestResolveUserEndpointAccess(t *testing.T) { + is := assert.New(t) + + t.Run("Returns resolved access with role and authorizations", func(t *testing.T) { + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ + ID: 1, + UserAccessPolicies: portainer.UserAccessPolicies{ + 1: {RoleID: roleOperator.ID}, + }, + } + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + Roles: allRoles, + } + + access := ResolveUserEndpointAccess(input) + + is.NotNil(access) + is.Equal(roleOperator.ID, access.Role.ID) + is.True(access.Authorizations["operator"]) + }) + + t.Run("Returns nil when no access", func(t *testing.T) { + user := &portainer.User{ID: 1} + endpoint := &portainer.Endpoint{ID: 1} + + input := ResolverInput{ + User: user, + Endpoint: endpoint, + Roles: allRoles, + } + + access := ResolveUserEndpointAccess(input) + is.Nil(access) + }) +} + +func TestFindRoleByID(t *testing.T) { + is := assert.New(t) + + t.Run("Finds existing role", func(t *testing.T) { + role := FindRoleByID(roleOperator.ID, allRoles) + is.NotNil(role) + is.Equal(roleOperator.ID, role.ID) + }) + + t.Run("Returns nil for non-existent role", func(t *testing.T) { + role := FindRoleByID(999, allRoles) + is.Nil(role) + }) + + t.Run("Returns nil for empty roles slice", func(t *testing.T) { + role := FindRoleByID(1, []portainer.Role{}) + is.Nil(role) + }) +} + +func TestGetHighestPriorityRole(t *testing.T) { + is := assert.New(t) + + t.Run("Returns nil for empty slice", func(t *testing.T) { + result := GetHighestPriorityRole([]*portainer.Role{}) + is.Nil(result) + }) + + t.Run("Returns single role", func(t *testing.T) { + result := GetHighestPriorityRole([]*portainer.Role{&roleOperator}) + is.Equal(roleOperator.ID, result.ID) + }) + + t.Run("Returns highest priority from multiple roles", func(t *testing.T) { + result := GetHighestPriorityRole([]*portainer.Role{&roleReadOnly, &roleAdmin, &roleOperator}) + is.Equal(roleReadOnly.ID, result.ID) + }) +}