fix(api/workflows): kubernetes UAC (#2508)
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user