diff --git a/api/http/handler/gitops/workflows/filter.go b/api/http/handler/gitops/workflows/filter.go index 46ccd51eb..a063688bb 100644 --- a/api/http/handler/gitops/workflows/filter.go +++ b/api/http/handler/gitops/workflows/filter.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/set" "github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/stacks/stackutils" @@ -46,12 +47,21 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma return m, nil } -func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) { +type endpointAccess struct { + isKubeAdmin bool + nonAdminNamespaces []string +} + +// filterDockerStacksByAccess filters stacks to only those the current user can access. +func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) { if sc.IsAdmin { return stacks, nil } - stackResourceIDSet := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) string { + // do not try to check UAC on kube stacks + filtered, dockerStacks := slicesx.Partition(stacks, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack }) + + stackResourceIDSet := set.ToSet(slicesx.Map(dockerStacks, func(s portainer.Stack) string { return stackutils.ResourceControlID(s.EndpointID, s.Name) })) @@ -62,8 +72,51 @@ func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, return nil, err } - stacks = authorization.DecorateStacks(stacks, resourceControls) + dockerStacks = authorization.DecorateStacks(dockerStacks, resourceControls) userTeamIDs := authorization.TeamIDs(sc.UserMemberships) - return authorization.FilterAuthorizedStacks(stacks, sc.UserID, userTeamIDs), nil + filtered = append(filtered, authorization.FilterAuthorizedStacks(dockerStacks, sc.UserID, userTeamIDs)...) + return filtered, nil +} + +func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) { + if sc.IsAdmin { + return endpointAccess{isKubeAdmin: true}, nil + } + + pcli, err := k8sFactory.GetPrivilegedKubeClient(ep) + if err != nil { + return endpointAccess{}, fmt.Errorf("unable to get privileged kube client for endpoint %d: %w", ep.ID, err) + } + + teamIDs := make([]int, 0, len(sc.UserMemberships)) + for _, m := range sc.UserMemberships { + teamIDs = append(teamIDs, int(m.TeamID)) + } + + nonAdminNamespaces, err := pcli.GetNonAdminNamespaces(int(sc.UserID), teamIDs, ep.Kubernetes.Configuration.RestrictDefaultNamespace) + if err != nil { + return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err) + } + + return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil +} + +func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) { + result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap)) + + for epID, ep := range endpointMap { + if !endpointutils.IsKubernetesEndpoint(&ep) { + continue + } + + access, err := resolveKubeAccess(k8sFactory, sc, &ep) + if err != nil { + return nil, err + } + + result[epID] = access + } + + return result, nil } diff --git a/api/http/handler/gitops/workflows/filter_test.go b/api/http/handler/gitops/workflows/filter_test.go index ccaca02a6..283051f7b 100644 --- a/api/http/handler/gitops/workflows/filter_test.go +++ b/api/http/handler/gitops/workflows/filter_test.go @@ -5,10 +5,17 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" "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/kubernetes/cli" "github.com/portainer/portainer/api/stacks/stackutils" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -78,3 +85,274 @@ func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) { require.Len(t, items, 1) assert.Equal(t, stackName, items[0].Name) } + +func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack} + dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack} + + stacks := []portainer.Stack{kubeStack, dockerStack} + + var result []portainer.Stack + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + result, txErr = filterDockerStacksByAccess(tx, stacks, sc) + + return txErr + }) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "kube-stack", result[0].Name) +} + +func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}, + {ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}, + } + + result, err := filterDockerStacksByAccess(nil, stacks, sc) + require.NoError(t, err) + require.Len(t, result, 2) +} + +func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + 2: {ID: 2, Type: portainer.DockerEnvironment}, + } + + result, err := buildEndpointAccessMap(nil, sc, endpointMap) + require.NoError(t, err) + require.Len(t, result, 1) + require.True(t, result[1].isKubeAdmin) + require.Empty(t, result[1].nonAdminNamespaces) +} + +func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "default", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) + assert.Equal(t, "default", result[0].Namespace) +} + +func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} + +func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) +} + +func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 5}, + }, + } + + access, err := resolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.isKubeAdmin) + require.Equal(t, []string{"default"}, access.nonAdminNamespaces) +} + +func TestResolveKubeAccess_NonAdmin(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + access, err := resolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.isKubeAdmin) + require.Equal(t, []string{"default"}, access.nonAdminNamespaces) +} + +func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: false, nonAdminNamespaces: []string{}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} diff --git a/api/http/handler/gitops/workflows/list.go b/api/http/handler/gitops/workflows/list.go index dc744cbf9..47f84e6c1 100644 --- a/api/http/handler/gitops/workflows/list.go +++ b/api/http/handler/gitops/workflows/list.go @@ -157,7 +157,7 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq return err } - stacks, err = filterStacksByAccess(tx, stacks, sc) + stacks, err = filterDockerStacksByAccess(tx, stacks, sc) if err != nil { return err } @@ -176,7 +176,12 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq if err != nil { return nil, err } - entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, sc.UserID) + accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap) + if err != nil { + return nil, err + } + + entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap) if err != nil { return nil, err } @@ -197,7 +202,7 @@ func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool { (endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode)) } -func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, userId portainer.UserID) ([]portainer.Stack, error) { +func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) { k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack }) @@ -212,10 +217,15 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint continue } - kcl, err := k8sFactory.GetPrivilegedUserKubeClient(&ep, userId) + kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep) if err != nil { return nil, err } + + access := accessMap[envID] + kcl.SetIsKubeAdmin(access.isKubeAdmin) + kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces) + apps, err := kcl.GetApplications("", "") if err != nil { return nil, err