diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go index db012e331..1a74f0938 100644 --- a/api/http/handler/teams/team_list.go +++ b/api/http/handler/teams/team_list.go @@ -6,6 +6,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -15,6 +16,7 @@ import ( // @description **Access policy**: restricted // @tags teams // @param onlyLedTeams query boolean false "Only list teams that the user is leader of" +// @param environmentId query int false "Identifier of the environment(endpoint) that will be used to filter the authorized teams" // @security ApiKeyAuth // @security jwt // @produce json @@ -34,13 +36,42 @@ func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httper onlyLedTeams, _ := request.RetrieveBooleanQueryParameter(r, "onlyLedTeams", true) - filteredTeams := teams - + var userTeams []portainer.Team if onlyLedTeams { - filteredTeams = security.FilterLeaderTeams(filteredTeams, securityContext) + userTeams = security.FilterLeaderTeams(teams, securityContext) + } else { + userTeams = security.FilterUserTeams(teams, securityContext) } - filteredTeams = security.FilterUserTeams(filteredTeams, securityContext) + endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true) + if endpointID == 0 { + return response.JSON(w, userTeams) + } - return response.JSON(w, filteredTeams) + // filter out teams who do not have access to the specific endpoint + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve endpoint from the database", err) + } + + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + allowedTeams := make(map[portainer.TeamID]struct{}) + for teamID := range endpointGroup.TeamAccessPolicies { + allowedTeams[teamID] = struct{}{} + } + for teamID := range endpoint.TeamAccessPolicies { + allowedTeams[teamID] = struct{}{} + } + + listableTeams := make([]portainer.Team, 0) + for _, team := range userTeams { + if _, ok := allowedTeams[team.ID]; ok { + listableTeams = append(listableTeams, team) + } + } + return response.JSON(w, listableTeams) } diff --git a/api/http/handler/teams/team_list_test.go b/api/http/handler/teams/team_list_test.go new file mode 100644 index 000000000..6d088f7a5 --- /dev/null +++ b/api/http/handler/teams/team_list_test.go @@ -0,0 +1,190 @@ +package teams + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_teamList(t *testing.T) { + is := assert.New(t) + + _, store, teardown := datastore.MustNewTestStore(t, true, true) + defer teardown() + + // create admin + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + is.NoError(err, "error creating admin user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + + h := NewHandler(requestBouncer) + h.DataStore = store + + // generate admin user tokens + adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + + // Case 1: the team is given the endpoint access directly + // create teams + teamWithEndpointAccess := &portainer.Team{ID: 1, Name: "team-with-endpoint-access"} + err = store.Team().Create(teamWithEndpointAccess) + is.NoError(err, "error creating team") + + teamWithoutEndpointAccess := &portainer.Team{ID: 2, Name: "team-without-endpoint-access"} + err = store.Team().Create(teamWithoutEndpointAccess) + is.NoError(err, "error creating team") + + // create users + userWithEndpointAccessByTeam := &portainer.User{ID: 2, Username: "standard-user-inherit-endpoint-access-from-team", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithEndpointAccessByTeam) + is.NoError(err, "error creating user") + + userWithoutEndpointAccess := &portainer.User{ID: 3, Username: "standard-user-without-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithoutEndpointAccess) + is.NoError(err, "error creating user") + + // create team membership + teamMembership := &portainer.TeamMembership{ID: 1, UserID: userWithEndpointAccessByTeam.ID, TeamID: teamWithEndpointAccess.ID} + err = store.TeamMembership().Create(teamMembership) + is.NoError(err, "error creating team membership") + + // create endpoint and team access policies + teamAccessPolicies := make(portainer.TeamAccessPolicies, 0) + teamAccessPolicies[teamWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userWithEndpointAccessByTeam.Role)} + + endpointGroupOnly := &portainer.EndpointGroup{ID: 5, Name: "endpoint-group"} + err = store.EndpointGroup().Create(endpointGroupOnly) + is.NoError(err, "error creating endpoint group") + + endpointWithTeamAccessPolicy := &portainer.Endpoint{ID: 1, GroupID: endpointGroupOnly.ID, TeamAccessPolicies: teamAccessPolicies} + err = store.Endpoint().Create(endpointWithTeamAccessPolicy) + is.NoError(err, "error creating endpoint") + + jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role}) + + t.Run("admin user can successfully list all teams", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/teams", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 2) + }) + + t.Run("admin user can list team who is given access to the specific endpoint", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamWithEndpointAccess.ID, resp[0].ID) + } + }) + + t.Run("standard user only can list team where he belongs to", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/teams", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamWithEndpointAccess.ID, resp[0].ID) + } + }) + + // Case 2: the team is under an environment group and the endpoint group has endpoint access. + // the user inherits the endpoint access from the environment group + // create team + teamUnderGroup := &portainer.Team{ID: 3, Name: "team-under-environment-group"} + err = store.Team().Create(teamUnderGroup) + is.NoError(err, "error creating user") + + // create environment group including a team + teamAccessPoliciesUnderGroup := make(portainer.TeamAccessPolicies, 0) + teamAccessPoliciesUnderGroup[teamUnderGroup.ID] = portainer.AccessPolicy{} + + endpointGroupWithTeam := &portainer.EndpointGroup{ID: 2, Name: "endpoint-group-with-team", TeamAccessPolicies: teamAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithTeam) + is.NoError(err, "error creating endpoint group") + + // create endpoint + endpointUnderGroupWithTeam := &portainer.Endpoint{ID: 2, GroupID: endpointGroupWithTeam.ID} + err = store.Endpoint().Create(endpointUnderGroupWithTeam) + is.NoError(err, "error creating endpoint") + + t.Run("admin user can list teams who inherit endpoint access from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) + req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamUnderGroup.ID, resp[0].ID) + } + }) +} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go index 610b17ae2..d20e0b0d0 100644 --- a/api/http/handler/users/user_list.go +++ b/api/http/handler/users/user_list.go @@ -4,7 +4,9 @@ import ( "net/http" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -18,6 +20,7 @@ import ( // @security ApiKeyAuth // @security jwt // @produce json +// @param environmentId query int false "Identifier of the environment(endpoint) that will be used to filter the authorized users" // @success 200 {array} portainer.User "Success" // @failure 400 "Invalid request" // @failure 500 "Server error" @@ -33,11 +36,45 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper return httperror.InternalServerError("Unable to retrieve info from request context", err) } - filteredUsers := security.FilterUsers(users, securityContext) - - for idx := range filteredUsers { - hideFields(&filteredUsers[idx]) + availableUsers := security.FilterUsers(users, securityContext) + for i := range availableUsers { + hideFields(&availableUsers[i]) } - return response.JSON(w, filteredUsers) + endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true) + if endpointID == 0 { + return response.JSON(w, availableUsers) + } + + // filter out users who do not have access to the specific endpoint + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve endpoint from the database", err) + } + + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + canAccessEndpoint := make([]portainer.User, 0) + for _, user := range availableUsers { + // the users who have the endpoint authorization + if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok { + canAccessEndpoint = append(canAccessEndpoint, user) + continue + } + + // the user inherits the endpoint access from team or environment group + teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve team membership from the database", err) + } + + if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) { + canAccessEndpoint = append(canAccessEndpoint, user) + } + } + + return response.JSON(w, canAccessEndpoint) } diff --git a/api/http/handler/users/user_list_test.go b/api/http/handler/users/user_list_test.go new file mode 100644 index 000000000..40d4c7cf7 --- /dev/null +++ b/api/http/handler/users/user_list_test.go @@ -0,0 +1,285 @@ +package users + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/demo" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_userList(t *testing.T) { + is := assert.New(t) + + _, store, teardown := datastore.MustNewTestStore(t, true, true) + defer teardown() + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + is.NoError(err, "error creating admin user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, &demo.Service{}, passwordChecker) + h.DataStore = store + + // generate admin user tokens + adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + + // Case 1: the user is given the endpoint access directly + userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithEndpointAccess) + is.NoError(err, "error creating user") + + userWithoutEndpointAccess := &portainer.User{ID: 3, Username: "standard-user-without-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithoutEndpointAccess) + is.NoError(err, "error creating user") + + // create environment group + endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "default-endpoint-group"} + err = store.EndpointGroup().Create(endpointGroup) + is.NoError(err, "error creating endpoint group") + + // create endpoint and user access policies + userAccessPolicies := make(portainer.UserAccessPolicies, 0) + userAccessPolicies[userWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userWithEndpointAccess.Role)} + + endpointWithUserAccessPolicy := &portainer.Endpoint{ID: 1, UserAccessPolicies: userAccessPolicies, GroupID: endpointGroup.ID} + err = store.Endpoint().Create(endpointWithUserAccessPolicy) + is.NoError(err, "error creating endpoint") + + jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role}) + + t.Run("admin user can successfully list all users", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 3) + }) + + t.Run("admin user can list users who are given the endpoint access directly", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userWithEndpointAccess.ID, resp[0].ID) + } + }) + + t.Run("standard user cannot list amdin users", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 2) + if len(resp) > 0 { + for _, user := range resp { + is.NotEqual(portainer.AdministratorRole, user.Role) + } + } + }) + + // Case 2: the user is under an environment group and the environment group has endpoint access. + // the user inherits the endpoint access from the environment group + // create user + userUnderGroup := &portainer.User{ID: 4, Username: "standard-user-under-environment-group", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderGroup) + is.NoError(err, "error creating user") + + // create environment group including a user + userAccessPoliciesUnderGroup := make(portainer.UserAccessPolicies, 0) + userAccessPoliciesUnderGroup[userUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderGroup.Role)} + + endpointGroupWithUser := &portainer.EndpointGroup{ID: 2, Name: "endpoint-group-with-user", UserAccessPolicies: userAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithUser) + is.NoError(err, "error creating endpoint group") + + // create endpoint + endpointUnderGroupWithUser := &portainer.Endpoint{ID: 2, GroupID: endpointGroupWithUser.ID} + err = store.Endpoint().Create(endpointUnderGroupWithUser) + is.NoError(err, "error creating endpoint") + + t.Run("admin user can list users who inherit endpoint access from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderGroup.ID, resp[0].ID) + } + }) + + // Case 3: the user is under a team and the team is under an environment group. + // the environment group is given the endpoint access. + // both user and team should inherits the endpoint access from the environment group + // create a team including a user + teamUnderGroup := &portainer.Team{ID: 1, Name: "team-under-environment-group"} + err = store.Team().Create(teamUnderGroup) + is.NoError(err, "error creating team") + + userUnderTeam := &portainer.User{ID: 4, Username: "standard-user-under-team", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderTeam) + is.NoError(err, "error creating user") + + teamMembership := &portainer.TeamMembership{ID: 1, UserID: userUnderTeam.ID, TeamID: teamUnderGroup.ID} + err = store.TeamMembership().Create(teamMembership) + is.NoError(err, "error creating team membership") + + // create environment group including a team + teamAccessPoliciesUnderGroup := make(portainer.TeamAccessPolicies, 0) + teamAccessPoliciesUnderGroup[teamUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeam.Role)} + + endpointGroupWithTeam := &portainer.EndpointGroup{ID: 3, Name: "endpoint-group-with-team", TeamAccessPolicies: teamAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithTeam) + is.NoError(err, "error creating endpoint group") + + // create endpoint + endpointUnderGroupWithTeam := &portainer.Endpoint{ID: 3, GroupID: endpointGroupWithTeam.ID} + err = store.Endpoint().Create(endpointUnderGroupWithTeam) + is.NoError(err, "error creating endpoint") + t.Run("admin user can list users who inherit endpoint access from a team that inherit from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderTeam.ID, resp[0].ID) + } + }) + + // Case 4: the user is under a team and the team is given the endpoint access + // the user inherits the endpoint access from the team + // create a team including a user + teamWithEndpointAccess := &portainer.Team{ID: 2, Name: "team-with-endpoint-access"} + err = store.Team().Create(teamWithEndpointAccess) + is.NoError(err, "error creating team") + + userUnderTeamWithEndpointAccess := &portainer.User{ID: 5, Username: "standard-user-under-team-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderTeamWithEndpointAccess) + is.NoError(err, "error creating user") + + teamMembershipWithEndpointAccess := &portainer.TeamMembership{ID: 2, UserID: userUnderTeamWithEndpointAccess.ID, TeamID: teamWithEndpointAccess.ID} + err = store.TeamMembership().Create(teamMembershipWithEndpointAccess) + is.NoError(err, "error creating team membership") + + // create environment group + endpointGroupWithoutTeam := &portainer.EndpointGroup{ID: 4, Name: "endpoint-group-without-team"} + err = store.EndpointGroup().Create(endpointGroupWithoutTeam) + is.NoError(err, "error creating endpoint group") + + // create endpoint and team access policies + teamAccessPolicies := make(portainer.TeamAccessPolicies, 0) + teamAccessPolicies[teamWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeamWithEndpointAccess.Role)} + + endpointWithTeamAccessPolicy := &portainer.Endpoint{ID: 4, TeamAccessPolicies: teamAccessPolicies, GroupID: endpointGroupWithoutTeam.ID} + err = store.Endpoint().Create(endpointWithTeamAccessPolicy) + is.NoError(err, "error creating endpoint") + t.Run("admin user can list users who inherit endpoint access from a team", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderTeamWithEndpointAccess.ID, resp[0].ID) + } + }) +} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 52c70b8ef..29aec0408 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -113,10 +113,10 @@ func AuthorizedIsAdmin(context *RestrictedRequestContext) bool { return context.IsAdmin } -// authorizedEndpointAccess ensure that the user can access the specified environment(endpoint). +// AuthorizedEndpointAccess ensure that the user can access the specified environment(endpoint). // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the environment(endpoint) and the associated group. -func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { +func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) if !groupAccess { return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 233b73107..5f6f9bbb6 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -126,7 +126,7 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return err } - if !authorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + if !AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { return httperrors.ErrEndpointAccessDenied } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 3f746c890..518be87bd 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -7,21 +7,21 @@ import ( // FilterUserTeams filters teams based on user role. // non-administrator users only have access to team they are member of. func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { - filteredTeams := teams + if context.IsAdmin { + return teams + } - if !context.IsAdmin { - filteredTeams = make([]portainer.Team, 0) - for _, membership := range context.UserMemberships { - for _, team := range teams { - if team.ID == membership.TeamID { - filteredTeams = append(filteredTeams, team) - break - } + teamsAccessableToUser := make([]portainer.Team, 0) + for _, membership := range context.UserMemberships { + for _, team := range teams { + if team.ID == membership.TeamID { + teamsAccessableToUser = append(teamsAccessableToUser, team) + break } } } - return filteredTeams + return teamsAccessableToUser } // FilterLeaderTeams filters teams based on user role. @@ -52,19 +52,18 @@ func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext // FilterUsers filters users based on user role. // Non-administrator users only have access to non-administrator users. func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User { - filteredUsers := users + if context.IsAdmin { + return users + } - if !context.IsAdmin { - filteredUsers = make([]portainer.User, 0) - - for _, user := range users { - if user.Role != portainer.AdministratorRole { - filteredUsers = append(filteredUsers, user) - } + nonAdmins := make([]portainer.User, 0) + for _, user := range users { + if user.Role != portainer.AdministratorRole { + nonAdmins = append(nonAdmins, user) } } - return filteredUsers + return nonAdmins } // FilterRegistries filters registries based on user role and team memberships. @@ -96,7 +95,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint for _, endpoint := range endpoints { endpointGroup := getAssociatedGroup(&endpoint, groups) - if authorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { + if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } diff --git a/app/portainer/components/accessControlForm/porAccessControlFormController.js b/app/portainer/components/accessControlForm/porAccessControlFormController.js index 08b59b388..09b8550e6 100644 --- a/app/portainer/components/accessControlForm/porAccessControlFormController.js +++ b/app/portainer/components/accessControlForm/porAccessControlFormController.js @@ -4,12 +4,13 @@ import { ResourceControlOwnership as RCO } from '@/react/portainer/access-contro angular.module('portainer.app').controller('porAccessControlFormController', [ '$q', '$scope', + '$state', 'UserService', 'TeamService', 'Notifications', 'Authentication', 'ResourceControlService', - function ($q, $scope, UserService, TeamService, Notifications, Authentication, ResourceControlService) { + function ($q, $scope, $state, UserService, TeamService, Notifications, Authentication, ResourceControlService) { var ctrl = this; ctrl.RCO = RCO; @@ -54,9 +55,10 @@ angular.module('portainer.app').controller('porAccessControlFormController', [ ctrl.formData.Ownership = ctrl.RCO.ADMINISTRATORS; } + const environmentId = $state.params.endpointId; $q.all({ - availableTeams: TeamService.teams(), - availableUsers: isAdmin ? UserService.users(false) : [], + availableTeams: TeamService.teams(environmentId), + availableUsers: isAdmin ? UserService.users(false, environmentId) : [], }) .then(function success(data) { ctrl.availableUsers = _.orderBy(data.availableUsers, 'Username', 'asc'); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index f2a87e4c0..784e7d2fb 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -98,5 +98,6 @@ export const componentsModule = angular 'resourceControl', 'resourceId', 'resourceType', + 'environmentId', ]) ).name; diff --git a/app/portainer/rest/team.js b/app/portainer/rest/team.js index 528d8b692..b17a9ecc0 100644 --- a/app/portainer/rest/team.js +++ b/app/portainer/rest/team.js @@ -8,7 +8,7 @@ angular.module('portainer.app').factory('Teams', [ {}, { create: { method: 'POST', ignoreLoadingBar: true }, - query: { method: 'GET', isArray: true }, + query: { method: 'GET', isArray: true, params: { environmentId: '@environmentId' } }, get: { method: 'GET', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id' } }, queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, diff --git a/app/portainer/services/api/teamService.js b/app/portainer/services/api/teamService.js index 765b42e7a..b42ffca92 100644 --- a/app/portainer/services/api/teamService.js +++ b/app/portainer/services/api/teamService.js @@ -8,9 +8,9 @@ angular.module('portainer.app').factory('TeamService', [ 'use strict'; var service = {}; - service.teams = function () { + service.teams = function (environmentId) { var deferred = $q.defer(); - Teams.query() + Teams.query({ environmentId: environmentId }) .$promise.then(function success(data) { var teams = data.map(function (item) { return new TeamViewModel(item); diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js index fc09bc7cb..4201c9b38 100644 --- a/app/portainer/services/api/userService.js +++ b/app/portainer/services/api/userService.js @@ -9,8 +9,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) { 'use strict'; var service = {}; - service.users = async function (includeAdministrators) { - const users = await getUsers(includeAdministrators); + service.users = async function (includeAdministrators, environmentId) { + const users = await getUsers(includeAdministrators, environmentId); return users.map((u) => new UserViewModel(u)); }; diff --git a/app/portainer/users/queries.ts b/app/portainer/users/queries.ts index 34b57792b..3ea76edcb 100644 --- a/app/portainer/users/queries.ts +++ b/app/portainer/users/queries.ts @@ -34,16 +34,21 @@ export function useIsTeamLeader(user: User) { export function useUsers( includeAdministrator = false, + environmentId = 0, enabled = true, select: (data: User[]) => T = (data) => data as unknown as T ) { - const users = useQuery(['users'], () => getUsers(includeAdministrator), { - meta: { - error: { title: 'Failure', message: 'Unable to load users' }, - }, - enabled, - select, - }); + const users = useQuery( + ['users'], + () => getUsers(includeAdministrator, environmentId), + { + meta: { + error: { title: 'Failure', message: 'Unable to load users' }, + }, + enabled, + select, + } + ); return users; } diff --git a/app/portainer/users/user.service.ts b/app/portainer/users/user.service.ts index f9b2decf0..bef14691f 100644 --- a/app/portainer/users/user.service.ts +++ b/app/portainer/users/user.service.ts @@ -4,9 +4,14 @@ import { TeamMembership } from '@/react/portainer/users/teams/types'; import { User, UserId } from './types'; import { filterNonAdministratorUsers } from './user.helpers'; -export async function getUsers(includeAdministrators = false) { +export async function getUsers( + includeAdministrators = false, + environmentId = 0 +) { try { - const { data } = await axios.get(buildUrl()); + const { data } = await axios.get(buildUrl(), { + params: { environmentId }, + }); return includeAdministrators ? data : filterNonAdministratorUsers(data); } catch (e) { diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 0fb82c84c..5af957241 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -256,6 +256,7 @@ resource-id="stack.EndpointId + '_' + stack.Name" resource-control="stack.ResourceControl" resource-type="resourceType" + environment-id="stack.EndpointId" on-update-success="(onUpdateResourceControlSuccess)" > diff --git a/app/react/azure/container-instances/ItemView/ItemView.tsx b/app/react/azure/container-instances/ItemView/ItemView.tsx index 92475064c..4840ce78a 100644 --- a/app/react/azure/container-instances/ItemView/ItemView.tsx +++ b/app/react/azure/container-instances/ItemView/ItemView.tsx @@ -186,6 +186,7 @@ export function ItemView() { resourceId={id} resourceControl={container.resourceControl} resourceType={ResourceControlType.ContainerGroup} + environmentId={environmentId} /> ); diff --git a/app/react/docker/networks/ItemView/ItemView.tsx b/app/react/docker/networks/ItemView/ItemView.tsx index 3192666fb..b18bca3c7 100644 --- a/app/react/docker/networks/ItemView/ItemView.tsx +++ b/app/react/docker/networks/ItemView/ItemView.tsx @@ -89,6 +89,7 @@ export function ItemView() { resourceType={ResourceControlType.Network} disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)} resourceId={networkId} + environmentId={environmentId} /> ; } @@ -27,6 +29,7 @@ export function AccessControlPanel({ resourceType, disableOwnershipChange, resourceId, + environmentId, onUpdateSuccess, }: Props) { const [isEditMode, toggleEditMode] = useReducer((state) => !state, false); @@ -69,6 +72,7 @@ export function AccessControlPanel({ onCancelClick={() => toggleEditMode()} resourceId={resourceId} resourceType={resourceType} + environmentId={environmentId} onUpdateSuccess={handleUpdateSuccess} /> )} diff --git a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx index d2f7f8ec0..dcdfcf2ad 100644 --- a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx +++ b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx @@ -178,7 +178,7 @@ function InheritanceMessage({ } function useAuthorizedTeams(authorizedTeamIds: TeamId[]) { - return useTeams(false, { + return useTeams(false, 0, { enabled: authorizedTeamIds.length > 0, select: (teams) => { if (authorizedTeamIds.length === 0) { @@ -196,7 +196,7 @@ function useAuthorizedTeams(authorizedTeamIds: TeamId[]) { } function useAuthorizedUsers(authorizedUserIds: UserId[]) { - return useUsers(false, authorizedUserIds.length > 0, (users) => { + return useUsers(false, 0, authorizedUserIds.length > 0, (users) => { if (authorizedUserIds.length === 0) { return []; } diff --git a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx index 6365b29b1..78068c89e 100644 --- a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx +++ b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx @@ -6,6 +6,7 @@ import { object } from 'yup'; import { useUser } from '@/portainer/hooks/useUser'; import { confirmAsync } from '@/portainer/services/modal.service/confirm'; import { notifySuccess } from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/portainer/environments/types'; import { Button } from '@@/buttons'; import { LoadingButton } from '@@/buttons/LoadingButton'; @@ -27,6 +28,7 @@ interface Props { resourceType: ResourceControlType; resourceId: ResourceId; resourceControl?: ResourceControlViewModel; + environmentId?: EnvironmentId; onCancelClick(): void; onUpdateSuccess(): Promise; } @@ -35,6 +37,7 @@ export function AccessControlPanelForm({ resourceId, resourceType, resourceControl, + environmentId, onCancelClick, onUpdateSuccess, }: Props) { @@ -81,6 +84,7 @@ export function AccessControlPanelForm({ values={values.accessControl} isPublicVisible errors={errors.accessControl} + environmentId={environmentId} />
diff --git a/app/react/portainer/access-control/EditDetails/EditDetails.tsx b/app/react/portainer/access-control/EditDetails/EditDetails.tsx index ce870e891..157f8e3f3 100644 --- a/app/react/portainer/access-control/EditDetails/EditDetails.tsx +++ b/app/react/portainer/access-control/EditDetails/EditDetails.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { FormikErrors } from 'formik'; import { useUser } from '@/portainer/hooks/useUser'; +import { EnvironmentId } from '@/portainer/environments/types'; import { BoxSelector } from '@@/BoxSelector'; import { FormError } from '@@/form-components/FormError'; @@ -19,6 +20,7 @@ interface Props { isPublicVisible?: boolean; errors?: FormikErrors; formNamespace?: string; + environmentId?: EnvironmentId; } export function EditDetails({ @@ -27,10 +29,11 @@ export function EditDetails({ isPublicVisible = false, errors, formNamespace, + environmentId, }: Props) { const { user, isAdmin } = useUser(); - const { users, teams, isLoading } = useLoadState(); + const { users, teams, isLoading } = useLoadState(environmentId); const options = useOptions(isAdmin, teams, isPublicVisible); const handleChange = useCallback( diff --git a/app/react/portainer/access-control/EditDetails/useLoadState.ts b/app/react/portainer/access-control/EditDetails/useLoadState.ts index 991dcb6e0..ce44aa385 100644 --- a/app/react/portainer/access-control/EditDetails/useLoadState.ts +++ b/app/react/portainer/access-control/EditDetails/useLoadState.ts @@ -1,10 +1,11 @@ import { useTeams } from '@/react/portainer/users/teams/queries'; import { useUsers } from '@/portainer/users/queries'; +import { EnvironmentId } from '@/portainer/environments/types'; -export function useLoadState() { - const teams = useTeams(); +export function useLoadState(environmentId?: EnvironmentId) { + const teams = useTeams(false, environmentId); - const users = useUsers(false); + const users = useUsers(false, environmentId); return { teams: teams.data, diff --git a/app/react/portainer/users/teams/ListView/ListView.tsx b/app/react/portainer/users/teams/ListView/ListView.tsx index 28798ba01..c40c2f0ea 100644 --- a/app/react/portainer/users/teams/ListView/ListView.tsx +++ b/app/react/portainer/users/teams/ListView/ListView.tsx @@ -12,7 +12,7 @@ export function ListView() { const { isAdmin } = useUser(); const usersQuery = useUsers(false); - const teamsQuery = useTeams(!isAdmin, { enabled: !!usersQuery.data }); + const teamsQuery = useTeams(!isAdmin, 0, { enabled: !!usersQuery.data }); return ( <> diff --git a/app/react/portainer/users/teams/queries.ts b/app/react/portainer/users/teams/queries.ts index bd8c40755..176bb7151 100644 --- a/app/react/portainer/users/teams/queries.ts +++ b/app/react/portainer/users/teams/queries.ts @@ -14,6 +14,7 @@ import { Team, TeamId, TeamMembership, TeamRole } from './types'; export function useTeams( onlyLedTeams = false, + environmentId = 0, { enabled = true, select = (data) => data as unknown as T, @@ -23,8 +24,8 @@ export function useTeams( } = {} ) { const teams = useQuery( - ['teams', { onlyLedTeams }], - () => getTeams(onlyLedTeams), + ['teams', { onlyLedTeams, environmentId }], + () => getTeams(onlyLedTeams, environmentId), { meta: { error: { title: 'Failure', message: 'Unable to load teams' }, diff --git a/app/react/portainer/users/teams/teams.service.ts b/app/react/portainer/users/teams/teams.service.ts index 1195a1b46..1cbcf19bb 100644 --- a/app/react/portainer/users/teams/teams.service.ts +++ b/app/react/portainer/users/teams/teams.service.ts @@ -4,10 +4,10 @@ import { type UserId } from '@/portainer/users/types'; import { createTeamMembership } from './team-membership.service'; import { Team, TeamId, TeamMembership, TeamRole } from './types'; -export async function getTeams(onlyLedTeams = false) { +export async function getTeams(onlyLedTeams = false, environmentId = 0) { try { const { data } = await axios.get(buildUrl(), { - params: { onlyLedTeams }, + params: { onlyLedTeams, environmentId }, }); return data; } catch (error) {