Files
portainer/api/http/handler/websocket/shell_pod_test.go
T
Phil Calder f596c862b3 fix(websocket): enforce environment authorization on kubernetes-shell [BE-13027] (#2774)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2026-06-22 15:09:41 +12:00

102 lines
4.0 KiB
Go

package websocket
import (
"net/http"
"net/http/httptest"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newWebsocketTestHandler builds a websocket Handler backed by a test store with a single
// Kubernetes environment (ID 2) and a real bouncer, returning both the handler and that
// endpoint so callers can grant access via its UserAccessPolicies. KubernetesClientFactory is
// left nil so any handler that proceeds past authorization trips a clear panic. Shared by the
// exec/attach/pod/kubernetes-shell L2 tests (BE-13027).
func newWebsocketTestHandler(t *testing.T) (*Handler, *portainer.Endpoint) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
endpoint := &portainer.Endpoint{
ID: 2,
Name: "target-env",
Type: portainer.AgentOnKubernetesEnvironment,
GroupID: 1,
}
require.NoError(t, store.Endpoint().Create(endpoint))
bouncer := security.NewRequestBouncer(t.Context(), store, nil, nil)
handler := &Handler{
DataStore: store,
requestBouncer: bouncer,
// KubernetesClientFactory intentionally left nil.
}
return handler, endpoint
}
// TestWebsocketShellPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access
// policy on the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketShellPodExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketShellPodExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
// TestWebsocketShellPodExec_allowsAuthorizedEndpoint asserts an admin passes authorization and
// reaches the nil KubernetesClientFactory (panic proves auth did not block the request) (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}
// TestWebsocketShellPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment
// access passes authorization (reaching the nil client and panicking). CE has no operation-level
// (L3) layer, so environment access is the only gate (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedNonAdmin(t *testing.T) {
handler, endpoint := newWebsocketTestHandler(t)
user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.User().Create(user); err != nil {
return err
}
// Access is by membership; the access policy's role is irrelevant to the CE access decision.
endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}}
return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}